Merge pull request #3 from Fretuks/claude/brave-mccarthy-qvr20u

Convert combat phase system to coroutine-based with pacing delays
This commit is contained in:
2026-07-08 18:27:39 +02:00
committed by GitHub
3 changed files with 85 additions and 67 deletions
+71 -51
View File
@@ -2,8 +2,10 @@ using KernelPanic.Core;
using KernelPanic.Data; using KernelPanic.Data;
using KernelPanic.Run; using KernelPanic.Run;
using KernelPanic.Meta; using KernelPanic.Meta;
using KernelPanic.UI;
using UnityEngine; using UnityEngine;
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -44,6 +46,7 @@ namespace KernelPanic.Combat
private int rawhideBonusCharges; private int rawhideBonusCharges;
private int queuedRepeatCharges; private int queuedRepeatCharges;
private int nextTurnCycleBonus; private int nextTurnCycleBonus;
private Coroutine phaseCoroutine;
public TurnPhase CurrentPhase => currentPhase; public TurnPhase CurrentPhase => currentPhase;
public RunConfig RunConfig => runConfig; public RunConfig RunConfig => runConfig;
@@ -116,7 +119,12 @@ namespace KernelPanic.Combat
awaitingWaveContinue = false; awaitingWaveContinue = false;
DecayJavaWarmupForNextWave(); DecayJavaWarmupForNextWave();
StartWave(preservePlayerUptime: true); StartCoroutine(ContinueToNextWaveSequenced());
}
private IEnumerator ContinueToNextWaveSequenced()
{
yield return StartWaveSequenced(preservePlayerUptime: true);
SetPhase(TurnPhase.Allocate); SetPhase(TurnPhase.Allocate);
} }
@@ -458,7 +466,13 @@ namespace KernelPanic.Combat
TurnPhase previousPhase = currentPhase; TurnPhase previousPhase = currentPhase;
currentPhase = nextPhase; currentPhase = nextPhase;
GameEvents.RaisePhaseChanged(new PhaseChangedEvent(previousPhase, nextPhase)); GameEvents.RaisePhaseChanged(new PhaseChangedEvent(previousPhase, nextPhase));
EnterPhase(nextPhase);
if (phaseCoroutine != null)
{
StopCoroutine(phaseCoroutine);
}
phaseCoroutine = StartCoroutine(EnterPhase(nextPhase));
} }
private static TurnPhase GetNextPhase(TurnPhase phase) private static TurnPhase GetNextPhase(TurnPhase phase)
@@ -474,52 +488,46 @@ namespace KernelPanic.Combat
}; };
} }
private void EnterPhase(TurnPhase phase) private IEnumerator EnterPhase(TurnPhase phase)
{ {
switch (phase) switch (phase)
{ {
case TurnPhase.Boot: case TurnPhase.Boot:
BootCombat(); yield return BootCombatSequenced();
if (!IsCombatPaused)
{
AdvancePhase();
}
break; break;
case TurnPhase.Allocate: case TurnPhase.Allocate:
AllocateTurn(); yield return AllocateTurnSequenced();
if (!IsCombatPaused)
{
AdvancePhase();
}
break; break;
case TurnPhase.Execute: case TurnPhase.Execute:
StateChanged?.Invoke(); StateChanged?.Invoke();
break; yield break;
case TurnPhase.Interpret: case TurnPhase.Interpret:
InterpretQueue(); yield return InterpretQueueSequenced();
if (!IsCombatPaused)
{
AdvancePhase();
}
break; break;
case TurnPhase.EnemyProcess: case TurnPhase.EnemyProcess:
ProcessEnemies(); yield return ProcessEnemiesSequenced();
if (!IsCombatPaused)
{
AdvancePhase();
}
break; break;
case TurnPhase.GarbageCollection: case TurnPhase.GarbageCollection:
GarbageCollect(); GarbageCollect();
break;
}
if (!IsCombatPaused) if (!IsCombatPaused)
{ {
yield return Wait(CombatTuning.PhaseTransitionDelaySeconds);
AdvancePhase(); AdvancePhase();
} }
break;
}
} }
private void BootCombat() private static IEnumerator Wait(float seconds)
{
if (!UIPreferences.ReducedMotion && seconds > 0f)
{
yield return new WaitForSeconds(seconds);
}
}
private IEnumerator BootCombatSequenced()
{ {
runLost = false; runLost = false;
awaitingWaveContinue = false; awaitingWaveContinue = false;
@@ -537,12 +545,12 @@ namespace KernelPanic.Combat
RandomRoll.Seed(runConfig.RunSeed); RandomRoll.Seed(runConfig.RunSeed);
playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles());
ApplyVersionState(playerState); ApplyVersionState(playerState);
StartWave(preservePlayerUptime: false); yield return StartWaveSequenced(preservePlayerUptime: false);
Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards"); Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards");
StateChanged?.Invoke(); StateChanged?.Invoke();
} }
private void StartWave(bool preservePlayerUptime) private IEnumerator StartWaveSequenced(bool preservePlayerUptime)
{ {
int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime; int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime;
if (!preservePlayerUptime || playerState == null) if (!preservePlayerUptime || playerState == null)
@@ -584,15 +592,16 @@ namespace KernelPanic.Combat
enemies.Clear(); enemies.Clear();
SpawnStructuralEnemies(); SpawnStructuralEnemies();
PickEnemyIntents(); PickEnemyIntents();
DrawOpeningHand(); StateChanged?.Invoke();
yield return DrawOpeningHandSequenced();
skipNextAllocateDraw = true; skipNextAllocateDraw = true;
} }
private void AllocateTurn() private IEnumerator AllocateTurnSequenced()
{ {
if (CheckLoss()) if (CheckLoss())
{ {
return; yield break;
} }
playerState.Cycles = playerState.MaxCycles; playerState.Cycles = playerState.MaxCycles;
@@ -606,9 +615,10 @@ namespace KernelPanic.Combat
nextTurnCycleBonus = 0; nextTurnCycleBonus = 0;
} }
statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline); statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline);
StateChanged?.Invoke();
if (CheckLoss()) if (CheckLoss())
{ {
return; yield break;
} }
TryApplyUbuntuAptUpdate(); TryApplyUbuntuAptUpdate();
@@ -619,13 +629,13 @@ namespace KernelPanic.Combat
} }
else else
{ {
DrawForTurn(); yield return DrawForTurnSequenced();
} }
StateChanged?.Invoke(); StateChanged?.Invoke();
} }
private void InterpretQueue() private IEnumerator InterpretQueueSequenced()
{ {
while (interpreterQueue.TryDequeue(out CardInstance card)) while (interpreterQueue.TryDequeue(out CardInstance card))
{ {
@@ -636,24 +646,27 @@ namespace KernelPanic.Combat
ExecuteCardEffects(card, ResolveQueuedTargets(card)); ExecuteCardEffects(card, ResolveQueuedTargets(card));
if (CheckWinOrLoss()) if (CheckWinOrLoss())
{ {
return; yield break;
} }
} }
ResolveCardRiders(card); ResolveCardRiders(card);
deckController.Discard(card); deckController.Discard(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue)); GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue));
StateChanged?.Invoke();
if (CheckWinOrLoss()) if (CheckWinOrLoss())
{ {
return; yield break;
} }
yield return Wait(CombatTuning.QueueCardResolveDelaySeconds);
} }
CheckWinOrLoss(); CheckWinOrLoss();
StateChanged?.Invoke(); StateChanged?.Invoke();
} }
private void ProcessEnemies() private IEnumerator ProcessEnemiesSequenced()
{ {
for (int i = 0; i < enemies.Count; i++) for (int i = 0; i < enemies.Count; i++)
{ {
@@ -667,7 +680,7 @@ namespace KernelPanic.Combat
RemoveDefeatedEnemies(); RemoveDefeatedEnemies();
if (CheckWinOrLoss()) if (CheckWinOrLoss())
{ {
return; yield break;
} }
if (enemy.State.IsDefeated) if (enemy.State.IsDefeated)
@@ -677,12 +690,18 @@ namespace KernelPanic.Combat
Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}"); Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}");
GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy)); GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy));
StateChanged?.Invoke();
yield return Wait(CombatTuning.EnemyTelegraphDelaySeconds);
ExecuteEnemyIntent(enemy); ExecuteEnemyIntent(enemy);
GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent)); GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent));
StateChanged?.Invoke();
if (CheckLoss()) if (CheckLoss())
{ {
return; yield break;
} }
yield return Wait(CombatTuning.EnemyActionDelaySeconds);
} }
PickEnemyIntents(); PickEnemyIntents();
@@ -710,13 +729,13 @@ namespace KernelPanic.Combat
StateChanged?.Invoke(); StateChanged?.Invoke();
} }
private void DrawOpeningHand() private IEnumerator DrawOpeningHandSequenced()
{ {
int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram); int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram);
DrawCardsToHand(openingDraw, "opening hand"); yield return DrawCardsToHandSequenced(openingDraw, "opening hand");
} }
private void DrawForTurn() private IEnumerator DrawForTurnSequenced()
{ {
int requested = CombatTuning.DrawPerTurn; int requested = CombatTuning.DrawPerTurn;
if (CombatTuning.MinimumHandFloor > 0 && handController.Cards.Count < CombatTuning.MinimumHandFloor && deckController.AvailableToDrawCount > 0) if (CombatTuning.MinimumHandFloor > 0 && handController.Cards.Count < CombatTuning.MinimumHandFloor && deckController.AvailableToDrawCount > 0)
@@ -724,31 +743,32 @@ namespace KernelPanic.Combat
requested = Mathf.Max(requested, CombatTuning.MinimumHandFloor - handController.Cards.Count); requested = Mathf.Max(requested, CombatTuning.MinimumHandFloor - handController.Cards.Count);
} }
DrawCardsToHand(requested, "turn draw"); yield return DrawCardsToHandSequenced(requested, "turn draw");
} }
private void DrawCardsToHand(int requestedCount, string label) private IEnumerator DrawCardsToHandSequenced(int requestedCount, string label)
{ {
if (handController == null || playerState == null || requestedCount <= 0) if (handController == null || playerState == null || requestedCount <= 0)
{ {
return; yield break;
} }
int room = handController.RemainingRam; int room = handController.RemainingRam;
if (room <= 0) if (room <= 0)
{ {
Log($"{label}: hand full"); Log($"{label}: hand full");
return; yield break;
} }
int drawCount = requestedCount; IReadOnlyList<CardInstance> drawn = deckController.Draw(requestedCount);
IReadOnlyList<CardInstance> drawn = deckController.Draw(drawCount);
int added = 0; int added = 0;
for (int i = 0; i < drawn.Count; i++) for (int i = 0; i < drawn.Count; i++)
{ {
if (handController.Add(drawn[i])) if (handController.Add(drawn[i]))
{ {
added++; added++;
StateChanged?.Invoke();
yield return Wait(CombatTuning.CardDrawDelaySeconds);
} }
else else
{ {
@@ -806,7 +826,7 @@ namespace KernelPanic.Combat
} }
ubuntuEmptyHandRefillUsed = true; ubuntuEmptyHandRefillUsed = true;
DrawCardsToHand(playerState.Ram, "ubuntu 24.04 refill"); StartCoroutine(DrawCardsToHandSequenced(playerState.Ram, "ubuntu 24.04 refill"));
} }
private bool CanApplyFedoraBonus() private bool CanApplyFedoraBonus()
@@ -902,7 +922,7 @@ namespace KernelPanic.Combat
return; return;
} }
DrawCardsToHand(card.DrawRider, "upgrade rider"); StartCoroutine(DrawCardsToHandSequenced(card.DrawRider, "upgrade rider"));
} }
private IReadOnlyList<CombatantState> CaptureTargets(CardInstance card) private IReadOnlyList<CombatantState> CaptureTargets(CardInstance card)
+9
View File
@@ -41,5 +41,14 @@ namespace KernelPanic.Combat
public const int StatUpgradeHeal = 5; public const int StatUpgradeHeal = 5;
public const int StatUpgradeRam = 1; public const int StatUpgradeRam = 1;
public const int UpgradeMagnitudeBonus = 2; public const int UpgradeMagnitudeBonus = 2;
// Combat pacing: gaps between phase transitions and per-item resolution steps so
// players can follow drawing/queue/enemy resolution instead of seeing it all at once.
// Skipped entirely when UIPreferences.ReducedMotion is set.
public const float PhaseTransitionDelaySeconds = 0.35f;
public const float CardDrawDelaySeconds = 0.12f;
public const float QueueCardResolveDelaySeconds = 0.45f;
public const float EnemyTelegraphDelaySeconds = 0.35f;
public const float EnemyActionDelaySeconds = 0.45f;
} }
} }
@@ -61,7 +61,6 @@ namespace KernelPanic.UI
private readonly Dictionary<CombatantState, int> previousUptime = new(); private readonly Dictionary<CombatantState, int> previousUptime = new();
private readonly Dictionary<CombatantState, int> previousShield = new(); private readonly Dictionary<CombatantState, int> previousShield = new();
private readonly HashSet<CardInstance> knownHandCards = new(); private readonly HashSet<CardInstance> knownHandCards = new();
private int queueCascadeIndex;
private void Awake() private void Awake()
{ {
@@ -1047,15 +1046,10 @@ namespace KernelPanic.UI
} }
if (payload.Track == ResolutionTrack.InterpreterQueue) if (payload.Track == ResolutionTrack.InterpreterQueue)
{
int delay = UIPreferences.ReducedMotion ? 0 : Mathf.Min(queueCascadeIndex * 120, 600);
queueCascadeIndex++;
ScheduleFeedback(() =>
{ {
VisualElement chip = queueChipElements.TryGetValue(payload.Card, out VisualElement queuedChip) ? queuedChip : interpreterStrip; VisualElement chip = queueChipElements.TryGetValue(payload.Card, out VisualElement queuedChip) ? queuedChip : interpreterStrip;
PlayElementBeat(chip, "queue-chip-resolve", 260); PlayElementBeat(chip, "queue-chip-resolve", 260);
FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard"); FlyCardGhost(payload.Card, chip, turnResourceGrid, "feedback-card-discard");
}, delay);
return; return;
} }
@@ -1069,11 +1063,6 @@ namespace KernelPanic.UI
private void HandlePhaseChanged(PhaseChangedEvent payload) private void HandlePhaseChanged(PhaseChangedEvent payload)
{ {
if (payload.NextPhase == TurnPhase.Interpret)
{
queueCascadeIndex = 0;
}
ScheduleFeedback(() => PlayElementBeat(phaseLabel, "phase-pulse", 260), 0); ScheduleFeedback(() => PlayElementBeat(phaseLabel, "phase-pulse", 260), 0);
} }