buff fedora, add elite enemies and add impact to attack

This commit is contained in:
2026-07-10 11:17:40 +02:00
parent e4db7af324
commit 37049a7c16
11 changed files with 832 additions and 114 deletions
+17
View File
@@ -1062,6 +1062,23 @@ namespace KernelPanic.Combat
or "arch_pacman_syu" or "arch_consult_wiki" or "arch_makepkg" or "shop_c_free" or "shop_c_sizeof" or "shop_c_kernel_exploit";
}
public static bool CanReceiveBleedingEdgeDamageBonus(CardDefinition definition)
{
if (definition == null || definition.ResolutionTrack != ResolutionTrack.Native)
{
return false;
}
return definition.Id is "lang_js_console_log" or "lang_js_fetch" or "lang_js_typeof" or "mint_fix_broken"
or "lang_rs_fn_main" or "lang_rs_unwrap"
or "lang_c_malloc" or "lang_c_printf" or "lang_c_pointer"
or "lang_java_public_static_main" or "lang_java_system_out_println" or "lang_java_new_object"
or "shop_js_math_random" or "shop_js_promise_all" or "ubuntu_snap_install"
or "mint_nemo" or "fedora_dnf_autoremove" or "fedora_first_to_package" or "fedora_dnf_update"
or "arch_pacman_syu" or "arch_consult_wiki" or "arch_makepkg"
or "shop_c_free" or "shop_c_sizeof" or "shop_c_kernel_exploit";
}
public static bool TargetsAllEnemies(CardDefinition definition)
{
return definition?.Id is "lang_py_for_loop";
+241 -15
View File
@@ -39,7 +39,7 @@ namespace KernelPanic.Combat
private bool _skipNextAllocateDraw;
private bool _ubuntuEmptyHandRefillUsed;
private int _fedoraCardsDiscountedThisTurn;
private int _fedoraCrashChance = 10;
private int _fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance;
private int _cardsPlayedThisTurn;
private int _cardsPlayedThisWave;
private int _cCardsPlayedThisTurn;
@@ -54,6 +54,7 @@ namespace KernelPanic.Combat
private int _queuedRepeatCharges;
private int _nextTurnCycleBonus;
private int _nextRacePairId = 1;
private int _activeKernelRamPenalty;
private Coroutine _phaseCoroutine;
public TurnPhase CurrentPhase => currentPhase;
@@ -71,6 +72,7 @@ namespace KernelPanic.Combat
public bool AwaitingWaveContinue => _awaitingWaveContinue;
public bool RunLost => _runLost;
public int CurrentWaveNumber => _runManager == null ? 1 : _runManager.CurrentWaveNumber;
public int ActiveKernelRamPenalty => _activeKernelRamPenalty;
public event Action StateChanged;
public event Action<string> CombatLog;
@@ -194,11 +196,11 @@ namespace KernelPanic.Combat
if (card.IsLocked)
{
Log($"{GetCardName(card)} is locked");
Log($"{GetCardName(card)} is locked; pay {EnemyArchetypeCatalog.DrmUnlockCycleCost} Cycle to unlock");
return false;
}
bool fedoraBonus = CanApplyFedoraBonus();
bool fedoraBonus = CanApplyFedoraBonus(card);
int cost = GetEffectiveCardCost(card);
if (fedoraBonus)
{
@@ -248,11 +250,13 @@ namespace KernelPanic.Combat
}
GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track));
ApplyTelemetryCardPlayed();
LogPlayIntent(card);
card.SetTargetSnapshot(CaptureTargets(card));
if (fedoraBonus)
{
bool firstFedoraBonusThisTurn = _fedoraCardsDiscountedThisTurn == 0;
_fedoraCardsDiscountedThisTurn++;
bool rawhideChargeUsed = _rawhideBonusCharges > 0;
if (rawhideChargeUsed)
@@ -262,7 +266,7 @@ namespace KernelPanic.Combat
if (!IsCrashImmune(card) && RandomRoll.RollRange(1, 100, new RollContext(_playerState)) <= _fedoraCrashChance)
{
_fedoraCrashChance = 10;
_fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance;
if (IsDistro("fedora") && _runConfig.DistroVersion >= 4)
{
_playerState.Cycles += 1;
@@ -276,10 +280,12 @@ namespace KernelPanic.Combat
return true;
}
_fedoraCrashChance = Mathf.Min(90, _fedoraCrashChance + 10);
_fedoraCrashChance = Mathf.Min(
CombatTuning.FedoraBleedingEdgeMaxCrashChance,
_fedoraCrashChance + CombatTuning.FedoraBleedingEdgeCrashChanceStep);
card.MarkFedoraNonCrashBonus();
_playerState.DamageMultiplierPercent = _runConfig.DistroVersion >= 2 ? 175 : 150;
ApplyFedoraGrowth(card, rawhideChargeUsed);
ApplyFedoraGrowth(card, rawhideChargeUsed, firstFedoraBonusThisTurn);
}
if (card.Definition.Language == Language.C && _cCardsPlayedThisTurn == 0 && HasPackageEffect(PackageEffectKind.FirstCThisTurnDamageMultiplier, out PackageEffectData cPackageEffect))
@@ -372,6 +378,27 @@ namespace KernelPanic.Combat
return true;
}
public bool TryUnlockCard(CardInstance card)
{
if (currentPhase != TurnPhase.Execute || card == null || !card.IsLocked || _playerState == null)
{
return false;
}
int cost = EnemyArchetypeCatalog.DrmUnlockCycleCost;
if (_playerState.Cycles < cost)
{
Log($"not enough cycles to unlock {GetCardName(card)}");
return false;
}
_playerState.Cycles -= cost;
card.IsLocked = false;
Log($"unlocked {GetCardName(card)}");
StateChanged?.Invoke();
return true;
}
public void ReportEffectResult(string message)
{
if (!string.IsNullOrWhiteSpace(message))
@@ -626,7 +653,7 @@ namespace KernelPanic.Combat
_awaitingWaveContinue = false;
_loggedEffectTodos.Clear();
_ubuntuEmptyHandRefillUsed = false;
_fedoraCrashChance = 10;
_fedoraCrashChance = CombatTuning.FedoraBleedingEdgeBaseCrashChance;
_fedoraCardsDiscountedThisTurn = 0;
_cardsPlayedThisTurn = 0;
_cardsPlayedThisWave = 0;
@@ -700,6 +727,7 @@ namespace KernelPanic.Combat
_interpreterQueue.Clear();
_enemies.Clear();
SpawnStructuralEnemies();
RecalculateKernelRamPressure();
PickEnemyIntents();
StateChanged?.Invoke();
yield return DrawOpeningHandSequenced();
@@ -715,6 +743,7 @@ namespace KernelPanic.Combat
}
RevivePendingEnemies();
UnlockHandCards("license check expired");
_playerState.Cycles = _playerState.MaxCycles;
_fedoraCardsDiscountedThisTurn = 0;
_cardsPlayedThisTurn = 0;
@@ -856,7 +885,7 @@ namespace KernelPanic.Combat
private IEnumerator DrawOpeningHandSequenced()
{
int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, _playerState.Ram);
int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, _handController == null ? _playerState.Ram : _handController.RamCapacity);
yield return DrawCardsToHandSequenced(openingDraw, "opening hand");
}
@@ -954,9 +983,87 @@ namespace KernelPanic.Combat
StartCoroutine(DrawCardsToHandSequenced(_playerState.Ram, "ubuntu 24.04 refill"));
}
private bool CanApplyFedoraBonus()
private bool CanApplyFedoraBonus(CardInstance card)
{
return CanApplyFedoraPassiveBonus() || _rawhideBonusCharges > 0;
return CardEffectFactory.CanReceiveBleedingEdgeDamageBonus(card?.Definition)
&& CardCanBenefitFromBleedingEdgeNow(card)
&& (CanApplyFedoraPassiveBonus() || _rawhideBonusCharges > 0);
}
private bool CardCanBenefitFromBleedingEdgeNow(CardInstance card)
{
string id = card?.Definition?.Id;
if (string.IsNullOrWhiteSpace(id))
{
return false;
}
if (id == "arch_consult_wiki")
{
return _handController == null || !HasBrokenHandCard();
}
if (id == "fedora_dnf_autoremove")
{
return HasJunkHandCard(card);
}
return true;
}
private bool HasBrokenHandCard()
{
if (_handController == null)
{
return false;
}
for (int i = 0; i < _handController.Cards.Count; i++)
{
if (_handController.Cards[i]?.IsBroken == true)
{
return true;
}
}
return false;
}
private bool HasJunkHandCard(CardInstance playedCard)
{
if (_handController == null)
{
return false;
}
for (int i = 0; i < _handController.Cards.Count; i++)
{
CardInstance card = _handController.Cards[i];
if (card != playedCard && IsJunkForDnfAutoremove(card))
{
return true;
}
}
return false;
}
private static bool IsJunkForDnfAutoremove(CardInstance card)
{
if (card == null)
{
return false;
}
string id = card.Definition?.Id ?? string.Empty;
string name = card.Definition?.DisplayName ?? string.Empty;
return card.Definition != null && card.Definition.IsToken
|| card.IsBroken
|| id.IndexOf("junk", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("nop", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("junk", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("NOP", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("broken", StringComparison.OrdinalIgnoreCase) >= 0;
}
private void DecayJavaWarmupForNextWave()
@@ -981,9 +1088,9 @@ namespace KernelPanic.Combat
return _fedoraCardsDiscountedThisTurn < limit;
}
private void ApplyFedoraGrowth(CardInstance card, bool rawhideChargeUsed)
private void ApplyFedoraGrowth(CardInstance card, bool rawhideChargeUsed, bool firstFedoraBonusThisTurn)
{
if (!IsDistro("fedora") || _runConfig.DistroVersion < 3 || card == null || !card.WasFirstCardThisTurn)
if (!IsDistro("fedora") || _runConfig.DistroVersion < 3 || card == null || !firstFedoraBonusThisTurn)
{
return;
}
@@ -1739,6 +1846,7 @@ namespace KernelPanic.Combat
_runManager?.AddBits(CombatTuning.BitsPerKill);
HandleEnemyDeath(enemy);
_enemies.RemoveAt(i);
RecalculateKernelRamPressure();
}
}
@@ -1803,6 +1911,87 @@ namespace KernelPanic.Combat
return candidates[index];
}
private void UnlockHandCards(string reason)
{
if (_handController == null)
{
return;
}
int unlocked = 0;
for (int i = 0; i < _handController.Cards.Count; i++)
{
CardInstance card = _handController.Cards[i];
if (card != null && card.IsLocked)
{
card.IsLocked = false;
unlocked++;
}
}
if (unlocked > 0)
{
Log($"{reason}: unlocked {unlocked} card(s)");
}
}
private void RecalculateKernelRamPressure()
{
if (_handController == null || _playerState == null || _runManager == null)
{
return;
}
int penalty = 0;
for (int i = 0; i < _enemies.Count; i++)
{
EnemyInstance enemy = _enemies[i];
if (enemy.HasBehavior(EnemyBehaviorFlags.RamPressure) && !enemy.State.IsDefeated)
{
penalty += EnemyArchetypeCatalog.KernelPanicRamPenalty;
}
}
_activeKernelRamPenalty = penalty;
int baseRam = _runManager.EffectiveRam();
int effectiveRam = Mathf.Max(1, baseRam - penalty);
_playerState.Ram = effectiveRam;
_handController.SetRamCapacity(effectiveRam);
}
private void ApplyTelemetryCardPlayed()
{
int collectors = 0;
for (int i = 0; i < _enemies.Count; i++)
{
EnemyInstance enemy = _enemies[i];
if (enemy.HasBehavior(EnemyBehaviorFlags.TelemetryCollector) && !enemy.State.IsDefeated)
{
enemy.AddTelemetryStack();
collectors++;
}
}
if (collectors > 0)
{
Log($"telemetry collector profiled card: +{EnemyArchetypeCatalog.TelemetryDamageGrowthPerCard} damage");
StateChanged?.Invoke();
}
}
private void LockRandomHandCard(EnemyInstance source)
{
CardInstance card = RandomHandCard(cardInstance => cardInstance != null && !cardInstance.IsBroken && !cardInstance.IsLocked);
if (card == null)
{
Log("license check: no unlockable hand card");
return;
}
card.IsLocked = true;
Log($"{source.Name} locked {GetCardName(card)}");
}
private void BreakRacePair(EnemyInstance defeated)
{
EnemyInstance survivor = RacePartner(defeated);
@@ -1829,7 +2018,8 @@ namespace KernelPanic.Combat
int count = Mathf.Clamp(CombatTuning.BaseEnemiesPerWave + countGrowth, CombatTuning.BaseEnemiesPerWave, CombatTuning.MaxEnemiesPerWave);
int waveUptimeBonus = Mathf.Max(0, wave - 1) * CombatTuning.EnemyUptimeGrowthPerWave;
bool rootkitSpawned = false;
for (int i = 0; i < count; i++)
bool eliteSpawned = TrySpawnEliteEncounter(wave, waveUptimeBonus);
for (int i = eliteSpawned ? 1 : 0; i < count; i++)
{
string archetypeId = CreatePlaceholderArchetypeId(wave, i);
if (archetypeId == "rootkit")
@@ -1860,6 +2050,33 @@ namespace KernelPanic.Combat
}
}
private bool TrySpawnEliteEncounter(int wave, int uptimeBonus)
{
if (wave < 5)
{
return false;
}
bool guaranteedIntro = wave == 5;
bool rolledElite = RandomRoll.RollRange(1, 100, RollContext.None) <= 35;
if (!guaranteedIntro && !rolledElite)
{
return false;
}
SpawnEnemy(CreateEliteArchetypeId(), uptimeBonus);
Log("elite process escorted by fodder");
// TODO: Replace this single-elite chance with authored elite encounter assets.
return true;
}
private static string CreateEliteArchetypeId()
{
string[] pool = { "daemon", "kernel_panic", "telemetry_collector", "drm_guardian" };
int index = RandomRoll.RollRange(0, pool.Length - 1, RollContext.None);
return pool[index];
}
private void SpawnRacePair(int uptimeBonus)
{
int pairId = _nextRacePairId++;
@@ -2002,15 +2219,24 @@ namespace KernelPanic.Combat
Log("TODO: buff intent needs enemy buff/status support.");
break;
case EnemyIntentKind.Special:
ExecuteSpecialEnemyIntent(enemy);
ExecuteSpecialEnemyIntent(enemy, value);
break;
}
enemy.AdvanceCountdownAfterAction();
}
private void ExecuteSpecialEnemyIntent(EnemyInstance enemy)
private void ExecuteSpecialEnemyIntent(EnemyInstance enemy, int value)
{
if (enemy.HasBehavior(EnemyBehaviorFlags.CardLocker))
{
LockRandomHandCard(enemy);
value = ApplyIncomingAttackMitigation(value);
DamageResult result = _damagePipeline.DealDamage(new DamageRequest(enemy.State, _playerState, value, enemy.CurrentIntent.DamageType, enemy.CurrentIntent.TrueDamage, false));
GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, result.FinalAmount));
return;
}
if (enemy.HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath))
{
enemy.AdvanceSegfaultCountdown();
+34
View File
@@ -41,6 +41,12 @@ 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 int HitMagnitudeModeratePercent = 10;
public const int HitMagnitudeMajorPercent = 25;
public const int HitMagnitudeMassivePercent = 45;
public static int ScaleStatUpgradeMaxUptime(int maxUptime)
{
@@ -61,4 +67,32 @@ namespace KernelPanic.Combat
public const float EnemyTelegraphDelaySeconds = 0.35f;
public const float EnemyActionDelaySeconds = 0.45f;
}
public enum HitMagnitudeTier
{
Minor,
Moderate,
Major,
Massive
}
public static class HitMagnitude
{
public static HitMagnitudeTier Classify(int damageAmount, int targetMaxUptime, bool targetDefeated)
{
int maxUptime = UnityEngine.Mathf.Max(1, targetMaxUptime);
int amount = UnityEngine.Mathf.Max(0, damageAmount);
int percent = UnityEngine.Mathf.FloorToInt((amount * 100f) / maxUptime);
HitMagnitudeTier tier = percent >= CombatTuning.HitMagnitudeMassivePercent
? HitMagnitudeTier.Massive
: percent >= CombatTuning.HitMagnitudeMajorPercent
? HitMagnitudeTier.Major
: percent >= CombatTuning.HitMagnitudeModeratePercent
? HitMagnitudeTier.Moderate
: HitMagnitudeTier.Minor;
return targetDefeated && tier < HitMagnitudeTier.Major ? HitMagnitudeTier.Major : tier;
}
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ 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));
GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language, amount, absorbedAmount, wasCritical, shieldDamage, uptimeDamage, request.TrueDamage));
bool defeated = false;
if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated)
+58 -3
View File
@@ -19,7 +19,16 @@ namespace KernelPanic.Combat
SegfaultOnDeath = 1 << 7,
RacePair = 1 << 8,
RootkitMasked = 1 << 9,
Elite = 1 << 10
Elite = 1 << 10,
RamPressure = 1 << 11,
TelemetryCollector = 1 << 12,
CardLocker = 1 << 13
}
public enum EnemyTier
{
Fodder,
Elite
}
public sealed class EnemyArchetypeDescriptor
@@ -30,7 +39,8 @@ namespace KernelPanic.Combat
int baseUptimeMin,
int baseUptimeMax,
EnemyBehaviorFlags behaviorFlags,
IReadOnlyList<EnemyIntent> intentPool)
IReadOnlyList<EnemyIntent> intentPool,
EnemyTier tier = EnemyTier.Fodder)
{
Id = string.IsNullOrWhiteSpace(id) ? "unknown_process" : id;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? Id : displayName;
@@ -38,6 +48,7 @@ namespace KernelPanic.Combat
BaseUptimeMax = Mathf.Max(BaseUptimeMin, baseUptimeMax);
BehaviorFlags = behaviorFlags;
IntentPool = intentPool ?? Array.Empty<EnemyIntent>();
Tier = tier;
}
public string Id { get; }
@@ -46,6 +57,7 @@ namespace KernelPanic.Combat
public int BaseUptimeMax { get; }
public EnemyBehaviorFlags BehaviorFlags { get; }
public IReadOnlyList<EnemyIntent> IntentPool { get; }
public EnemyTier Tier { get; }
}
/// <summary>
@@ -61,6 +73,9 @@ namespace KernelPanic.Combat
public const int ForkBombTotalCap = 5;
public const int RootkitMaskedDamagePercent = 10;
public const int RacePairDamageBonus = 2;
public const int KernelPanicRamPenalty = 2;
public const int TelemetryDamageGrowthPerCard = 1;
public const int DrmUnlockCycleCost = 1;
public const int CronInitialCountdown = 2;
public const int SegfaultInitialCountdown = 2;
@@ -106,7 +121,8 @@ namespace KernelPanic.Combat
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 6, 8, Language.C, "daemon hit", "!")
});
},
EnemyTier.Elite);
private static readonly EnemyArchetypeDescriptor Defender = new(
"firewalld",
@@ -176,6 +192,42 @@ namespace KernelPanic.Combat
new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "masked hit", "?")
});
private static readonly EnemyArchetypeDescriptor KernelPanic = new(
"kernel_panic",
"Kernel Panic",
25,
30,
EnemyBehaviorFlags.Elite | EnemyBehaviorFlags.RamPressure,
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 5, 7, Language.C, "panic hit", "!")
},
EnemyTier.Elite);
private static readonly EnemyArchetypeDescriptor TelemetryCollector = new(
"telemetry_collector",
"Telemetry Collector",
20,
25,
EnemyBehaviorFlags.Elite | EnemyBehaviorFlags.TelemetryCollector,
new[]
{
new EnemyIntent(EnemyIntentKind.Attack, 2, 3, Language.C, "profile hit", "!")
},
EnemyTier.Elite);
private static readonly EnemyArchetypeDescriptor DrmGuardian = new(
"drm_guardian",
"DRM Guardian",
25,
30,
EnemyBehaviorFlags.Elite | EnemyBehaviorFlags.CardLocker,
new[]
{
new EnemyIntent(EnemyIntentKind.Special, 4, 6, Language.C, "license check", "$")
},
EnemyTier.Elite);
public static EnemyArchetypeDescriptor Get(string id)
{
return id switch
@@ -189,6 +241,9 @@ namespace KernelPanic.Combat
"segfault" => Segfault,
"race_condition" => RaceCondition,
"rootkit" => Rootkit,
"kernel_panic" => KernelPanic,
"telemetry_collector" => TelemetryCollector,
"drm_guardian" => DrmGuardian,
_ => ZombieProcess
};
}
+26 -2
View File
@@ -46,14 +46,18 @@ namespace KernelPanic.Combat
public bool LethalHitThisTurn { 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 HasPendingMarker => PendingRevive || HasRevived;
public bool HasSpecialSignal => HasSplitSignal || HasSegfaultSignal || HasRaceSignal || HasRootkitSignal || HasEliteSignal || SpawnedFromDeath;
public bool HasSpecialSignal => HasSplitSignal || HasSegfaultSignal || HasRaceSignal || HasRootkitSignal || HasEliteSignal || HasRamPressureSignal || HasTelemetrySignal || HasCardLockerSignal || SpawnedFromDeath;
public bool HasSplitSignal => HasBehavior(EnemyBehaviorFlags.Split);
public bool HasSegfaultSignal => HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath);
public bool HasRaceSignal => HasBehavior(EnemyBehaviorFlags.RacePair);
public bool HasRootkitSignal => HasBehavior(EnemyBehaviorFlags.RootkitMasked);
public bool HasEliteSignal => HasBehavior(EnemyBehaviorFlags.Elite);
public bool HasEliteSignal => Archetype.Tier == EnemyTier.Elite || HasBehavior(EnemyBehaviorFlags.Elite);
public bool HasRamPressureSignal => HasBehavior(EnemyBehaviorFlags.RamPressure);
public bool HasTelemetrySignal => HasBehavior(EnemyBehaviorFlags.TelemetryCollector);
public bool HasCardLockerSignal => HasBehavior(EnemyBehaviorFlags.CardLocker);
public EnemyIntent DisplayIntent => BuildDisplayIntent();
public void PickNextIntent()
@@ -93,6 +97,14 @@ namespace KernelPanic.Combat
int maxValue = UnityEngine.Mathf.Min(CurrentIntent.MaxValue + growth, EnemyArchetypeCatalog.MemoryLeakAttackCapMax);
CurrentIntent = CurrentIntent.WithValues(minValue, maxValue, "growing leak");
}
if (HasBehavior(EnemyBehaviorFlags.TelemetryCollector) && CurrentIntent.Kind == EnemyIntentKind.Attack && TelemetryStacks > 0)
{
CurrentIntent = CurrentIntent.WithValues(
CurrentIntent.MinValue + TelemetryStacks,
CurrentIntent.MaxValue + TelemetryStacks,
"profiled hit");
}
}
public bool HasBehavior(EnemyBehaviorFlags behavior)
@@ -128,6 +140,18 @@ namespace KernelPanic.Combat
RaceEnrageStacks++;
}
public void AddTelemetryStack()
{
TelemetryStacks += EnemyArchetypeCatalog.TelemetryDamageGrowthPerCard;
if (CurrentIntent.Kind == EnemyIntentKind.Attack)
{
CurrentIntent = CurrentIntent.WithValues(
CurrentIntent.MinValue + EnemyArchetypeCatalog.TelemetryDamageGrowthPerCard,
CurrentIntent.MaxValue + EnemyArchetypeCatalog.TelemetryDamageGrowthPerCard,
"profiled hit");
}
}
public void ResetTurnLethalMarker()
{
LethalHitThisTurn = false;
+5
View File
@@ -54,6 +54,11 @@ namespace KernelPanic.Combat
return card != null && _cards.Remove(card);
}
public void SetRamCapacity(int ramCapacity)
{
RamCapacity = System.Math.Max(1, ramCapacity);
}
public void Clear()
{
_cards.Clear();
+8 -1
View File
@@ -162,11 +162,16 @@ namespace KernelPanic.Core
}
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)
: this(source, target, amount, language, incomingAmount, absorbedAmount, wasCritical, 0, amount, false)
{
}
public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical, int shieldDamage, int uptimeDamage)
: this(source, target, amount, language, incomingAmount, absorbedAmount, wasCritical, shieldDamage, uptimeDamage, false)
{
}
public DamageDealtEvent(CombatantState source, CombatantState target, int amount, Language language, int incomingAmount, int absorbedAmount, bool wasCritical, int shieldDamage, int uptimeDamage, bool trueDamage)
{
Source = source;
Target = target;
@@ -177,6 +182,7 @@ namespace KernelPanic.Core
WasCritical = wasCritical;
ShieldDamage = shieldDamage;
UptimeDamage = uptimeDamage;
TrueDamage = trueDamage;
}
public CombatantState Source { get; }
@@ -188,6 +194,7 @@ namespace KernelPanic.Core
public bool WasCritical { get; }
public int ShieldDamage { get; }
public int UptimeDamage { get; }
public bool TrueDamage { get; }
public bool WasFullyBlocked => IncomingAmount > 0 && UptimeDamage <= 0;
public bool WasMitigated => ShieldDamage > 0 || AbsorbedAmount > 0 || WasFullyBlocked;
}
+231 -19
View File
@@ -23,6 +23,7 @@ namespace KernelPanic.UI
private const string StyleResourcePath = "CombatScene";
private const string SharedScrollbarStyleResourcePath = "TerminalScrollbars";
private const string SharedRarityStyleResourcePath = "RarityPresentation";
private const int MaxFeedbackElements = 42;
private const string FilledCycle = "●";
private const string EmptyCycle = "○";
@@ -66,6 +67,9 @@ namespace KernelPanic.UI
private int _queueCascadeIndex;
private int _feedbackCascadeIndex;
private int _feedbackBeatVersion;
private int _rootShakeVersion;
private int _hitStopVersion;
private int _lastHitStopFrame = -1;
private void Awake()
{
@@ -143,6 +147,7 @@ namespace KernelPanic.UI
private void OnDisable()
{
Time.timeScale = 1f;
SettleRunRewardsIfNeeded();
_combatManager.StateChanged -= Refresh;
_combatManager.CombatLog -= HandleCombatLog;
@@ -432,6 +437,14 @@ namespace KernelPanic.UI
Label ram = new($"ram hand cap {_combatManager.HandController.UsedRam}/{_combatManager.HandController.RamCapacity}");
ram.AddToClassList("ram-note");
_playerPanel.Add(ram);
if (_combatManager.ActiveKernelRamPenalty > 0)
{
Label ramPressure = new($"! kernel panic: RAM -{_combatManager.ActiveKernelRamPenalty}");
ramPressure.AddToClassList("status-label");
ramPressure.AddToClassList("intent-attack");
_playerPanel.Add(ramPressure);
}
_playerPanel.Add(StatusBlock(state, true));
}
@@ -465,6 +478,7 @@ namespace KernelPanic.UI
card.text = string.Empty;
card.AddToClassList("enemy-card");
card.EnableInClassList("enemy-card-compact", compactEnemies);
card.EnableInClassList("enemy-card-elite", enemy.HasEliteSignal);
bool highlighted = _combatManager.PendingTargetCard != null || _combatManager.SelectedEnemyIndex == index;
if (highlighted)
{
@@ -823,7 +837,16 @@ namespace KernelPanic.UI
private VisualElement CardFace(CardInstance card)
{
Button button = new(() => PlayCardWithFeedback(card));
Button button = new(() =>
{
if (card.IsLocked)
{
UnlockCardWithFeedback(card);
return;
}
PlayCardWithFeedback(card);
});
button.text = string.Empty;
button.AddToClassList("hand-card");
if (_combatManager.PendingTargetCard == card)
@@ -839,7 +862,7 @@ namespace KernelPanic.UI
if (card.IsBroken || card.IsLocked)
{
button.AddToClassList("hand-card-unaffordable");
button.SetEnabled(false);
button.SetEnabled(!card.IsBroken);
}
PopulateCardFace(button, card);
@@ -874,7 +897,7 @@ namespace KernelPanic.UI
if (card.IsBroken || card.IsLocked)
{
Label state = new(card.IsBroken ? "segv corrupt" : "locked");
Label state = new(card.IsBroken ? "segv corrupt" : $"locked: pay {EnemyArchetypeCatalog.DrmUnlockCycleCost}c");
state.AddToClassList("status-label");
target.Add(state);
}
@@ -926,6 +949,21 @@ namespace KernelPanic.UI
row.Add(Tag("! elite", "intent-attack"));
}
if (enemy.HasRamPressureSignal)
{
row.Add(Tag($"# RAM -{EnemyArchetypeCatalog.KernelPanicRamPenalty}", "intent-attack"));
}
if (enemy.HasTelemetrySignal)
{
row.Add(Tag($"+ profile {enemy.TelemetryStacks}", "intent-special"));
}
if (enemy.HasCardLockerSignal)
{
row.Add(Tag($"$ lock {EnemyArchetypeCatalog.DrmUnlockCycleCost}c", "intent-special"));
}
if (enemy.HasSplitSignal)
{
string text = CountLivingArchetype(enemies, enemy.ArchetypeId) >= EnemyArchetypeCatalog.ForkBombTotalCap
@@ -1155,6 +1193,29 @@ namespace KernelPanic.UI
return false;
}
private bool UnlockCardWithFeedback(CardInstance card)
{
int cyclesBefore = _combatManager.PlayerState == null ? 0 : _combatManager.PlayerState.Cycles;
bool unlocked = _combatManager.TryUnlockCard(card);
if (unlocked)
{
int spent = Mathf.Max(0, cyclesBefore - (_combatManager.PlayerState == null ? cyclesBefore : _combatManager.PlayerState.Cycles));
if (spent > 0)
{
PlayElementBeat(_turnResourceGrid, "cycles-spent-beat", 220);
}
return true;
}
if (_handCardElements.TryGetValue(card, out VisualElement face))
{
PlayElementBeat(face, "feedback-denied", 220);
}
return false;
}
private void HandleCardPlayed(CardPlayedEvent payload)
{
if (payload.Card == null)
@@ -1216,24 +1277,30 @@ namespace KernelPanic.UI
{
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;
ScheduleCombatBeat(beatIndex =>
{
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";
}
string bypass = payload.TrueDamage ? "! " : string.Empty;
string text = shieldOnly
? $"{bypass}block {payload.ShieldDamage}"
: payload.WasFullyBlocked
? $"{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);
TriggerHitStop(tier);
TriggerRootShake(tier, isPlayer);
SpawnFloatingText(target, text, tone, beatIndex);
SpawnImpactMarker(target, payload.WasCritical, payload.WasMitigated, beatIndex);
SpawnImpactMarker(target, payload.WasCritical, payload.WasMitigated, beatIndex, tier, shieldOnly);
PlayElementBeat(target, beatClass, beatDuration);
if (isPlayer && payload.UptimeDamage > 0)
{
FlashDamageVignette(payload.WasCritical);
FlashDamageVignette(tier);
}
});
}
@@ -1308,6 +1375,80 @@ namespace KernelPanic.UI
_previousShield[state] = state.Shield;
}
private static string BuildDamageFloatClasses(HitMagnitudeTier tier, DamageDealtEvent payload, bool shieldOnly)
{
string classes = shieldOnly || payload.WasFullyBlocked ? "float-shield" : "float-damage";
classes += " " + TierFloatClass(tier);
if (payload.TrueDamage)
{
classes += " float-true";
}
return classes;
}
private static string BuildDamageBeatClass(HitMagnitudeTier tier, DamageDealtEvent payload, bool shieldOnly)
{
if (shieldOnly || payload.WasFullyBlocked)
{
return tier >= HitMagnitudeTier.Major ? "feedback-clang-major" : "feedback-clang";
}
if (payload.WasCritical || tier == HitMagnitudeTier.Massive)
{
return "feedback-crit";
}
return tier switch
{
HitMagnitudeTier.Minor => "feedback-hit-minor",
HitMagnitudeTier.Moderate => "feedback-hit",
HitMagnitudeTier.Major => "feedback-hit-major",
_ => "feedback-crit"
};
}
private static string TierFloatClass(HitMagnitudeTier tier)
{
return tier switch
{
HitMagnitudeTier.Minor => "float-tier-minor",
HitMagnitudeTier.Moderate => "float-tier-moderate",
HitMagnitudeTier.Major => "float-tier-major",
HitMagnitudeTier.Massive => "float-tier-massive",
_ => "float-tier-moderate"
};
}
private static string TierImpactClass(HitMagnitudeTier tier)
{
return tier switch
{
HitMagnitudeTier.Minor => "impact-tier-minor",
HitMagnitudeTier.Moderate => "impact-tier-moderate",
HitMagnitudeTier.Major => "impact-tier-major",
HitMagnitudeTier.Massive => "impact-tier-massive",
_ => "impact-tier-moderate"
};
}
private static int HitBeatDuration(HitMagnitudeTier tier, bool mitigated)
{
if (mitigated && tier < HitMagnitudeTier.Major)
{
return 220;
}
return tier switch
{
HitMagnitudeTier.Minor => 120,
HitMagnitudeTier.Moderate => 220,
HitMagnitudeTier.Major => 340,
HitMagnitudeTier.Massive => 460,
_ => 220
};
}
private VisualElement CombatantElement(CombatantState state)
{
if (state != null && _combatantElements.TryGetValue(state, out VisualElement element))
@@ -1375,6 +1516,7 @@ namespace KernelPanic.UI
return;
}
PruneFeedbackLayer();
Rect anchorRect = anchor.worldBound;
Rect rootRect = _root.worldBound;
Label label = new(text);
@@ -1412,18 +1554,20 @@ namespace KernelPanic.UI
ScheduleFeedback(() => label.RemoveFromHierarchy(), 520);
}
private void SpawnImpactMarker(VisualElement anchor, bool critical, bool mitigated, int cascadeOffset)
private void SpawnImpactMarker(VisualElement anchor, bool critical, bool mitigated, int cascadeOffset, HitMagnitudeTier tier = HitMagnitudeTier.Moderate, bool shieldOnly = false)
{
if (_feedbackLayer == null || anchor == null)
{
return;
}
PruneFeedbackLayer();
Rect anchorRect = anchor.worldBound;
Rect rootRect = _root.worldBound;
Label marker = new(critical ? "crit" : mitigated ? "clang" : "hit");
Label marker = new(shieldOnly ? "shield" : critical ? "crit" : mitigated ? "clang" : "hit");
marker.AddToClassList("impact-marker");
marker.AddToClassList(critical ? "impact-marker-crit" : mitigated ? "impact-marker-clang" : "impact-marker-hit");
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;
@@ -1444,6 +1588,65 @@ namespace KernelPanic.UI
ScheduleFeedback(() => marker.RemoveFromHierarchy(), 420);
}
private void TriggerHitStop(HitMagnitudeTier tier)
{
if (UIPreferences.ReducedMotion || tier < HitMagnitudeTier.Major || Time.frameCount == _lastHitStopFrame)
{
return;
}
_lastHitStopFrame = Time.frameCount;
int version = ++_hitStopVersion;
float duration = tier == HitMagnitudeTier.Massive ? 0.16f : 0.09f;
Time.timeScale = 0.04f;
ScheduleFeedback(() =>
{
if (_hitStopVersion == version)
{
Time.timeScale = 1f;
}
}, Mathf.RoundToInt(duration * 1000f));
}
private void TriggerRootShake(HitMagnitudeTier tier, bool playerHit)
{
if (UIPreferences.ReducedMotion || tier < HitMagnitudeTier.Major || _root == null)
{
return;
}
int version = ++_rootShakeVersion;
_root.RemoveFromClassList("root-shake-major");
_root.RemoveFromClassList("root-shake-massive");
_root.RemoveFromClassList("root-shake-player");
_root.AddToClassList(tier == HitMagnitudeTier.Massive ? "root-shake-massive" : "root-shake-major");
_root.EnableInClassList("root-shake-player", playerHit);
ScheduleFeedback(() =>
{
if (_rootShakeVersion != version)
{
return;
}
_root.RemoveFromClassList("root-shake-major");
_root.RemoveFromClassList("root-shake-massive");
_root.RemoveFromClassList("root-shake-player");
}, tier == HitMagnitudeTier.Massive ? 220 : 140);
}
private void PruneFeedbackLayer()
{
if (_feedbackLayer == null)
{
return;
}
while (_feedbackLayer.childCount >= MaxFeedbackElements)
{
_feedbackLayer.ElementAt(0).RemoveFromHierarchy();
}
}
private void FlyCardGhost(CardInstance card, VisualElement source, VisualElement destination, string className)
{
if (_feedbackLayer == null || card == null)
@@ -1514,21 +1717,29 @@ namespace KernelPanic.UI
ScheduleFeedback(() => ghost.RemoveFromHierarchy(), 360);
}
private void FlashDamageVignette(bool critical)
private void FlashDamageVignette(HitMagnitudeTier tier)
{
if (_damageVignette == null)
{
return;
}
PlayElementBeat(_damageVignette, critical ? "damage-vignette-crit" : "damage-vignette-on", UIPreferences.ReducedMotion ? 120 : critical ? 320 : 240);
string className = tier >= HitMagnitudeTier.Massive
? "damage-vignette-massive"
: tier >= HitMagnitudeTier.Major
? "damage-vignette-crit"
: "damage-vignette-on";
PlayElementBeat(_damageVignette, className, UIPreferences.ReducedMotion ? 120 : tier >= HitMagnitudeTier.Massive ? 360 : tier >= HitMagnitudeTier.Major ? 300 : 180);
}
private static void RemoveFeedbackBeatClasses(VisualElement element)
{
element.RemoveFromClassList("feedback-hit-minor");
element.RemoveFromClassList("feedback-hit");
element.RemoveFromClassList("feedback-hit-major");
element.RemoveFromClassList("feedback-crit");
element.RemoveFromClassList("feedback-clang");
element.RemoveFromClassList("feedback-clang-major");
element.RemoveFromClassList("feedback-block");
element.RemoveFromClassList("feedback-status");
element.RemoveFromClassList("feedback-boost");
@@ -1543,6 +1754,7 @@ namespace KernelPanic.UI
element.RemoveFromClassList("hand-card-drawn");
element.RemoveFromClassList("damage-vignette-on");
element.RemoveFromClassList("damage-vignette-crit");
element.RemoveFromClassList("damage-vignette-massive");
}
private void ScheduleCombatBeat(Action action, int holdMs = 0)