Implement too many changes to list

This commit is contained in:
2026-07-07 11:46:31 +02:00
parent 8f66e83b65
commit 28486bff8c
50 changed files with 6344 additions and 1131 deletions
+6
View File
@@ -35,3 +35,9 @@ MonoBehaviour:
- {fileID: 11400000, guid: a7d40000000000000000000000000004, type: 2}
- {fileID: 11400000, guid: a7d40000000000000000000000000005, type: 2}
- {fileID: 11400000, guid: a7d40000000000000000000000000006, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000001, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000002, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000003, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000004, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000005, type: 2}
- {fileID: 11400000, guid: a7d50000000000000000000000000006, type: 2}
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: AsyncDef
m_EditorClassIdentifier:
id: shop_py_async_def
displayName: async def
description: 'queue: draw 2. TODO: gain 1 Cycle next turn.'
flavorText: returns eventually. emotionally, at least.
language: 3
rarity: 1
cycleCost: 1
resolutionTrack: 1
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. Draw 2 works now; next-turn cycle grant waits for deferred-cycle-grant.
requiresSystem:
- deferred-cycle-grant
upgradeTarget: {fileID: 0}
effects: []
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: ListAppend
m_EditorClassIdentifier:
id: shop_py_list_append
displayName: list.append()
description: 'queue: deal 4. TODO: grows when re-queued.'
flavorText: mutable state, but make it cute.
language: 3
rarity: 1
cycleCost: 1
resolutionTrack: 1
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. Base deal 4 works now; permanent per-combat requeue growth waits for requeue-tracking.
requiresSystem:
- requeue-tracking
upgradeTarget: {fileID: 0}
effects: []
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: MathRandom
m_EditorClassIdentifier:
id: shop_js_math_random
displayName: Math.random()
description: deal 2-20.
flavorText: cryptographically unsuitable, emotionally perfect.
language: 4
rarity: 1
cycleCost: 1
resolutionTrack: 0
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. JavaScript damage range roll 2-20.
requiresSystem:
- damage-range-roll
upgradeTarget: {fileID: 0}
effects: []
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: PromiseAll
m_EditorClassIdentifier:
id: shop_js_promise_all
displayName: Promise.all()
description: deal 4-10 three times.
flavorText: all or nothing, except the stack traces.
language: 4
rarity: 1
cycleCost: 2
resolutionTrack: 0
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. Three independent JavaScript damage rolls.
requiresSystem:
- damage-range-roll
upgradeTarget: {fileID: 0}
effects: []
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: Spread
m_EditorClassIdentifier:
id: shop_js_spread
displayName: spread ...
description: 'TODO: copy the lowest-cost card in hand.'
flavorText: cloning by punctuation, naturally.
language: 4
rarity: 1
cycleCost: 1
resolutionTrack: 0
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. Requires card-copy.
requiresSystem:
- card-copy
upgradeTarget: {fileID: 0}
effects: []
+30
View File
@@ -0,0 +1,30 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3}
m_Name: Zip
m_EditorClassIdentifier:
id: shop_py_zip
displayName: zip()
description: 'TODO: next 2 queued cards resolve twice.'
flavorText: pairs nicely until lengths disagree.
language: 3
rarity: 1
cycleCost: 1
resolutionTrack: 1
distroExclusive: 0
isToken: 0
isRunOnly: 1
designNotes: Run-only Repository card. Requires queue-effect-multiply.
requiresSystem:
- queue-effect-multiply
upgradeTarget: {fileID: 0}
effects: []
+953
View File
@@ -0,0 +1,953 @@
:root {
--kp-bg: #030605;
--kp-bg-deep: #020403;
--kp-panel: #07110d;
--kp-panel-raised: #0b1812;
--kp-panel-track: #050b08;
--kp-panel-targeted: #10160d;
--kp-panel-hover: #0d2016;
--kp-action-hover: #111409;
--kp-text: #63ff91;
--kp-text-dim: #7a9783;
--kp-border: #14592f;
--kp-border-subtle: #0c2d1b;
--kp-border-meter: #0d3a22;
--kp-border-empty: #0a2416;
--kp-border-strong: #27d866;
--kp-warning: #ffb347;
--kp-beneficial: #57d8ff;
--kp-accent: #ff7a1a;
--kp-danger: #ff5f5f;
--kp-overlay: rgba(0, 0, 0, 0.88);
--kp-lang-c: #8ea4a9;
--kp-lang-cpp: #6da6ff;
--kp-lang-rust: #d78b4a;
--kp-lang-python: #f2ce61;
--kp-lang-javascript: #f7df1e;
--kp-lang-typescript: #5aa7ff;
--kp-lang-haskell: #b48cff;
--kp-lang-assembly: #ff6b6b;
--kp-lang-java: #ff8c42;
--kp-lang-go: #6de6ff;
--kp-lang-ruby: #ff5f7d;
--kp-lang-php: #9aa2ff;
}
.combat-root {
background-color: var(--kp-bg);
color: var(--kp-text);
font-size: 13px;
}
.combat-status-bar {
height: 34px;
padding-left: 10px;
padding-right: 10px;
flex-direction: row;
align-items: center;
background-color: var(--kp-bg-deep);
border-bottom-width: 1px;
border-bottom-color: var(--kp-border);
}
.status-cluster {
flex-direction: row;
align-items: center;
margin-right: 14px;
}
.status-label {
color: var(--kp-text-dim);
font-size: 11px;
margin-right: 5px;
}
.status-value {
color: var(--kp-text);
-unity-font-style: bold;
}
.phase-pill {
color: var(--kp-bg);
background-color: var(--kp-accent);
padding-left: 8px;
padding-right: 8px;
padding-top: 2px;
padding-bottom: 2px;
-unity-font-style: bold;
}
.status-spacer {
flex-grow: 1;
}
.seed-value {
color: var(--kp-text-dim);
font-size: 11px;
}
.main-layout {
flex-grow: 1;
min-height: 0;
flex-direction: row;
padding: 10px;
}
.bottom-layout {
min-height: 178px;
flex-direction: row;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 10px;
}
.panel {
min-height: 0;
padding: 10px;
background-color: var(--kp-panel);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.panel-header {
flex-direction: row;
align-items: center;
margin-bottom: 10px;
border-bottom-width: 1px;
border-bottom-color: var(--kp-border-subtle);
padding-bottom: 5px;
}
.panel-title {
color: var(--kp-text);
-unity-font-style: bold;
font-size: 15px;
flex-grow: 1;
}
.panel-subtitle {
color: var(--kp-text-dim);
font-size: 11px;
}
.field-panel {
flex-grow: 1;
margin-left: 10px;
margin-right: 10px;
}
.hand-panel {
flex-grow: 1;
}
.turn-panel {
margin-left: 10px;
}
.meter-block {
margin-bottom: 10px;
}
.meter-head {
flex-direction: row;
align-items: center;
margin-bottom: 4px;
}
.metric-label {
color: var(--kp-text-dim);
font-size: 11px;
flex-grow: 1;
}
.metric-value {
color: var(--kp-text);
-unity-font-style: bold;
}
.metric-value-muted {
color: var(--kp-text-dim);
}
.meter-track {
height: 13px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border-meter);
}
.meter-fill {
height: 100%;
background-color: var(--kp-text);
}
.meter-fill-warning {
background-color: var(--kp-warning);
}
.meter-fill-danger {
background-color: var(--kp-danger);
}
.meter-fill-beneficial {
background-color: var(--kp-beneficial);
}
.cycle-row {
margin-top: 2px;
margin-bottom: 10px;
}
.cycle-pips {
color: var(--kp-warning);
font-size: 17px;
-unity-font-style: bold;
}
.ram-note {
color: var(--kp-text-dim);
font-size: 11px;
margin-bottom: 8px;
}
.status-section-title {
color: var(--kp-text-dim);
font-size: 11px;
margin-top: 8px;
margin-bottom: 5px;
}
.status-row {
flex-direction: row;
flex-wrap: wrap;
}
.status-pip {
flex-direction: row;
align-items: center;
margin-right: 5px;
margin-bottom: 5px;
padding-left: 5px;
padding-right: 5px;
padding-top: 2px;
padding-bottom: 2px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.status-pip-harmful {
color: var(--kp-warning);
border-color: var(--kp-warning);
}
.status-pip-beneficial {
color: var(--kp-beneficial);
border-color: var(--kp-beneficial);
}
.status-pip-empty {
color: var(--kp-text-dim);
}
.status-tooltip {
color: var(--kp-text-dim);
font-size: 11px;
white-space: normal;
margin-bottom: 4px;
}
.track-zone {
flex-grow: 1;
min-height: 0;
margin-bottom: 8px;
padding: 8px;
background-color: var(--kp-panel-track);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.track-zone-queue {
border-left-color: var(--kp-lang-python);
}
.track-zone-stack {
border-left-color: var(--kp-lang-haskell);
}
.track-zone-token {
border-left-color: var(--kp-lang-go);
}
.track-header {
flex-direction: row;
align-items: center;
margin-bottom: 6px;
}
.track-title {
color: var(--kp-text);
-unity-font-style: bold;
flex-grow: 1;
}
.track-subtitle {
color: var(--kp-text-dim);
font-size: 11px;
}
.track-contents {
flex-grow: 1;
min-height: 36px;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
}
.empty-state {
color: var(--kp-text-dim);
font-size: 12px;
background-color: var(--kp-bg);
padding-left: 8px;
padding-right: 8px;
padding-top: 5px;
padding-bottom: 5px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border-empty);
}
.card-chip {
margin-right: 6px;
margin-bottom: 6px;
padding-left: 7px;
padding-right: 7px;
padding-top: 5px;
padding-bottom: 5px;
background-color: var(--kp-bg-deep);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.card-chip-name {
color: var(--kp-text);
-unity-font-style: bold;
font-size: 12px;
}
.card-chip-meta {
color: var(--kp-text-dim);
font-size: 10px;
}
.enemy-row {
flex-direction: row;
flex-wrap: wrap;
flex-grow: 1;
align-content: flex-start;
}
.enemy-card {
width: 132px;
min-height: 154px;
margin-right: 8px;
margin-bottom: 8px;
padding: 8px;
background-color: var(--kp-panel-raised);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
color: var(--kp-text);
}
.enemy-card:hover {
border-color: var(--kp-border-strong);
}
.enemy-card-targeted {
border-color: var(--kp-accent);
background-color: var(--kp-panel-targeted);
}
.enemy-name {
color: var(--kp-text);
-unity-font-style: bold;
font-size: 14px;
margin-bottom: 7px;
white-space: normal;
}
.enemy-shield {
color: var(--kp-beneficial);
font-size: 11px;
margin-top: 4px;
margin-bottom: 4px;
}
.intent-panel {
margin-top: 8px;
padding: 6px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border-meter);
}
.intent-label {
color: var(--kp-text-dim);
font-size: 10px;
}
.intent-value {
-unity-font-style: bold;
font-size: 18px;
}
.intent-attack {
color: var(--kp-warning);
border-color: var(--kp-warning);
}
.intent-defend {
color: var(--kp-beneficial);
border-color: var(--kp-beneficial);
}
.intent-special {
color: var(--kp-accent);
border-color: var(--kp-accent);
}
.hand-row {
flex-direction: row;
flex-grow: 1;
min-height: 0;
}
.hand-card {
width: 158px;
min-height: 146px;
margin-right: 8px;
padding: 8px;
background-color: var(--kp-panel-raised);
color: var(--kp-text);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.hand-card:hover {
border-color: var(--kp-border-strong);
background-color: var(--kp-panel-hover);
}
.hand-card-selected {
border-color: var(--kp-accent);
background-color: var(--kp-action-hover);
}
.hand-card-unaffordable {
opacity: 0.48;
}
.hand-card-rare {
border-right-color: var(--kp-beneficial);
}
.hand-card-epic {
border-right-color: var(--kp-lang-haskell);
}
.hand-card-legendary {
border-right-color: var(--kp-warning);
}
.card-top-row {
flex-direction: row;
align-items: center;
margin-bottom: 6px;
}
.card-cost {
width: 28px;
height: 28px;
margin-right: 7px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-warning);
color: var(--kp-warning);
-unity-text-align: middle-center;
-unity-font-style: bold;
font-size: 16px;
}
.card-name {
flex-grow: 1;
color: var(--kp-text);
-unity-font-style: bold;
font-size: 14px;
white-space: normal;
}
.tag-row {
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 7px;
}
.tag {
font-size: 10px;
padding-left: 5px;
padding-right: 5px;
padding-top: 2px;
padding-bottom: 2px;
margin-right: 5px;
margin-bottom: 4px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.track-tag {
color: var(--kp-text-dim);
}
.language-c {
color: var(--kp-lang-c);
border-color: var(--kp-lang-c);
}
.language-cpp {
color: var(--kp-lang-cpp);
border-color: var(--kp-lang-cpp);
}
.language-rust {
color: var(--kp-lang-rust);
border-color: var(--kp-lang-rust);
}
.language-python {
color: var(--kp-lang-python);
border-color: var(--kp-lang-python);
}
.language-javascript {
color: var(--kp-lang-javascript);
border-color: var(--kp-lang-javascript);
}
.language-typescript {
color: var(--kp-lang-typescript);
border-color: var(--kp-lang-typescript);
}
.language-haskell {
color: var(--kp-lang-haskell);
border-color: var(--kp-lang-haskell);
}
.language-assembly {
color: var(--kp-lang-assembly);
border-color: var(--kp-lang-assembly);
}
.language-java {
color: var(--kp-lang-java);
border-color: var(--kp-lang-java);
}
.language-go {
color: var(--kp-lang-go);
border-color: var(--kp-lang-go);
}
.language-ruby {
color: var(--kp-lang-ruby);
border-color: var(--kp-lang-ruby);
}
.language-php {
color: var(--kp-lang-php);
border-color: var(--kp-lang-php);
}
.card-effect {
color: var(--kp-text-dim);
white-space: normal;
font-size: 11px;
flex-grow: 1;
}
.turn-resource-grid {
margin-bottom: 10px;
}
.turn-stat-row {
flex-direction: row;
margin-bottom: 4px;
}
.turn-stat-label {
color: var(--kp-text-dim);
width: 72px;
font-size: 11px;
}
.turn-stat-value {
color: var(--kp-text);
-unity-font-style: bold;
}
.primary-action {
height: 44px;
background-color: var(--kp-bg-deep);
color: var(--kp-text);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-accent);
-unity-font-style: bold;
}
.primary-action:hover {
background-color: var(--kp-action-hover);
}
.log-line {
color: var(--kp-text-dim);
white-space: normal;
margin-top: 8px;
font-size: 11px;
}
.overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: var(--kp-overlay);
justify-content: center;
align-items: center;
}
.overlay-title {
color: var(--kp-warning);
-unity-font-style: bold;
font-size: 28px;
margin-bottom: 10px;
}
.overlay-line {
color: var(--kp-text);
margin-bottom: 6px;
}
.repository {
width: 900px;
height: 86%;
padding: 14px;
background-color: var(--kp-panel);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-accent);
}
.repository-header {
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.repository-header-copy {
flex-grow: 1;
}
.repository-wallet {
min-width: 132px;
padding: 8px;
background-color: var(--kp-bg-deep);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-accent);
}
.repository-wallet-value {
color: var(--kp-warning);
-unity-font-style: bold;
font-size: 22px;
}
.repository-body {
flex-grow: 1;
min-height: 0;
margin-top: 8px;
margin-bottom: 10px;
}
.repository-offers {
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 10px;
}
.repository-row {
margin-bottom: 8px;
padding: 8px;
background-color: var(--kp-panel-raised);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.repository-tile {
width: 270px;
min-height: 244px;
margin-right: 10px;
margin-bottom: 10px;
padding: 8px;
background-color: var(--kp-panel-raised);
border-left-width: 2px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.repository-tile-card {
border-left-color: var(--kp-accent);
}
.repository-tile-upgrade {
border-left-color: var(--kp-beneficial);
}
.repository-tile-stat {
border-left-color: var(--kp-warning);
min-height: 190px;
}
.repository-tile-top {
flex-direction: row;
align-items: center;
margin-bottom: 7px;
}
.repository-kind {
color: var(--kp-text-dim);
font-size: 11px;
flex-grow: 1;
}
.repository-price {
min-width: 46px;
padding-left: 7px;
padding-right: 7px;
padding-top: 3px;
padding-bottom: 3px;
color: var(--kp-bg);
background-color: var(--kp-warning);
-unity-font-style: bold;
-unity-text-align: middle-center;
}
.repository-price-warning {
color: var(--kp-warning);
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-warning);
}
.repository-name {
color: var(--kp-text);
-unity-font-style: bold;
margin-bottom: 4px;
}
.repository-description {
color: var(--kp-text-dim);
white-space: normal;
margin-bottom: 6px;
}
.repository-action {
height: 28px;
margin-top: 7px;
background-color: var(--kp-bg-deep);
color: var(--kp-text);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-accent);
-unity-font-style: bold;
}
.repository-card-content {
flex-grow: 1;
}
.repository-card-preview {
width: 100%;
min-height: 154px;
margin-right: 0;
}
.repository-run-only-tag {
color: var(--kp-accent);
border-color: var(--kp-accent);
}
.repository-upgrade-content {
flex-grow: 1;
}
.repository-upgrade-card {
width: 100%;
min-height: 126px;
margin-right: 0;
opacity: 0.72;
}
.repository-delta {
flex-direction: row;
align-items: center;
margin-top: 7px;
padding: 7px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-beneficial);
}
.repository-delta-icon {
color: var(--kp-beneficial);
-unity-font-style: bold;
font-size: 18px;
margin-right: 7px;
}
.repository-delta-text {
color: var(--kp-text);
white-space: normal;
flex-grow: 1;
}
.repository-stat-content {
flex-grow: 1;
justify-content: center;
}
.repository-stat-icon {
color: var(--kp-warning);
-unity-font-style: bold;
font-size: 30px;
margin-bottom: 10px;
}
.repository-footer {
flex-direction: row;
min-height: 44px;
flex-shrink: 0;
}
.repository-footer-action {
width: 210px;
margin-right: 8px;
}
.repository-leave-action {
flex-grow: 1;
}
.repository-leave-block {
flex-grow: 1;
}
.repository-banked-hint {
color: var(--kp-text-dim);
font-size: 11px;
margin-top: 4px;
-unity-text-align: middle-center;
}
.repository-remove {
margin-bottom: 2px;
padding: 8px;
background-color: var(--kp-bg-deep);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border-empty);
}
.repository-remove-list {
flex-direction: row;
flex-wrap: wrap;
}
.repo-remove-button {
height: 24px;
margin-right: 6px;
margin-bottom: 6px;
background-color: var(--kp-bg-deep);
color: var(--kp-text-dim);
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: var(--kp-border);
}
.repo-unaffordable {
opacity: 0.45;
}
.repo-sold {
opacity: 0.72;
border-color: var(--kp-text-dim);
}
+64 -33
View File
@@ -90,7 +90,6 @@ GameObject:
- component: {fileID: 100004}
- component: {fileID: 100003}
- component: {fileID: 100001}
- component: {fileID: 100002}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
@@ -106,38 +105,6 @@ AudioListener:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 100000}
m_Enabled: 1
--- !u!114 &100002
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 100000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
--- !u!20 &100003
Camera:
m_ObjectHideFlags: 0
@@ -206,6 +173,10 @@ GameObject:
serializedVersion: 6
m_Component:
- component: {fileID: 200001}
- component: {fileID: 200002}
- component: {fileID: 200003}
- component: {fileID: 200004}
- component: {fileID: 200005}
m_Layer: 0
m_Name: GameRoot
m_TagString: Untagged
@@ -229,3 +200,63 @@ Transform:
m_Father: {fileID: 0}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &200002
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 200000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_PanelSettings: {fileID: 11400000, guid: 7d68bd7978394c8295debbd403a39d80, type: 2}
m_ParentUI: {fileID: 0}
sourceAsset: {fileID: 0}
m_SortingOrder: 0
--- !u!114 &200003
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 200000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9f87432412de1ee46bf7d4c9c530d4c6, type: 3}
m_Name:
m_EditorClassIdentifier:
currentPhase: 0
--- !u!114 &200004
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 200000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 234cc6c5ca577b64ea44020f5514cebe, type: 3}
m_Name:
m_EditorClassIdentifier:
combatManager: {fileID: 200003}
currentWaveNumber: 0
isRunActive: 0
isPlayerDead: 0
--- !u!114 &200005
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 200000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b1100000000000000000000000000003, type: 3}
m_Name:
m_EditorClassIdentifier:
distroDatabase: {fileID: 11400000, guid: 4d88ca5446fa4d80b2fbd4b12697cb7c, type: 2}
cardDatabase: {fileID: 11400000, guid: 1632132e32bfc31e8a293140f5908aa3, type: 2}
languageDeckDatabase: {fileID: 11400000, guid: a7d20000000000000000000000000001, type: 2}
-33
View File
@@ -134,7 +134,6 @@ GameObject:
- component: {fileID: 519420032}
- component: {fileID: 519420031}
- component: {fileID: 519420029}
- component: {fileID: 519420030}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
@@ -150,38 +149,6 @@ AudioListener:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 519420028}
m_Enabled: 1
--- !u!114 &519420030
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 519420028}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
--- !u!20 &519420031
Camera:
m_ObjectHideFlags: 0
+447
View File
@@ -0,0 +1,447 @@
using System.Collections.Generic;
using System.Linq;
using KernelPanic.Core;
using KernelPanic.Data;
namespace KernelPanic.Combat
{
public sealed class DealDamageEffect : ICardEffect
{
private readonly int _minAmount;
private readonly int _maxAmount;
private readonly Language _language;
private readonly bool _trueDamage;
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)
{
_minAmount = minAmount;
_maxAmount = maxAmount;
_language = language;
_trueDamage = trueDamage;
_canCrit = canCrit;
_allEnemies = allEnemies;
}
public void Execute(CombatContext context)
{
IReadOnlyList<CombatantState> targets = _allEnemies
? context.Enemies.Where(enemy => enemy.State != null && !enemy.State.IsDefeated).Select(enemy => enemy.State).ToList()
: context.Targets;
if (StatusEffectController.Has(context.Source, StatusType.RaceCondition) && targets.Count > 1)
{
targets = ShuffleTargets(targets, context.Source);
}
for (int i = 0; i < targets.Count; i++)
{
CombatantState target = targets[i];
if (target == null || target.IsDefeated)
{
continue;
}
int minAmount = UpgradeMath.ScaleAmount(_minAmount, context.Card);
int maxAmount = UpgradeMath.ScaleAmount(_maxAmount, context.Card);
int amount = minAmount == maxAmount
? minAmount
: RandomRoll.RollRange(minAmount, maxAmount, new RollContext(context.Source));
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit));
}
}
private static IReadOnlyList<CombatantState> ShuffleTargets(IReadOnlyList<CombatantState> targets, CombatantState source)
{
List<CombatantState> shuffled = targets.ToList();
for (int i = shuffled.Count - 1; i > 0; i--)
{
int swapIndex = RandomRoll.RollRange(0, i, new RollContext(source));
(shuffled[i], shuffled[swapIndex]) = (shuffled[swapIndex], shuffled[i]);
}
return shuffled;
}
}
public sealed class ShieldEffect : ICardEffect
{
private readonly int _amount;
public ShieldEffect(int amount)
{
_amount = amount;
}
public void Execute(CombatContext context)
{
int amount = UpgradeMath.ScaleShield(_amount, context.Card);
context.Source.Shield += amount;
context.CombatManager.ReportEffectResult($"gained {amount} shield");
}
}
public sealed class ChanceDamageEffect : ICardEffect
{
private readonly int _damageAmount;
private readonly int _successPercent;
private readonly Language _language;
public ChanceDamageEffect(int damageAmount, int successPercent, Language language)
{
_damageAmount = damageAmount;
_successPercent = successPercent;
_language = language;
}
public void Execute(CombatContext context)
{
for (int i = 0; i < context.Targets.Count; i++)
{
CombatantState target = context.Targets[i];
if (target == null || target.IsDefeated)
{
continue;
}
int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source));
int damageAmount = UpgradeMath.ScaleAmount(_damageAmount, context.Card);
int amount = roll <= _successPercent ? damageAmount : 0;
context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false));
context.CombatManager.ReportEffectResult(amount == 0 ? "typeof -> NaN (0)" : $"typeof -> {amount}");
}
}
}
public sealed class ApplyStatusEffect : ICardEffect
{
private readonly StatusType _statusType;
private readonly int _stacks;
private readonly int _duration;
private readonly bool _targetSelf;
private readonly bool _skipNextTick;
public ApplyStatusEffect(StatusType statusType, int stacks, int duration, bool targetSelf, bool skipNextTick = false)
{
_statusType = statusType;
_stacks = stacks;
_duration = duration;
_targetSelf = targetSelf;
_skipNextTick = skipNextTick;
}
public void Execute(CombatContext context)
{
if (_targetSelf)
{
context.StatusEffects.Apply(context.Source, _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick);
return;
}
for (int i = 0; i < context.Targets.Count; i++)
{
context.StatusEffects.Apply(context.Targets[i], _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick);
}
}
}
public sealed class CleanseEffect : ICardEffect
{
private readonly bool _harmfulOnly;
public CleanseEffect(bool harmfulOnly)
{
_harmfulOnly = harmfulOnly;
}
public void Execute(CombatContext context)
{
if (_harmfulOnly)
{
context.StatusEffects.CleanseHarmful(context.Source);
}
}
}
public sealed class DrawEffect : ICardEffect
{
private readonly int _count;
public DrawEffect(int count)
{
_count = count;
}
public void Execute(CombatContext context)
{
int room = context.HandController.RamCapacity - context.HandController.Cards.Count;
if (room <= 0)
{
context.CombatManager.ReportEffectResult("hand full: drew 0");
return;
}
// RAM is a hard hand cap. Draw effects only pull cards that can fit; blocked cards stay in the draw pile.
int requested = UpgradeMath.ScaleAmount(_count, context.Card);
int drawCount = UnityEngine.Mathf.Min(requested, room);
IReadOnlyList<CardInstance> drawn = context.DeckController.Draw(drawCount);
int added = 0;
for (int i = 0; i < drawn.Count; i++)
{
if (context.HandController.Add(drawn[i]))
{
added++;
}
}
string capped = drawCount < requested ? " (hand full)" : string.Empty;
context.CombatManager.ReportEffectResult($"drew {added}{capped}");
}
}
public sealed class UpgradeHandCardsEffect : ICardEffect
{
private readonly int _minCount;
private readonly int _maxCount;
public UpgradeHandCardsEffect(int minCount, int maxCount)
{
_minCount = minCount;
_maxCount = maxCount;
}
public void Execute(CombatContext context)
{
int minCount = UpgradeMath.ScaleAmount(_minCount, context.Card);
int maxCount = UpgradeMath.ScaleAmount(_maxCount, context.Card);
int targetCount = minCount == maxCount
? minCount
: RandomRoll.RollRange(minCount, maxCount, new RollContext(context.Source));
List<CardInstance> candidates = context.HandController.Cards
.Where(card => card != null && card.CanUpgrade)
.ToList();
int upgraded = 0;
while (upgraded < targetCount && candidates.Count > 0)
{
int index = RandomRoll.RollRange(0, candidates.Count - 1, new RollContext(context.Source));
CardInstance card = candidates[index];
candidates.RemoveAt(index);
if (card.Upgrade())
{
upgraded++;
}
}
context.CombatManager.ReportEffectResult(upgraded == 0 ? "no cards upgraded" : $"upgraded {upgraded} card(s)");
}
}
public sealed class GenerateCardEffect : ICardEffect
{
private readonly Language _language;
private readonly Rarity _rarity;
public GenerateCardEffect(Language language, Rarity rarity)
{
_language = language;
_rarity = rarity;
}
public void Execute(CombatContext context)
{
if (context.HandController.Cards.Count >= context.HandController.RamCapacity)
{
context.CombatManager.ReportEffectResult("hand full: generated 0");
return;
}
if (!context.CombatManager.TryCreateGeneratedCard(_language, _rarity, out CardInstance generatedCard))
{
context.CombatManager.ReportEffectResult($"no {_rarity.ToString().ToLowerInvariant()} {_language} card pool");
return;
}
if (context.HandController.Add(generatedCard))
{
context.CombatManager.ReportEffectResult($"generated {GetCardName(generatedCard)} at 0c");
}
else
{
context.CombatManager.ReportEffectResult("hand full: generated 0");
}
}
private static string GetCardName(CardInstance card)
{
if (card?.Definition == null)
{
return "--";
}
return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName;
}
}
public sealed class NoOpCardEffect : ICardEffect
{
private readonly string _todo;
public NoOpCardEffect(string todo)
{
_todo = todo;
}
public void Execute(CombatContext context)
{
context.CombatManager.LogEffectTodo(context.Card, _todo);
}
}
public static class CardEffectFactory
{
public static IReadOnlyList<ICardEffect> CreateEffects(CardDefinition definition)
{
List<ICardEffect> serializedEffects = CreateSerializedEffects(definition);
if (serializedEffects.Count > 0)
{
return serializedEffects;
}
return definition?.Id switch
{
"lang_py_print" => One(new DealDamageEffect(3, 3, Language.Python)),
"lang_py_import_antigravity" => One(new DrawEffect(2)),
"lang_py_for_loop" => One(new DealDamageEffect(2, 2, Language.Python, allEnemies: true)),
"lang_js_console_log" => One(new DealDamageEffect(3, 9, Language.JavaScript)),
"lang_js_fetch" => One(new DealDamageEffect(5, 13, Language.JavaScript)),
"ubuntu_ask_ubuntu" => One(new DrawEffect(2)),
"ubuntu_unattended_upgrades" => new ICardEffect[]
{
new ShieldEffect(4),
new ApplyStatusEffect(StatusType.UnattendedUpgrades, 4, 2, targetSelf: true, skipNextTick: true)
},
"mint_fix_broken" => new ICardEffect[]
{
new DealDamageEffect(8, 8, Language.JavaScript),
new CleanseEffect(harmfulOnly: true)
},
"lang_js_typeof" => One(new ChanceDamageEffect(8, 75, Language.JavaScript)),
"shop_py_list_append" => One(new DealDamageEffect(4, 4, Language.Python)),
"shop_py_async_def" => One(new DrawEffect(2)),
"shop_py_zip" => Todo("TODO: zip() needs queue-effect-multiply support."),
"shop_js_math_random" => One(new DealDamageEffect(2, 20, Language.JavaScript)),
"shop_js_promise_all" => new ICardEffect[]
{
new DealDamageEffect(4, 10, Language.JavaScript),
new DealDamageEffect(4, 10, Language.JavaScript),
new DealDamageEffect(4, 10, Language.JavaScript)
},
"shop_js_spread" => Todo("TODO: spread ... needs card-copy support."),
"ubuntu_snap_install" => Todo("TODO: snap install needs token/junk insertion system."),
"ubuntu_do_release_upgrade" => One(new UpgradeHandCardsEffect(1, 3)),
"ubuntu_apt_install" => One(new GenerateCardEffect(Language.Python, Rarity.Common)),
"ubuntu_pro_trial" => Todo("TODO: ubuntu pro trial needs unplayable token handling."),
"mint_update_manager" => Todo("TODO: update manager needs delayed repeat system."),
"mint_cinnamon" => Todo("TODO: cinnamon needs shield effect support."),
"mint_nemo" => Todo("TODO: nemo needs conditional repeat based on shield."),
"mint_timeshift" => Todo("TODO: timeshift snapshot needs delayed restore system."),
"fedora_borrow_checker" => Todo("TODO: borrow checker needs shield and first-card tracking."),
"fedora_cargo_build" => Todo("TODO: cargo build --release needs overkill-to-shield handling."),
"fedora_dnf_update" => Todo("TODO: dnf update needs Java played count and cost mutation."),
"fedora_rawhide" => Todo("TODO: rawhide needs next-card cost mutation and crash-risk hook."),
"fedora_selinux" => Todo("TODO: SELinux enforcing needs enemy attack mitigation statuses."),
_ => Todo("TODO: card effect is not implemented yet.")
};
}
public static bool RequiresSingleTarget(CardDefinition definition)
{
return definition?.Id is "lang_js_console_log" or "lang_js_fetch" or "lang_js_typeof" or "mint_fix_broken"
or "shop_py_list_append" or "shop_js_math_random" or "shop_js_promise_all";
}
public static bool TargetsAllEnemies(CardDefinition definition)
{
return definition?.Id is "lang_py_for_loop";
}
private static List<ICardEffect> CreateSerializedEffects(CardDefinition definition)
{
List<ICardEffect> effects = new();
if (definition?.Effects == null)
{
return effects;
}
for (int i = 0; i < definition.Effects.Count; i++)
{
ICardEffect effect = definition.Effects[i] == null ? null : definition.Effects[i].CreateRuntimeEffect();
if (effect != null)
{
effects.Add(effect);
}
}
return effects;
}
private static IReadOnlyList<ICardEffect> One(ICardEffect effect)
{
return new[] { effect };
}
private static IReadOnlyList<ICardEffect> Todo(string message)
{
return One(new NoOpCardEffect(message));
}
public static string GetRulesText(CardInstance card)
{
if (card?.Definition == null)
{
return "--";
}
int level = card.UpgradeLevel;
string marker = level > 0 ? " [upgraded]" : string.Empty;
return card.Definition.Id switch
{
"lang_py_print" => $"queue: deal {UpgradeMath.ScaleAmount(3, card)}.{marker}",
"lang_py_import_antigravity" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}",
"lang_py_for_loop" => $"queue: deal {UpgradeMath.ScaleAmount(2, card)} to all enemies.{marker}",
"lang_js_console_log" => $"deal {UpgradeMath.ScaleAmount(3, card)}-{UpgradeMath.ScaleAmount(9, card)}.{marker}",
"lang_js_fetch" => $"deal {UpgradeMath.ScaleAmount(5, card)}-{UpgradeMath.ScaleAmount(13, card)}.{marker}",
"lang_js_typeof" => $"75%: deal {UpgradeMath.ScaleAmount(8, card)}. 25%: deal 0.{marker}",
"shop_py_list_append" => $"queue: deal {UpgradeMath.ScaleAmount(4, card)}. TODO: grows when re-queued.{marker}",
"shop_py_async_def" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}. TODO: gain 1 cycle next turn.{marker}",
"shop_py_zip" => $"TODO: next 2 queued cards resolve twice.{marker}",
"shop_js_math_random" => $"deal {UpgradeMath.ScaleAmount(2, card)}-{UpgradeMath.ScaleAmount(20, card)}.{marker}",
"shop_js_promise_all" => $"deal {UpgradeMath.ScaleAmount(4, card)}-{UpgradeMath.ScaleAmount(10, card)} three times.{marker}",
"shop_js_spread" => $"TODO: copy the lowest-cost card in hand.{marker}",
"ubuntu_ask_ubuntu" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}",
"ubuntu_unattended_upgrades" => $"queue: gain {UpgradeMath.ScaleShield(4, card)} shield now and next 2 turns.{marker}",
"ubuntu_do_release_upgrade" => $"upgrade {UpgradeMath.ScaleAmount(1, card)}-{UpgradeMath.ScaleAmount(3, card)} random hand cards.{marker}",
"ubuntu_apt_install" => $"queue: generate a random common Python card at 0c.{marker}",
"mint_fix_broken" => $"deal {UpgradeMath.ScaleAmount(8, card)}. cleanse harmful statuses.{marker}",
_ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "TODO: effect not implemented." : $"{card.Definition.Description}{marker}"
};
}
}
public static class UpgradeMath
{
public static int ScaleAmount(int baseAmount, CardInstance card)
{
return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0);
}
public static int ScaleShield(int baseAmount, CardInstance card)
{
return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0);
}
}
}
+86
View File
@@ -1,4 +1,6 @@
using KernelPanic.Data;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -7,14 +9,98 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CardInstance
{
private readonly List<CombatantState> _targetSnapshot = new();
public CardInstance(CardDefinition definition)
{
Definition = definition;
}
public CardDefinition Definition { get; }
public IReadOnlyList<CombatantState> TargetSnapshot => _targetSnapshot;
public int TemporaryCostDelta { get; set; }
public int PermanentCostDelta { get; private set; }
public int MagnitudeBonus { get; private set; }
public int DrawRider { get; private set; }
public int UpgradeLevel { get; private set; }
public bool IsBroken { get; set; }
public bool IsLocked { get; set; }
public bool CanUpgrade => UpgradeLevel == 0;
public bool CanReceiveRepositoryUpgrade => UpgradeLevel < 3;
public bool IsUpgraded => UpgradeLevel > 0;
public bool Upgrade()
{
if (!CanUpgrade)
{
return false;
}
UpgradeLevel = 1;
ApplyCostUpgrade(-1);
ApplyMagnitudeUpgrade(1);
return true;
}
public bool ApplyCostUpgrade(int delta)
{
if (Definition == null || Definition.CycleCost + PermanentCostDelta + TemporaryCostDelta <= 0)
{
return false;
}
PermanentCostDelta += delta;
UpgradeLevel++;
return true;
}
public bool ApplyMagnitudeUpgrade(int amount)
{
if (amount <= 0)
{
return false;
}
MagnitudeBonus += amount;
UpgradeLevel++;
return true;
}
public bool ApplyDrawRider(int amount)
{
if (amount <= 0)
{
return false;
}
DrawRider += amount;
UpgradeLevel++;
return true;
}
public void SetTargetSnapshot(IEnumerable<CombatantState> targets)
{
_targetSnapshot.Clear();
if (targets == null)
{
return;
}
_targetSnapshot.AddRange(targets.Where(target => target != null && !target.IsDefeated));
}
public void ClearTargetSnapshot()
{
_targetSnapshot.Clear();
}
public void ResetCombatState()
{
TemporaryCostDelta = 0;
IsBroken = false;
IsLocked = false;
ClearTargetSnapshot();
}
}
}
+22 -1
View File
@@ -7,15 +7,36 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CombatContext
{
public CombatContext(CombatantState source, IReadOnlyList<CombatantState> targets, CombatManager combatManager)
public CombatContext(
CardInstance card,
CombatantState source,
IReadOnlyList<CombatantState> targets,
CombatManager combatManager,
DamagePipeline damagePipeline,
StatusEffectController statusEffects,
DeckController deckController,
HandController handController,
IReadOnlyList<EnemyInstance> enemies)
{
Card = card;
Source = source;
Targets = targets;
CombatManager = combatManager;
DamagePipeline = damagePipeline;
StatusEffects = statusEffects;
DeckController = deckController;
HandController = handController;
Enemies = enemies;
}
public CardInstance Card { get; }
public CombatantState Source { get; }
public IReadOnlyList<CombatantState> Targets { get; }
public CombatManager CombatManager { get; }
public DamagePipeline DamagePipeline { get; }
public StatusEffectController StatusEffects { get; }
public DeckController DeckController { get; }
public HandController HandController { get; }
public IReadOnlyList<EnemyInstance> Enemies { get; }
}
}
+782
View File
@@ -1,5 +1,10 @@
using KernelPanic.Core;
using KernelPanic.Data;
using KernelPanic.Run;
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -10,26 +15,277 @@ namespace KernelPanic.Combat
{
[SerializeField] private TurnPhase currentPhase = TurnPhase.Boot;
private readonly DeckController deckController = new();
private readonly InterpreterQueue interpreterQueue = new();
private readonly LazyStack lazyStack = new();
private readonly NativeTrack nativeTrack = new();
private readonly StatusEffectController statusEffects = new();
private readonly DamagePipeline damagePipeline = new();
private readonly List<EnemyInstance> enemies = new();
private readonly HashSet<string> loggedEffectTodos = new();
private readonly List<CardDefinition> generatedCardPool = new();
private RunManager runManager;
private RunConfig runConfig;
private HandController handController;
private CombatantState playerState;
private int selectedEnemyIndex = -1;
private CardInstance pendingTargetCard;
private bool awaitingWaveContinue;
private bool runLost;
private bool skipNextAllocateDraw;
public TurnPhase CurrentPhase => currentPhase;
public RunConfig RunConfig => runConfig;
public DeckController DeckController => deckController;
public HandController HandController => handController;
public CombatantState PlayerState => playerState;
public InterpreterQueue InterpreterQueue => interpreterQueue;
public LazyStack LazyStack => lazyStack;
public NativeTrack NativeTrack => nativeTrack;
public DamagePipeline DamagePipeline => damagePipeline;
public IReadOnlyList<EnemyInstance> Enemies => enemies;
public int SelectedEnemyIndex => selectedEnemyIndex;
public CardInstance PendingTargetCard => pendingTargetCard;
public bool AwaitingWaveContinue => awaitingWaveContinue;
public bool RunLost => runLost;
public int CurrentWaveNumber => runManager == null ? 1 : runManager.CurrentWaveNumber;
public event Action StateChanged;
public event Action<string> CombatLog;
private void Awake()
{
runManager = GetComponent<RunManager>();
}
public void StartCombat()
{
if (runConfig == null)
{
Debug.LogWarning("CombatManager.StartCombat called without a RunConfig.");
return;
}
SetPhase(TurnPhase.Boot);
}
public void StartCombat(RunConfig config)
{
runConfig = config;
StartCombat();
}
public void AdvancePhase()
{
if (IsCombatPaused)
{
return;
}
TurnPhase nextPhase = GetNextPhase(currentPhase);
SetPhase(nextPhase);
}
public void EndPlayerTurn()
{
if (currentPhase == TurnPhase.Execute && !IsCombatPaused)
{
pendingTargetCard = null;
selectedEnemyIndex = -1;
SetPhase(TurnPhase.Interpret);
}
}
public void ContinueToNextWave()
{
if (!awaitingWaveContinue || runLost)
{
return;
}
awaitingWaveContinue = false;
StartWave(preservePlayerUptime: true);
SetPhase(TurnPhase.Allocate);
}
public void SelectEnemy(int index)
{
if (IsCombatPaused)
{
return;
}
if (index < 0 || index >= enemies.Count)
{
selectedEnemyIndex = -1;
StateChanged?.Invoke();
return;
}
selectedEnemyIndex = index;
if (pendingTargetCard != null)
{
CardInstance card = pendingTargetCard;
pendingTargetCard = null;
PlayCard(card);
return;
}
StateChanged?.Invoke();
}
public bool PlayCard(CardInstance card)
{
if (currentPhase != TurnPhase.Execute || IsCombatPaused || card == null || handController == null || playerState == null)
{
return false;
}
int cost = GetCardCost(card);
if (playerState.Cycles < cost)
{
Log($"not enough cycles for {GetCardName(card)}");
return false;
}
if (RequiresTarget(card) && selectedEnemyIndex < 0)
{
pendingTargetCard = card;
Log($"select target for {GetCardName(card)}");
StateChanged?.Invoke();
return false;
}
if (!handController.Remove(card))
{
return false;
}
playerState.Cycles -= cost;
ResolutionTrack track = card.Definition.ResolutionTrack;
GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track));
LogPlayIntent(card);
card.SetTargetSnapshot(CaptureTargets(card));
switch (track)
{
case ResolutionTrack.InterpreterQueue:
interpreterQueue.Enqueue(card);
break;
case ResolutionTrack.LazyStack:
lazyStack.Enqueue(card);
break;
default:
ExecuteCardEffects(card, ResolveTargets(card));
ResolveCardRiders(card);
deckController.Discard(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.Native));
selectedEnemyIndex = -1;
break;
}
pendingTargetCard = null;
StateChanged?.Invoke();
return true;
}
public static int GetCardCost(CardInstance card)
{
if (card?.Definition == null)
{
return 0;
}
return Mathf.Max(0, card.Definition.CycleCost + card.PermanentCostDelta + card.TemporaryCostDelta);
}
public bool RequiresTarget(CardInstance card)
{
return card?.Definition != null && CardEffectFactory.RequiresSingleTarget(card.Definition) && LivingEnemyStates().Count > 0;
}
public void LogEffectTodo(CardInstance card, string message)
{
string cardId = card?.Definition == null ? string.Empty : card.Definition.Id;
string key = $"{cardId}:{message}";
if (loggedEffectTodos.Add(key))
{
Log(message);
}
}
public void ReportEffectResult(string message)
{
if (!string.IsNullOrWhiteSpace(message))
{
Log(message);
}
}
public void SetGeneratedCardPool(IEnumerable<CardDefinition> cardPool)
{
generatedCardPool.Clear();
if (cardPool == null)
{
return;
}
foreach (CardDefinition card in cardPool)
{
if (card != null && !card.IsToken && !card.IsRunOnly)
{
generatedCardPool.Add(card);
}
}
}
public bool TryCreateGeneratedCard(Language language, Rarity rarity, out CardInstance generatedCard)
{
generatedCard = null;
List<CardDefinition> pool = new();
HashSet<string> seenIds = new();
IReadOnlyList<CardDefinition> source = generatedCardPool.Count > 0 ? generatedCardPool : runConfig?.StartingDeck;
if (source == null)
{
return false;
}
for (int i = 0; i < source.Count; i++)
{
CardDefinition definition = source[i];
if (definition == null || definition.IsToken || definition.IsRunOnly || definition.Language != language || definition.Rarity != rarity)
{
continue;
}
if (seenIds.Add(definition.Id))
{
pool.Add(definition);
}
}
if (pool.Count == 0)
{
return false;
}
int index = RandomRoll.RollRange(0, pool.Count - 1, new RollContext(playerState));
CardDefinition selected = pool[index];
generatedCard = new CardInstance(selected)
{
TemporaryCostDelta = -selected.CycleCost
};
return true;
}
private void SetPhase(TurnPhase nextPhase)
{
if (IsCombatPaused && nextPhase != TurnPhase.Boot)
{
return;
}
TurnPhase previousPhase = currentPhase;
currentPhase = nextPhase;
GameEvents.RaisePhaseChanged(new PhaseChangedEvent(previousPhase, nextPhase));
@@ -51,6 +307,532 @@ namespace KernelPanic.Combat
private void EnterPhase(TurnPhase phase)
{
switch (phase)
{
case TurnPhase.Boot:
BootCombat();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.Allocate:
AllocateTurn();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.Execute:
StateChanged?.Invoke();
break;
case TurnPhase.Interpret:
InterpretQueue();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.EnemyProcess:
ProcessEnemies();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
case TurnPhase.GarbageCollection:
GarbageCollect();
if (!IsCombatPaused)
{
AdvancePhase();
}
break;
}
}
private void BootCombat()
{
runLost = false;
awaitingWaveContinue = false;
loggedEffectTodos.Clear();
RandomRoll.Seed(runConfig.RunSeed);
playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles());
StartWave(preservePlayerUptime: false);
Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards");
StateChanged?.Invoke();
}
private void StartWave(bool preservePlayerUptime)
{
int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime;
if (!preservePlayerUptime || playerState == null)
{
playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles());
}
else
{
playerState.MaxUptime = runManager.EffectiveMaxUptime();
playerState.CurrentUptime = Mathf.Clamp(carriedUptime, 1, playerState.MaxUptime);
playerState.Ram = runManager.EffectiveRam();
playerState.MaxCycles = runManager.EffectiveMaxCycles();
playerState.Cycles = 0;
playerState.Shield = 0;
playerState.IsDefeated = false;
playerState.MutableStatuses.Clear();
}
handController = new HandController(playerState.Ram);
selectedEnemyIndex = -1;
pendingTargetCard = null;
List<CardInstance> startingCards = new();
for (int i = 0; i < runManager.RunDeck.Count; i++)
{
CardInstance card = runManager.RunDeck[i];
if (card != null)
{
card.ResetCombatState();
startingCards.Add(card);
}
}
deckController.Initialize(startingCards);
interpreterQueue.Clear();
enemies.Clear();
SpawnStructuralEnemies();
PickEnemyIntents();
DrawOpeningHand();
skipNextAllocateDraw = true;
}
private void AllocateTurn()
{
if (CheckLoss())
{
return;
}
playerState.Cycles = playerState.MaxCycles;
statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline);
if (CheckLoss())
{
return;
}
if (skipNextAllocateDraw)
{
skipNextAllocateDraw = false;
}
else
{
DrawForTurn();
}
StateChanged?.Invoke();
}
private void InterpretQueue()
{
while (interpreterQueue.TryDequeue(out CardInstance card))
{
LogPlayIntent(card);
ExecuteCardEffects(card, ResolveQueuedTargets(card));
ResolveCardRiders(card);
deckController.Discard(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue));
if (CheckWinOrLoss())
{
return;
}
}
CheckWinOrLoss();
StateChanged?.Invoke();
}
private void ProcessEnemies()
{
for (int i = 0; i < enemies.Count; i++)
{
EnemyInstance enemy = enemies[i];
if (enemy.State.IsDefeated)
{
continue;
}
statusEffects.Tick(enemy.State, StatusTickTiming.StartOfTurn, playerState, damagePipeline);
RemoveDefeatedEnemies();
if (CheckWinOrLoss())
{
return;
}
if (enemy.State.IsDefeated)
{
continue;
}
Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}");
GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy));
ExecuteEnemyIntent(enemy);
GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent));
if (CheckLoss())
{
return;
}
}
PickEnemyIntents();
StateChanged?.Invoke();
}
private void GarbageCollect()
{
selectedEnemyIndex = -1;
pendingTargetCard = null;
statusEffects.Tick(playerState, StatusTickTiming.EndOfTurn, playerState, damagePipeline);
if (CheckLoss())
{
return;
}
RemoveDefeatedEnemies();
CheckWinOrLoss();
StateChanged?.Invoke();
}
private void DrawOpeningHand()
{
int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram);
DrawCardsToHand(openingDraw, "opening hand");
}
private void DrawForTurn()
{
int requested = CombatTuning.DrawPerTurn;
if (CombatTuning.MinimumHandFloor > 0 && handController.Cards.Count < CombatTuning.MinimumHandFloor && deckController.AvailableToDrawCount > 0)
{
requested = Mathf.Max(requested, CombatTuning.MinimumHandFloor - handController.Cards.Count);
}
DrawCardsToHand(requested, "turn draw");
}
private void DrawCardsToHand(int requestedCount, string label)
{
if (handController == null || playerState == null || requestedCount <= 0)
{
return;
}
int room = playerState.Ram - handController.Cards.Count;
if (room <= 0)
{
Log($"{label}: hand full");
return;
}
int drawCount = Mathf.Min(requestedCount, room);
IReadOnlyList<CardInstance> drawn = deckController.Draw(drawCount);
int added = 0;
for (int i = 0; i < drawn.Count; i++)
{
if (handController.Add(drawn[i]))
{
added++;
}
}
if (added == 0 && deckController.AvailableToDrawCount == 0)
{
Log($"{label}: no cards to draw");
}
else if (added < requestedCount)
{
Log($"{label}: drew {added} (hand full)");
}
else
{
Log($"{label}: drew {added}");
}
}
private CombatContext BuildContext(CardInstance card, IReadOnlyList<CombatantState> targets)
{
return new CombatContext(card, playerState, targets, this, damagePipeline, statusEffects, deckController, handController, enemies);
}
private void ExecuteCardEffects(CardInstance card, IReadOnlyList<CombatantState> targets)
{
IReadOnlyList<ICardEffect> effects = CardEffectFactory.CreateEffects(card?.Definition);
CombatContext context = BuildContext(card, targets);
for (int i = 0; i < effects.Count; i++)
{
effects[i].Execute(context);
RemoveDefeatedEnemies();
if (CheckWinOrLoss())
{
return;
}
}
}
private void ResolveCardRiders(CardInstance card)
{
if (card == null || card.DrawRider <= 0 || handController == null || playerState == null)
{
return;
}
DrawCardsToHand(card.DrawRider, "upgrade rider");
}
private IReadOnlyList<CombatantState> CaptureTargets(CardInstance card)
{
if (card?.Definition == null)
{
return Array.Empty<CombatantState>();
}
if (CardEffectFactory.TargetsAllEnemies(card.Definition))
{
return LivingEnemyStates();
}
if (selectedEnemyIndex >= 0 && selectedEnemyIndex < enemies.Count && !enemies[selectedEnemyIndex].State.IsDefeated)
{
return new[] { enemies[selectedEnemyIndex].State };
}
if (RequiresEnemyTarget(card))
{
CombatantState firstLiving = FirstLivingEnemyState();
return firstLiving == null ? Array.Empty<CombatantState>() : new[] { firstLiving };
}
return Array.Empty<CombatantState>();
}
private IReadOnlyList<CombatantState> ResolveTargets(CardInstance card)
{
List<CombatantState> targets = card.TargetSnapshot.Where(target => target != null && !target.IsDefeated).ToList();
if (targets.Count == 0 && RequiresEnemyTarget(card))
{
CombatantState firstLiving = FirstLivingEnemyState();
if (firstLiving != null)
{
targets.Add(firstLiving);
}
}
return targets;
}
private IReadOnlyList<CombatantState> ResolveQueuedTargets(CardInstance card)
{
List<CombatantState> targets = ResolveTargets(card).ToList();
if (targets.Count == 0 && CardEffectFactory.TargetsAllEnemies(card?.Definition))
{
targets.AddRange(LivingEnemyStates());
}
// Queued cards snapshot targets when played. If those targets die before Interpret, retarget to living enemies.
return targets;
}
private bool RequiresEnemyTarget(CardInstance card)
{
if (card?.Definition == null)
{
return false;
}
return CardEffectFactory.RequiresSingleTarget(card.Definition)
|| CardEffectFactory.TargetsAllEnemies(card.Definition)
|| card.Definition.Id == "lang_py_print";
}
private List<CombatantState> LivingEnemyStates()
{
return enemies
.Where(enemy => enemy.State != null && !enemy.State.IsDefeated)
.Select(enemy => enemy.State)
.ToList();
}
private CombatantState FirstLivingEnemyState()
{
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i].State != null && !enemies[i].State.IsDefeated)
{
return enemies[i].State;
}
}
return null;
}
private void RemoveDefeatedEnemies()
{
for (int i = enemies.Count - 1; i >= 0; i--)
{
if (enemies[i].State.IsDefeated)
{
Log($"{enemies[i].Name} exited");
runManager?.AddBits(CombatTuning.BitsPerKill);
enemies.RemoveAt(i);
}
}
if (selectedEnemyIndex >= enemies.Count)
{
selectedEnemyIndex = -1;
}
}
private void SpawnStructuralEnemies()
{
int wave = CurrentWaveNumber;
int count = Mathf.Clamp(CombatTuning.BaseEnemiesPerWave + ((wave - 1) * CombatTuning.AdditionalEnemiesPerWave), CombatTuning.BaseEnemiesPerWave, CombatTuning.MaxEnemiesPerWave);
int waveUptimeBonus = Mathf.Max(0, wave - 1) * CombatTuning.EnemyUptimeGrowthPerWave;
for (int i = 0; i < count; i++)
{
// TODO: Replace this low-HP additive wave stub with the real encounter/difficulty system.
int baseUptime = RandomRoll.RollRange(CombatTuning.BaseEnemyUptimeMin, CombatTuning.BaseEnemyUptimeMax, RollContext.None);
int uptime = baseUptime + waveUptimeBonus;
enemies.Add(new EnemyInstance("zombie_process", uptime, CreateZombieProcessIntentPool(wave, i)));
}
}
private static IReadOnlyList<EnemyIntent> CreateZombieProcessIntentPool(int wave, int slotIndex)
{
// TODO: Replace this code-shaped pool with EnemyDefinition-provided intent data.
int attack = GetZombieProcessAttack(wave, slotIndex);
return new[]
{
new EnemyIntent(EnemyIntentKind.Attack, attack, attack, Language.C, "attack", ">"),
new EnemyIntent(EnemyIntentKind.Defend, 3 + wave, 3 + wave, Language.C, "defend", "#"),
new EnemyIntent(EnemyIntentKind.StatusAttack, CombatTuning.EnemyStatusAttackDamage, CombatTuning.EnemyStatusAttackDamage, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration)
};
}
private static int GetZombieProcessAttack(int wave, int slotIndex)
{
int safeWave = Mathf.Max(1, wave);
int waveGrowth = (safeWave - 1) / Mathf.Max(1, CombatTuning.EnemyAttackGrowthEveryWaves);
int slotVariance = CombatTuning.EnemySlotAttackVariance <= 0 ? 0 : slotIndex % (CombatTuning.EnemySlotAttackVariance + 1);
return CombatTuning.BaseEnemyAttack + waveGrowth + slotVariance;
}
private void PickEnemyIntents()
{
for (int i = 0; i < enemies.Count; i++)
{
if (!enemies[i].State.IsDefeated)
{
enemies[i].PickNextIntent();
}
}
}
private void ExecuteEnemyIntent(EnemyInstance enemy)
{
EnemyIntent intent = enemy.CurrentIntent;
int value = intent.MinValue == intent.MaxValue
? intent.MinValue
: RandomRoll.RollRange(intent.MinValue, intent.MaxValue, new RollContext(enemy.State));
switch (intent.Kind)
{
case EnemyIntentKind.Attack:
DamageResult attackResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false));
GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, attackResult.FinalAmount));
break;
case EnemyIntentKind.Defend:
enemy.State.Shield += value;
break;
case EnemyIntentKind.StatusAttack:
DamageResult statusResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false));
GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, statusResult.FinalAmount));
if (intent.StatusStacks > 0)
{
statusEffects.Apply(playerState, intent.StatusType, intent.StatusStacks, intent.StatusDuration, enemy.State, skipNextTick: true);
}
break;
case EnemyIntentKind.Buff:
Log("TODO: buff intent needs enemy buff/status support.");
break;
case EnemyIntentKind.Special:
Log("TODO: special intent needs enemy-specific behavior hooks.");
break;
}
}
private bool CheckWinOrLoss()
{
return CheckLoss() || CheckWin();
}
private bool CheckLoss()
{
if (runLost || playerState == null || !playerState.IsDefeated)
{
return runLost;
}
runLost = true;
awaitingWaveContinue = false;
selectedEnemyIndex = -1;
pendingTargetCard = null;
GameEvents.RaiseEncounterLost(new EncounterLostEvent(CurrentWaveNumber));
GameEvents.RaiseRunEnded(new RunEndedEvent(playerDied: true, entropyEarned: 0));
Log("kernel panic: player uptime reached 0");
StateChanged?.Invoke();
return true;
}
private bool CheckWin()
{
if (awaitingWaveContinue || runLost || enemies.Count > 0)
{
return awaitingWaveContinue;
}
awaitingWaveContinue = true;
selectedEnemyIndex = -1;
pendingTargetCard = null;
int clearedWave = CurrentWaveNumber;
GameEvents.RaiseEncounterWon(new EncounterWonEvent(clearedWave));
GameEvents.RaiseWaveCleared(new WaveClearedEvent(clearedWave));
Log("wave cleared -> repository available");
StateChanged?.Invoke();
return true;
}
private void LogPlayIntent(CardInstance card)
{
string notes = card?.Definition == null || string.IsNullOrWhiteSpace(card.Definition.DesignNotes)
? "no-op scaffold"
: card.Definition.DesignNotes;
Log($"{GetCardName(card)} would resolve: {notes}");
}
private void Log(string message)
{
CombatLog?.Invoke(message);
}
private static string GetCardName(CardInstance card)
{
if (card?.Definition == null)
{
return "--";
}
return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName;
}
private bool IsCombatPaused => awaitingWaveContinue || runLost;
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace KernelPanic.Combat
{
/// <summary>
/// Central combat economy/scaffold tuning. Kept in code while the combat scaffold is still volatile;
/// promote to a ScriptableObject once designers need per-run or per-encounter tuning without recompiles.
/// </summary>
public static class CombatTuning
{
public const int OpeningHandSize = 5;
public const int DrawPerTurn = 1;
public const int MinimumHandFloor = 0;
public const int BaseEnemyUptimeMin = 5;
public const int BaseEnemyUptimeMax = 10;
public const int EnemyUptimeGrowthPerWave = 2;
public const int BaseEnemyAttack = 3;
public const int EnemyAttackGrowthEveryWaves = 2;
public const int EnemySlotAttackVariance = 1;
public const int EnemyStatusAttackDamage = 1;
public const int EnemyStatusDuration = 3;
public const int BaseEnemiesPerWave = 2;
public const int AdditionalEnemiesPerWave = 1;
public const int MaxEnemiesPerWave = 5;
public const int BitsPerKill = 1;
public const int ShopSize = 5;
public const int RerollBaseCost = 1;
public const int RerollCostStep = 1;
public const int RemoveCardCost = 1;
public const int NewCardOfferCost = 2;
public const int UpgradeOfferCost = 2;
public const int StatUpgradeCost = 3;
public const int StatUpgradeMaxCycles = 1;
public const int StatUpgradeMaxUptime = 4;
public const int StatUpgradeHeal = 5;
public const int StatUpgradeRam = 1;
public const int UpgradeMagnitudeBonus = 2;
}
}
+31 -2
View File
@@ -8,7 +8,7 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class CombatantState
{
private readonly Dictionary<StatusType, int> activeStatuses = new();
private readonly List<StatusInstance> _statuses = new();
public CombatantState(int maxUptime, int ram, int cycles)
{
@@ -16,6 +16,7 @@ namespace KernelPanic.Combat
CurrentUptime = maxUptime;
Ram = ram;
Cycles = cycles;
MaxCycles = cycles;
}
public int CurrentUptime { get; set; }
@@ -23,6 +24,34 @@ namespace KernelPanic.Combat
public int Shield { get; set; }
public int Ram { get; set; }
public int Cycles { get; set; }
public IReadOnlyDictionary<StatusType, int> ActiveStatuses => activeStatuses;
public int MaxCycles { get; set; }
public bool IsDefeated { get; set; }
public bool forceMaxRolls { get; set; }
public IReadOnlyList<StatusInstance> Statuses => _statuses;
public IReadOnlyDictionary<StatusType, int> ActiveStatuses => BuildStatusSnapshot();
internal List<StatusInstance> MutableStatuses => _statuses;
public bool HasStatus(StatusType type)
{
return _statuses.Exists(status => status.Type == type);
}
public int StacksOf(StatusType type)
{
StatusInstance status = _statuses.Find(instance => instance.Type == type);
return status == null ? 0 : status.Stacks;
}
private IReadOnlyDictionary<StatusType, int> BuildStatusSnapshot()
{
Dictionary<StatusType, int> snapshot = new();
for (int i = 0; i < _statuses.Count; i++)
{
snapshot[_statuses[i].Type] = _statuses[i].Stacks;
}
return snapshot;
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using KernelPanic.Core;
using UnityEngine;
namespace KernelPanic.Combat
{
public readonly struct DamageRequest
{
public DamageRequest(
CombatantState source,
CombatantState target,
int amount,
Language language,
bool trueDamage,
bool canCrit)
{
Source = source;
Target = target;
Amount = amount;
Language = language;
TrueDamage = trueDamage;
CanCrit = canCrit;
}
public CombatantState Source { get; }
public CombatantState Target { get; }
public int Amount { get; }
public Language Language { get; }
public bool TrueDamage { get; }
public bool CanCrit { get; }
}
public readonly struct DamageResult
{
public DamageResult(int finalAmount, bool targetDefeated)
{
FinalAmount = finalAmount;
TargetDefeated = targetDefeated;
}
public int FinalAmount { get; }
public bool TargetDefeated { get; }
}
/// <summary>
/// Single damage chokepoint for shield, uptime, damage events, and defeat decisions.
/// </summary>
public sealed class DamagePipeline
{
public DamageResult DealDamage(DamageRequest request)
{
if (request.Target == null || request.Target.IsDefeated)
{
return new DamageResult(0, false);
}
int amount = Mathf.Max(0, request.Amount);
amount = ApplyMultipliers(amount, request);
amount = ApplyCrit(amount, request);
amount = ApplyResistancesAndWeaknesses(amount, request);
int finalAmount = ApplyShieldAndUptime(amount, request);
GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language));
bool defeated = false;
if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated)
{
request.Target.IsDefeated = true;
defeated = true;
GameEvents.RaiseCombatantDefeated(new CombatantDefeatedEvent(request.Target));
}
return new DamageResult(finalAmount, defeated);
}
private static int ApplyMultipliers(int amount, DamageRequest request)
{
// TODO: Apply buffs/debuffs and Mint's no-multiplier rule here.
return amount;
}
private static int ApplyCrit(int amount, DamageRequest request)
{
// TODO: Add crit calculation and crit modifiers.
return amount;
}
private static int ApplyResistancesAndWeaknesses(int amount, DamageRequest request)
{
// TODO: Apply enemy language resistances and weaknesses here.
return amount;
}
private static int ApplyShieldAndUptime(int amount, DamageRequest request)
{
CombatantState target = request.Target;
int remaining = amount;
int shieldDamage = 0;
if (!request.TrueDamage && target.Shield > 0)
{
shieldDamage = Mathf.Min(target.Shield, remaining);
target.Shield -= shieldDamage;
remaining -= shieldDamage;
}
int uptimeDamage = Mathf.Min(target.CurrentUptime, remaining);
target.CurrentUptime = Mathf.Max(0, target.CurrentUptime - uptimeDamage);
return shieldDamage + uptimeDamage;
}
}
}
+59 -13
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace KernelPanic.Combat
{
@@ -8,42 +8,88 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class DeckController
{
private readonly List<CardInstance> drawPile = new();
private readonly List<CardInstance> discardPile = new();
private readonly List<CardInstance> exhaustPile = new();
private readonly List<CardInstance> _drawPile = new();
private readonly List<CardInstance> _discardPile = new();
private readonly List<CardInstance> _exhaustPile = new();
public IReadOnlyList<CardInstance> DrawPile => drawPile;
public IReadOnlyList<CardInstance> DiscardPile => discardPile;
public IReadOnlyList<CardInstance> ExhaustPile => exhaustPile;
public IReadOnlyList<CardInstance> DrawPile => _drawPile;
public IReadOnlyList<CardInstance> DiscardPile => _discardPile;
public IReadOnlyList<CardInstance> ExhaustPile => _exhaustPile;
public int AvailableToDrawCount => _drawPile.Count + _discardPile.Count;
public void Initialize(IEnumerable<CardInstance> cards)
{
throw new NotImplementedException();
_drawPile.Clear();
_discardPile.Clear();
_exhaustPile.Clear();
if (cards != null)
{
_drawPile.AddRange(cards.Where(card => card != null));
}
ShuffleDrawPile();
}
public IReadOnlyList<CardInstance> Draw(int count)
{
throw new NotImplementedException();
List<CardInstance> drawn = new();
for (int i = 0; i < count; i++)
{
if (_drawPile.Count == 0)
{
ReshuffleDiscardIntoDraw();
}
if (_drawPile.Count == 0)
{
break;
}
int topIndex = _drawPile.Count - 1;
CardInstance card = _drawPile[topIndex];
_drawPile.RemoveAt(topIndex);
drawn.Add(card);
}
return drawn;
}
public void Discard(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
_discardPile.Add(card);
}
}
public void Exhaust(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
_exhaustPile.Add(card);
}
}
public void ShuffleDrawPile()
{
throw new NotImplementedException();
for (int i = _drawPile.Count - 1; i > 0; i--)
{
int swapIndex = RandomRoll.RollRange(0, i, RollContext.None);
(_drawPile[i], _drawPile[swapIndex]) = (_drawPile[swapIndex], _drawPile[i]);
}
}
public void ReshuffleDiscardIntoDraw()
{
throw new NotImplementedException();
if (_discardPile.Count == 0)
{
return;
}
_drawPile.AddRange(_discardPile);
_discardPile.Clear();
ShuffleDrawPile();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.Generic;
namespace KernelPanic.Combat
{
/// <summary>
/// Runtime-only enemy placeholder used until encounter and EnemyDefinition assets are wired.
/// </summary>
public sealed class EnemyInstance
{
private readonly List<EnemyIntent> _intentPool = new();
public EnemyInstance(string name, int maxUptime, IEnumerable<EnemyIntent> intentPool)
{
Name = string.IsNullOrWhiteSpace(name) ? "unknown_process" : name;
State = new CombatantState(maxUptime, 0, 0);
if (intentPool != null)
{
_intentPool.AddRange(intentPool);
}
}
public string Name { get; }
public int CurrentUptime => State.CurrentUptime;
public int MaxUptime => State.MaxUptime;
public CombatantState State { get; }
public IReadOnlyList<EnemyIntent> IntentPool => _intentPool;
public EnemyIntent CurrentIntent { get; private set; }
public void PickNextIntent()
{
if (_intentPool.Count == 0)
{
CurrentIntent = default;
return;
}
int index = RandomRoll.RollRange(0, _intentPool.Count - 1, new RollContext(State));
CurrentIntent = _intentPool[index];
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using KernelPanic.Core;
namespace KernelPanic.Combat
{
public enum EnemyIntentKind
{
Attack,
Defend,
Buff,
StatusAttack,
Special
}
/// <summary>
/// Runtime enemy intent shape. Future EnemyDefinition assets should provide pools of this data.
/// </summary>
public readonly struct EnemyIntent
{
public EnemyIntent(
EnemyIntentKind kind,
int minValue,
int maxValue,
Language damageType,
string displayLabel,
string iconKey,
bool trueDamage = false,
StatusType statusType = StatusType.MemoryLeak,
int statusStacks = 0,
int statusDuration = -1)
{
Kind = kind;
MinValue = minValue;
MaxValue = maxValue;
DamageType = damageType;
DisplayLabel = string.IsNullOrWhiteSpace(displayLabel) ? kind.ToString() : displayLabel;
IconKey = string.IsNullOrWhiteSpace(iconKey) ? "?" : iconKey;
TrueDamage = trueDamage;
StatusType = statusType;
StatusStacks = statusStacks;
StatusDuration = statusDuration;
}
public EnemyIntentKind Kind { get; }
public int MinValue { get; }
public int MaxValue { get; }
public Language DamageType { get; }
public string DisplayLabel { get; }
public string IconKey { get; }
public bool TrueDamage { get; }
public StatusType StatusType { get; }
public int StatusStacks { get; }
public int StatusDuration { get; }
public string ValueText => MinValue == MaxValue ? MinValue.ToString() : $"{MinValue}-{MaxValue}";
public string DisplayText => $"{IconKey} {DisplayLabel} {ValueText}";
}
}
+13 -8
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
namespace KernelPanic.Combat
@@ -8,7 +7,7 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class HandController
{
private readonly List<CardInstance> cards = new();
private readonly List<CardInstance> _cards = new();
public HandController(int ramCapacity)
{
@@ -16,26 +15,32 @@ namespace KernelPanic.Combat
}
public int RamCapacity { get; private set; }
public IReadOnlyList<CardInstance> Cards => cards;
public IReadOnlyList<CardInstance> Cards => _cards;
public bool CanAdd(CardInstance card)
{
throw new NotImplementedException();
return card != null && _cards.Count < RamCapacity;
}
public void Add(CardInstance card)
public bool Add(CardInstance card)
{
throw new NotImplementedException();
if (!CanAdd(card))
{
return false;
}
_cards.Add(card);
return true;
}
public bool Remove(CardInstance card)
{
throw new NotImplementedException();
return card != null && _cards.Remove(card);
}
public void Clear()
{
throw new NotImplementedException();
_cards.Clear();
}
}
}
+33 -6
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -8,23 +8,50 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class InterpreterQueue : IResolutionTrack
{
private readonly Queue<CardInstance> queuedCards = new();
private readonly Queue<CardInstance> _queuedCards = new();
private readonly List<CardInstance> _queuedView = new();
public int Count => queuedCards.Count;
public int Count => _queuedCards.Count;
public IReadOnlyList<CardInstance> Cards => _queuedView;
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card == null)
{
return;
}
_queuedCards.Enqueue(card);
_queuedView.Add(card);
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
while (_queuedCards.Count > 0)
{
CardInstance card = _queuedCards.Dequeue();
_queuedView.Remove(card);
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue));
}
}
public bool TryDequeue(out CardInstance card)
{
if (_queuedCards.Count == 0)
{
card = null;
return false;
}
card = _queuedCards.Dequeue();
_queuedView.Remove(card);
return true;
}
public void Clear()
{
throw new NotImplementedException();
_queuedCards.Clear();
_queuedView.Clear();
}
}
}
+30 -6
View File
@@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -8,23 +8,47 @@ namespace KernelPanic.Combat
/// </summary>
public sealed class LazyStack : IResolutionTrack
{
private readonly Stack<CardInstance> stackedCards = new();
private readonly Stack<CardInstance> _stackedCards = new();
private readonly List<CardInstance> _stackedView = new();
public int Count => stackedCards.Count;
public int Count => _stackedCards.Count;
public IReadOnlyList<CardInstance> Cards => _stackedView;
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card == null)
{
return;
}
_stackedCards.Push(card);
_stackedView.Insert(0, card);
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
ForceEvaluate(context);
}
public void ForceEvaluate(CombatContext context)
{
throw new NotImplementedException();
while (TryPop(out CardInstance card))
{
GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.LazyStack));
}
}
public bool TryPop(out CardInstance card)
{
if (_stackedCards.Count == 0)
{
card = null;
return false;
}
card = _stackedCards.Pop();
_stackedView.Remove(card);
return true;
}
}
}
+6 -3
View File
@@ -1,4 +1,4 @@
using System;
using KernelPanic.Core;
namespace KernelPanic.Combat
{
@@ -9,12 +9,15 @@ namespace KernelPanic.Combat
{
public void Enqueue(CardInstance card)
{
throw new NotImplementedException();
if (card != null)
{
GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, ResolutionTrack.Native));
}
}
public void Resolve(CombatContext context)
{
throw new NotImplementedException();
// Native cards resolve immediately in CombatManager.PlayCard; this track has no queue.
}
}
}
+47
View File
@@ -0,0 +1,47 @@
using System;
namespace KernelPanic.Combat
{
/// <summary>
/// Carries source data for combat rolls so later passives can bias the result.
/// </summary>
public readonly struct RollContext
{
public RollContext(CombatantState source)
{
Source = source;
}
public static RollContext None => new(null);
public CombatantState Source { get; }
}
/// <summary>
/// Single combat randomness chokepoint. Seeded from RunConfig.RunSeed during boot for reproducible runs.
/// </summary>
public static class RandomRoll
{
private static Random _random = new(0);
public static void Seed(int seed)
{
_random = new Random(seed);
}
public static int RollRange(int min, int max, RollContext context)
{
if (min > max)
{
(min, max) = (max, min);
}
// Mint's passive will flip this bool; no passive sets it in this scaffold.
if (context.Source != null && context.Source.forceMaxRolls)
{
return max;
}
return _random.Next(min, max + 1);
}
}
}
+191 -6
View File
@@ -1,26 +1,211 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using UnityEngine;
namespace KernelPanic.Combat
{
/// <summary>
/// Applies, ticks, and expires status effects on a combatant.
/// Applies, ticks, cleanses, and expires status effects on a combatant.
/// </summary>
public sealed class StatusEffectController
{
public void Apply(CombatantState target, StatusType statusType, int stacks)
private static readonly Dictionary<StatusType, StatusDescriptor> Descriptors = new()
{
throw new NotImplementedException();
[StatusType.MemoryLeak] = new(StatusType.MemoryLeak, "Memory Leak", "leak", false, StatusStackingRule.Intensity, StatusTickTiming.StartOfTurn, "Takes damage equal to stacks at start of turn."),
[StatusType.Segfault] = new(StatusType.Segfault, "Segfault", "segv", false, StatusStackingRule.Unique, StatusTickTiming.StartOfTurn, "Loses one cycle this turn. TODO: lock a random card slot."),
[StatusType.RaceCondition] = new(StatusType.RaceCondition, "Race Condition", "race", false, StatusStackingRule.Refresh, StatusTickTiming.None, "Randomizes multi-target resolution order where supported."),
[StatusType.Deprecated] = new(StatusType.Deprecated, "Deprecated", "dep", false, StatusStackingRule.Duration, StatusTickTiming.StartOfTurn, "A temporary positive value decays. Currently reduces shield by 1."),
[StatusType.DependencyError] = new(StatusType.DependencyError, "Dependency Error", "deps", false, StatusStackingRule.Unique, StatusTickTiming.None, "Registered for off-support card cost rules. TODO: connect draw-time cost path."),
[StatusType.Deadlock] = new(StatusType.Deadlock, "Deadlock", "lock", false, StatusStackingRule.Duration, StatusTickTiming.None, "Registered goroutine freeze. TODO: connect goroutine token ticking."),
[StatusType.UnattendedUpgrades] = new(StatusType.UnattendedUpgrades, "Unattended Upgrades", "apt", true, StatusStackingRule.Duration, StatusTickTiming.EndOfTurn, "Grants shield at end of turn, then expires."),
};
public static StatusDescriptor GetDescriptor(StatusType statusType)
{
return Descriptors[statusType];
}
public void Tick(CombatantState target)
public static bool Has(CombatantState target, StatusType statusType)
{
throw new NotImplementedException();
return target != null && target.HasStatus(statusType);
}
public static int StacksOf(CombatantState target, StatusType statusType)
{
return target == null ? 0 : target.StacksOf(statusType);
}
public void Apply(CombatantState target, StatusType statusType, int stacks, int duration, CombatantState source = null, bool skipNextTick = false)
{
if (target == null || target.IsDefeated)
{
return;
}
StatusDescriptor descriptor = GetDescriptor(statusType);
StatusInstance existing = target.MutableStatuses.Find(status => status.Type == statusType);
int safeStacks = Mathf.Max(1, stacks);
int safeDuration = duration == -1 ? -1 : Mathf.Max(1, duration);
if (existing == null)
{
target.MutableStatuses.Add(new StatusInstance(statusType, safeStacks, safeDuration, source, skipNextTick));
}
else
{
existing.Source = source ?? existing.Source;
Merge(existing, descriptor.StackingRule, safeStacks, safeDuration, skipNextTick);
}
GameEvents.RaiseStatusApplied(new StatusAppliedEvent(source, target, statusType, safeStacks, safeDuration));
}
public void Tick(CombatantState target, StatusTickTiming timing, CombatantState source, DamagePipeline damagePipeline)
{
if (target == null || target.IsDefeated)
{
return;
}
List<StatusInstance> snapshot = new(target.MutableStatuses);
for (int i = 0; i < snapshot.Count; i++)
{
StatusInstance status = snapshot[i];
if (!target.MutableStatuses.Contains(status))
{
continue;
}
StatusDescriptor descriptor = GetDescriptor(status.Type);
if (descriptor.TickTiming != timing)
{
continue;
}
if (status.SkipNextTick)
{
status.SkipNextTick = false;
continue;
}
ResolveTick(status, target, status.Source ?? source, damagePipeline);
DecayDuration(target, status);
}
}
public int Cleanse(CombatantState target, Predicate<StatusInstance> predicate)
{
if (target == null || predicate == null)
{
return 0;
}
int removed = 0;
for (int i = target.MutableStatuses.Count - 1; i >= 0; i--)
{
StatusInstance status = target.MutableStatuses[i];
if (predicate(status))
{
target.MutableStatuses.RemoveAt(i);
removed++;
GameEvents.RaiseStatusCleansed(new StatusCleansedEvent(target, status.Type));
}
}
return removed;
}
public int CleanseHarmful(CombatantState target)
{
return Cleanse(target, status => !GetDescriptor(status.Type).IsBeneficial);
}
public void Expire(CombatantState target, StatusType statusType)
{
throw new NotImplementedException();
if (target == null)
{
return;
}
for (int i = target.MutableStatuses.Count - 1; i >= 0; i--)
{
if (target.MutableStatuses[i].Type == statusType)
{
target.MutableStatuses.RemoveAt(i);
GameEvents.RaiseStatusExpired(new StatusExpiredEvent(target, statusType));
}
}
}
private static void Merge(StatusInstance existing, StatusStackingRule stackingRule, int stacks, int duration, bool skipNextTick)
{
switch (stackingRule)
{
case StatusStackingRule.Intensity:
existing.Stacks += stacks;
existing.Duration = MergeDuration(existing.Duration, duration);
break;
case StatusStackingRule.Duration:
existing.Stacks = Mathf.Max(existing.Stacks, stacks);
existing.Duration = existing.Duration == -1 || duration == -1 ? -1 : existing.Duration + duration;
break;
case StatusStackingRule.Refresh:
existing.Stacks = Mathf.Max(existing.Stacks, stacks);
existing.Duration = duration;
break;
case StatusStackingRule.Unique:
break;
}
existing.SkipNextTick |= skipNextTick;
}
private static int MergeDuration(int current, int incoming)
{
if (current == -1 || incoming == -1)
{
return -1;
}
return Mathf.Max(current, incoming);
}
private static void ResolveTick(StatusInstance status, CombatantState target, CombatantState source, DamagePipeline damagePipeline)
{
switch (status.Type)
{
case StatusType.MemoryLeak:
damagePipeline.DealDamage(new DamageRequest(source, target, status.Stacks, Language.C, false, false));
break;
case StatusType.Segfault:
target.Cycles = Mathf.Max(0, target.Cycles - 1);
break;
case StatusType.Deprecated:
target.Shield = Mathf.Max(0, target.Shield - 1);
break;
case StatusType.UnattendedUpgrades:
target.Shield += status.Stacks;
break;
case StatusType.DependencyError:
case StatusType.Deadlock:
case StatusType.RaceCondition:
break;
}
}
private void DecayDuration(CombatantState target, StatusInstance status)
{
if (status.Duration == -1)
{
return;
}
status.Duration--;
if (status.Duration <= 0)
{
Expire(target, status.Type);
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using KernelPanic.Core;
namespace KernelPanic.Combat
{
public enum StatusStackingRule
{
Intensity,
Duration,
Refresh,
Unique
}
public enum StatusTickTiming
{
None,
StartOfTurn,
EndOfTurn
}
public sealed class StatusInstance
{
public StatusInstance(StatusType type, int stacks, int duration, CombatantState source = null, bool skipNextTick = false)
{
Type = type;
Stacks = stacks;
Duration = duration;
Source = source;
SkipNextTick = skipNextTick;
}
public StatusType Type { get; }
public int Stacks { get; set; }
public int Duration { get; set; }
public CombatantState Source { get; set; }
public bool SkipNextTick { get; set; }
}
public readonly struct StatusDescriptor
{
public StatusDescriptor(
StatusType type,
string displayName,
string iconKey,
bool isBeneficial,
StatusStackingRule stackingRule,
StatusTickTiming tickTiming,
string tooltip)
{
Type = type;
DisplayName = displayName;
IconKey = iconKey;
IsBeneficial = isBeneficial;
StackingRule = stackingRule;
TickTiming = tickTiming;
Tooltip = tooltip;
}
public StatusType Type { get; }
public string DisplayName { get; }
public string IconKey { get; }
public bool IsBeneficial { get; }
public StatusStackingRule StackingRule { get; }
public StatusTickTiming TickTiming { get; }
public string Tooltip { get; }
}
}
+182
View File
@@ -12,7 +12,17 @@ namespace KernelPanic.Core
public static event Action<CardResolvedEvent> CardResolved;
public static event Action<PhaseChangedEvent> PhaseChanged;
public static event Action<DamageDealtEvent> DamageDealt;
public static event Action<CombatantDefeatedEvent> CombatantDefeated;
public static event Action<EnemyWouldActEvent> EnemyWouldAct;
public static event Action<EnemyActedEvent> EnemyActed;
public static event Action<PlayerDamagedEvent> PlayerDamaged;
public static event Action<EncounterWonEvent> EncounterWon;
public static event Action<EncounterLostEvent> EncounterLost;
public static event Action<WaveClearedEvent> WaveCleared;
public static event Action<WaveAdvancedEvent> WaveAdvanced;
public static event Action<StatusAppliedEvent> StatusApplied;
public static event Action<StatusExpiredEvent> StatusExpired;
public static event Action<StatusCleansedEvent> StatusCleansed;
public static event Action<RunEndedEvent> RunEnded;
public static void RaiseCardPlayed(CardPlayedEvent payload)
@@ -35,11 +45,61 @@ namespace KernelPanic.Core
DamageDealt?.Invoke(payload);
}
public static void RaiseCombatantDefeated(CombatantDefeatedEvent payload)
{
CombatantDefeated?.Invoke(payload);
}
public static void RaiseEnemyWouldAct(EnemyWouldActEvent payload)
{
EnemyWouldAct?.Invoke(payload);
}
public static void RaiseEnemyActed(EnemyActedEvent payload)
{
EnemyActed?.Invoke(payload);
}
public static void RaisePlayerDamaged(PlayerDamagedEvent payload)
{
PlayerDamaged?.Invoke(payload);
}
public static void RaiseEncounterWon(EncounterWonEvent payload)
{
EncounterWon?.Invoke(payload);
}
public static void RaiseEncounterLost(EncounterLostEvent payload)
{
EncounterLost?.Invoke(payload);
}
public static void RaiseWaveCleared(WaveClearedEvent payload)
{
WaveCleared?.Invoke(payload);
}
public static void RaiseWaveAdvanced(WaveAdvancedEvent payload)
{
WaveAdvanced?.Invoke(payload);
}
public static void RaiseStatusApplied(StatusAppliedEvent payload)
{
StatusApplied?.Invoke(payload);
}
public static void RaiseStatusExpired(StatusExpiredEvent payload)
{
StatusExpired?.Invoke(payload);
}
public static void RaiseStatusCleansed(StatusCleansedEvent payload)
{
StatusCleansed?.Invoke(payload);
}
public static void RaiseRunEnded(RunEndedEvent payload)
{
RunEnded?.Invoke(payload);
@@ -110,6 +170,76 @@ namespace KernelPanic.Core
public Language Language { get; }
}
/// <summary>
/// Describes a combatant reaching zero uptime through the damage pipeline.
/// </summary>
public readonly struct CombatantDefeatedEvent
{
public CombatantDefeatedEvent(CombatantState combatant)
{
Combatant = combatant;
}
public CombatantState Combatant { get; }
}
/// <summary>
/// Describes an enemy placeholder reaching its structural action point.
/// </summary>
public readonly struct EnemyWouldActEvent
{
public EnemyWouldActEvent(EnemyInstance enemy)
{
Enemy = enemy;
}
public EnemyInstance Enemy { get; }
}
public readonly struct EnemyActedEvent
{
public EnemyActedEvent(EnemyInstance enemy, EnemyIntent intent)
{
Enemy = enemy;
Intent = intent;
}
public EnemyInstance Enemy { get; }
public EnemyIntent Intent { get; }
}
public readonly struct PlayerDamagedEvent
{
public PlayerDamagedEvent(EnemyInstance enemy, int amount)
{
Enemy = enemy;
Amount = amount;
}
public EnemyInstance Enemy { get; }
public int Amount { get; }
}
public readonly struct EncounterWonEvent
{
public EncounterWonEvent(int waveNumber)
{
WaveNumber = waveNumber;
}
public int WaveNumber { get; }
}
public readonly struct EncounterLostEvent
{
public EncounterLostEvent(int waveNumber)
{
WaveNumber = waveNumber;
}
public int WaveNumber { get; }
}
/// <summary>
/// Describes a completed enemy wave.
/// </summary>
@@ -123,6 +253,58 @@ namespace KernelPanic.Core
public int WaveNumber { get; }
}
public readonly struct WaveAdvancedEvent
{
public WaveAdvancedEvent(int waveNumber)
{
WaveNumber = waveNumber;
}
public int WaveNumber { get; }
}
public readonly struct StatusAppliedEvent
{
public StatusAppliedEvent(CombatantState source, CombatantState target, StatusType statusType, int stacks, int duration)
{
Source = source;
Target = target;
StatusType = statusType;
Stacks = stacks;
Duration = duration;
}
public CombatantState Source { get; }
public CombatantState Target { get; }
public StatusType StatusType { get; }
public int Stacks { get; }
public int Duration { get; }
}
public readonly struct StatusExpiredEvent
{
public StatusExpiredEvent(CombatantState target, StatusType statusType)
{
Target = target;
StatusType = statusType;
}
public CombatantState Target { get; }
public StatusType StatusType { get; }
}
public readonly struct StatusCleansedEvent
{
public StatusCleansedEvent(CombatantState target, StatusType statusType)
{
Target = target;
StatusType = statusType;
}
public CombatantState Target { get; }
public StatusType StatusType { get; }
}
/// <summary>
/// Describes the end of a run and its earned meta currency.
/// </summary>
+1 -1
View File
@@ -16,6 +16,6 @@ namespace KernelPanic.Core
Java,
Go,
Ruby,
PHP
Php
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ namespace KernelPanic.Core
RaceCondition,
Deprecated,
DependencyError,
Deadlock
Deadlock,
UnattendedUpgrades
}
}
+2
View File
@@ -19,6 +19,7 @@ namespace KernelPanic.Data
[SerializeField] private ResolutionTrack resolutionTrack;
[SerializeField] private bool distroExclusive;
[SerializeField] private bool isToken;
[SerializeField] private bool isRunOnly;
[SerializeField, TextArea] private string designNotes;
[SerializeField] private List<string> requiresSystem = new();
[SerializeField] private CardDefinition upgradeTarget;
@@ -34,6 +35,7 @@ namespace KernelPanic.Data
public ResolutionTrack ResolutionTrack => resolutionTrack;
public bool DistroExclusive => distroExclusive;
public bool IsToken => isToken;
public bool IsRunOnly => isRunOnly;
public string DesignNotes => designNotes;
public IReadOnlyList<string> RequiresSystem => requiresSystem;
public CardDefinition UpgradeTarget => upgradeTarget;
+1 -1
View File
@@ -22,7 +22,7 @@ namespace KernelPanic.Data
new(Language.Java, "Java", "warming vm", ResolutionTrack.Native, "TODO: Java starts expensive and gets cheaper as the combat warms up. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("fedora", "Fedora")),
new(Language.Go, "Go", "small services", ResolutionTrack.Native, "TODO: Go uses direct native actions with simple scaling and concurrency hooks. Signature mechanic copy pending.", null, Distro("cachyos", "CachyOS"), Distro("nixos", "NixOS")),
new(Language.Ruby, "Ruby", "sharp scripts", ResolutionTrack.InterpreterQueue, "TODO: Ruby queues expressive effects with combo-friendly timing. Signature mechanic copy pending.", null, Distro("kali", "Kali"), Distro("endeavouros", "EndeavourOS")),
new(Language.PHP, "PHP", "legacy glue", ResolutionTrack.InterpreterQueue, "TODO: PHP queues practical effects that improve when the board is messy. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("cachyos", "CachyOS")),
new(Language.Php, "PHP", "legacy glue", ResolutionTrack.InterpreterQueue, "TODO: PHP queues practical effects that improve when the board is messy. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("cachyos", "CachyOS")),
};
public static IReadOnlyList<LanguageCatalogEntry> All => Entries;
+16 -16
View File
@@ -21,24 +21,24 @@ namespace KernelPanic.Meta
{
public const int MaxEquippedCards = 4;
private readonly IReadOnlyList<DistroDefinition> ownedUnits;
private readonly Dictionary<string, List<string>> equippedByDistro = new(StringComparer.OrdinalIgnoreCase);
private readonly IReadOnlyList<DistroDefinition> _ownedUnits;
private readonly Dictionary<string, List<string>> _equippedByDistro = new(StringComparer.OrdinalIgnoreCase);
public CardLoadout(IReadOnlyList<DistroDefinition> ownedUnits)
{
this.ownedUnits = ownedUnits ?? Array.Empty<DistroDefinition>();
this._ownedUnits = ownedUnits ?? Array.Empty<DistroDefinition>();
}
public IReadOnlyList<string> GetEquippedCardIds(string distroId)
{
return equippedByDistro.TryGetValue(distroId, out List<string> equipped)
return _equippedByDistro.TryGetValue(distroId, out List<string> equipped)
? equipped
: Array.Empty<string>();
}
public bool HasLoadout(string distroId)
{
return !string.IsNullOrWhiteSpace(distroId) && equippedByDistro.ContainsKey(distroId);
return !string.IsNullOrWhiteSpace(distroId) && _equippedByDistro.ContainsKey(distroId);
}
public void EnsureLoadout(DistroDefinition distro)
@@ -48,12 +48,12 @@ namespace KernelPanic.Meta
return;
}
equippedByDistro[distro.Id] = new List<string>();
_equippedByDistro[distro.Id] = new List<string>();
}
public void ClearAll()
{
equippedByDistro.Clear();
_equippedByDistro.Clear();
}
public void ClearLoadout(DistroDefinition distro)
@@ -63,7 +63,7 @@ namespace KernelPanic.Meta
return;
}
equippedByDistro[distro.Id] = new List<string>();
_equippedByDistro[distro.Id] = new List<string>();
}
public bool TryLoad(string distroId, IEnumerable<string> cardIds, out bool skippedInvalid)
@@ -80,7 +80,7 @@ namespace KernelPanic.Meta
foreach (string cardId in cardIds ?? Array.Empty<string>())
{
CardDefinition card = FindExclusiveCard(distro, cardId);
if (card == null || card.IsToken || equipped.Contains(card.Id))
if (card == null || card.IsToken || card.IsRunOnly || equipped.Contains(card.Id))
{
skippedInvalid = true;
continue;
@@ -96,7 +96,7 @@ namespace KernelPanic.Meta
}
}
equippedByDistro[distro.Id] = equipped;
_equippedByDistro[distro.Id] = equipped;
return true;
}
@@ -116,14 +116,14 @@ namespace KernelPanic.Meta
return false;
}
if (card.IsToken)
if (card.IsToken || card.IsRunOnly)
{
reason = CardLoadoutFailureReason.Token;
return false;
}
EnsureLoadout(distro);
List<string> equipped = equippedByDistro[distro.Id];
List<string> equipped = _equippedByDistro[distro.Id];
if (equipped.Contains(card.Id))
{
reason = CardLoadoutFailureReason.Duplicate;
@@ -151,7 +151,7 @@ namespace KernelPanic.Meta
}
EnsureLoadout(distro);
List<string> equipped = equippedByDistro[distro.Id];
List<string> equipped = _equippedByDistro[distro.Id];
if (!equipped.Remove(cardId))
{
reason = CardLoadoutFailureReason.NotEquipped;
@@ -169,9 +169,9 @@ namespace KernelPanic.Meta
return null;
}
for (int i = 0; i < ownedUnits.Count; i++)
for (int i = 0; i < _ownedUnits.Count; i++)
{
DistroDefinition distro = ownedUnits[i];
DistroDefinition distro = _ownedUnits[i];
if (distro != null && string.Equals(distro.Id, distroId, StringComparison.OrdinalIgnoreCase))
{
return distro;
@@ -191,7 +191,7 @@ namespace KernelPanic.Meta
for (int i = 0; i < distro.ExclusiveCards.Count; i++)
{
CardDefinition card = distro.ExclusiveCards[i];
if (card != null && string.Equals(card.Id, cardId, StringComparison.OrdinalIgnoreCase))
if (card != null && !card.IsRunOnly && string.Equals(card.Id, cardId, StringComparison.OrdinalIgnoreCase))
{
return card;
}
+55 -54
View File
@@ -19,6 +19,7 @@ namespace KernelPanic.Meta
public const int FiveStarSoftPityStart = 66;
public const int FiveStarHardPity = 80;
public const int EntropyPerPullToken = 100;
public const int TestRootCreditsBalance = 10000;
private const double FourStarBaseChance = 0.12d;
private const double FiveStarBaseChance = 0.008d;
@@ -26,10 +27,10 @@ namespace KernelPanic.Meta
private const double DistroChanceOnFeaturedTier = 0.5d;
private const double LimitedStandardFiveStarChance = 1d / 3d;
private readonly List<DistroDefinition> bannerPool = new();
private readonly Dictionary<GachaCurrencyType, int> currencyBalances = new();
private readonly Random random;
private GachaBannerState beginnerState = new(BeginnerBannerId);
private readonly List<DistroDefinition> _bannerPool = new();
private readonly Dictionary<GachaCurrencyType, int> _currencyBalances = new();
private readonly Random _random;
private GachaBannerState _beginnerState = new(BeginnerBannerId);
public GachaService()
: this(new Random())
@@ -38,17 +39,17 @@ namespace KernelPanic.Meta
public GachaService(Random random)
{
this.random = random ?? new Random();
currencyBalances[GachaCurrencyType.StandardPull] = 0;
currencyBalances[GachaCurrencyType.LimitedPull] = 0;
this._random = random ?? new Random();
_currencyBalances[GachaCurrencyType.StandardPull] = 0;
_currencyBalances[GachaCurrencyType.LimitedPull] = 0;
}
public int PullTokens => GetCurrencyBalance(GachaCurrencyType.StandardPull);
public int LimitedPullTokens => GetCurrencyBalance(GachaCurrencyType.LimitedPull);
public int RootCredits { get; private set; }
public IReadOnlyList<DistroDefinition> BannerPool => bannerPool;
public GachaBannerState BeginnerState => beginnerState;
public bool IsBeginnerBannerAvailable => !beginnerState.exhausted && beginnerState.totalPulls < BeginnerMaxPulls && bannerPool.Count > 0;
public IReadOnlyList<DistroDefinition> BannerPool => _bannerPool;
public GachaBannerState BeginnerState => _beginnerState;
public bool IsBeginnerBannerAvailable => !_beginnerState.exhausted && _beginnerState.totalPulls < BeginnerMaxPulls && _bannerPool.Count > 0;
public event Action BannerPoolChanged;
public event Action Changed;
@@ -61,18 +62,18 @@ namespace KernelPanic.Meta
}
data.EnsureLists();
RootCredits = Math.Max(0, data.rootCredits);
RootCredits = Math.Max(TestRootCreditsBalance, data.rootCredits);
SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.standardPullCurrency);
SetCurrencyBalanceSilently(GachaCurrencyType.LimitedPull, data.limitedPullCurrency);
beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId);
beginnerState.bannerId = BeginnerBannerId;
beginnerState.EnsureLists();
if (beginnerState.totalPulls >= BeginnerMaxPulls)
_beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId);
_beginnerState.bannerId = BeginnerBannerId;
_beginnerState.EnsureLists();
if (_beginnerState.totalPulls >= BeginnerMaxPulls)
{
beginnerState.exhausted = true;
_beginnerState.exhausted = true;
}
beginnerState.fiveStarPityCounter = Math.Max(0, beginnerState.fiveStarPityCounter);
_beginnerState.fiveStarPityCounter = Math.Max(0, _beginnerState.fiveStarPityCounter);
Changed?.Invoke();
}
@@ -88,12 +89,12 @@ namespace KernelPanic.Meta
data.rootCredits = RootCredits;
data.standardPullCurrency = GetCurrencyBalance(GachaCurrencyType.StandardPull);
data.limitedPullCurrency = GetCurrencyBalance(GachaCurrencyType.LimitedPull);
data.beginnerBannerState = CloneState(beginnerState);
data.beginnerBannerState = CloneState(_beginnerState);
}
public int GetCurrencyBalance(GachaCurrencyType currencyType)
{
return currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0;
return _currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0;
}
public void SetCurrencyBalance(GachaCurrencyType currencyType, int amount)
@@ -152,19 +153,19 @@ namespace KernelPanic.Meta
public void AddToBannerPool(DistroDefinition distro)
{
if (distro == null || bannerPool.Contains(distro))
if (distro == null || _bannerPool.Contains(distro))
{
return;
}
bannerPool.Add(distro);
_bannerPool.Add(distro);
BannerPoolChanged?.Invoke();
Changed?.Invoke();
}
public void SetBeginnerGuaranteedDistros(IReadOnlyList<DistroDefinition> distros)
{
beginnerState.guaranteedDistroIds.Clear();
_beginnerState.guaranteedDistroIds.Clear();
if (distros != null)
{
for (int i = 0; i < distros.Count; i++)
@@ -172,7 +173,7 @@ namespace KernelPanic.Meta
DistroDefinition distro = distros[i];
if (distro != null && !string.IsNullOrWhiteSpace(distro.Id))
{
beginnerState.guaranteedDistroIds.Add(distro.Id);
_beginnerState.guaranteedDistroIds.Add(distro.Id);
}
}
}
@@ -199,7 +200,7 @@ namespace KernelPanic.Meta
return false;
}
int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls;
int remainingPulls = BeginnerMaxPulls - _beginnerState.totalPulls;
if (pullCount > remainingPulls)
{
failureReason = $"only {remainingPulls} beginner pull(s) remain";
@@ -255,13 +256,13 @@ namespace KernelPanic.Meta
List<GachaReward> rewards = new();
for (int i = 0; i < pullCount; i++)
{
beginnerState.totalPulls++;
rewards.Add(RollBeginnerReward(beginnerState.totalPulls));
_beginnerState.totalPulls++;
rewards.Add(RollBeginnerReward(_beginnerState.totalPulls));
}
if (beginnerState.totalPulls >= BeginnerMaxPulls)
if (_beginnerState.totalPulls >= BeginnerMaxPulls)
{
beginnerState.exhausted = true;
_beginnerState.exhausted = true;
}
Changed?.Invoke();
@@ -272,8 +273,8 @@ namespace KernelPanic.Meta
{
return currencyType switch
{
GachaCurrencyType.LimitedPull => "feature-pull-token",
_ => "stable-pull-token"
GachaCurrencyType.LimitedPull => "Compute Credits",
_ => "Commits"
};
}
@@ -308,39 +309,39 @@ namespace KernelPanic.Meta
{
if (pullNumber == 20)
{
AdvanceFiveStarPityAfterNonFiveStar(beginnerState);
AdvanceFiveStarPityAfterNonFiveStar(_beginnerState);
return RewardGuaranteedBeginnerDistro(0, "20-pull starter guarantee");
}
if (pullNumber == 40)
{
AdvanceFiveStarPityAfterNonFiveStar(beginnerState);
AdvanceFiveStarPityAfterNonFiveStar(_beginnerState);
return RewardGuaranteedBeginnerDistro(1, "40-pull starter guarantee");
}
if (pullNumber == 50)
{
beginnerState.pityCounter = 0;
_beginnerState.pityCounter = 0;
return GachaReward.FutureStandardFiveStar("future standard 5-star distro", "50-pull beginner capstone");
}
if (TryRollFiveStar(beginnerState, out bool fiveStarPityTriggered))
if (TryRollFiveStar(_beginnerState, out bool fiveStarPityTriggered))
{
return RewardRandomFiveStar(fiveStarPityTriggered);
}
bool pityTriggered = beginnerState.pityCounter >= FourStarHardPity - 1;
bool hitFourStarOrCharacter = pityTriggered || random.NextDouble() < FourStarBaseChance;
bool pityTriggered = _beginnerState.pityCounter >= FourStarHardPity - 1;
bool hitFourStarOrCharacter = pityTriggered || _random.NextDouble() < FourStarBaseChance;
if (!hitFourStarOrCharacter)
{
beginnerState.pityCounter++;
_beginnerState.pityCounter++;
return GachaReward.Equipment(3, "3-star equipment", false, false);
}
beginnerState.pityCounter = 0;
if (bannerPool.Count > 0 && random.NextDouble() < DistroChanceOnFeaturedTier)
_beginnerState.pityCounter = 0;
if (_bannerPool.Count > 0 && _random.NextDouble() < DistroChanceOnFeaturedTier)
{
DistroDefinition distro = bannerPool[random.Next(bannerPool.Count)];
DistroDefinition distro = _bannerPool[_random.Next(_bannerPool.Count)];
return GachaReward.DistroReward(distro, 4, pityTriggered, false);
}
@@ -349,12 +350,12 @@ namespace KernelPanic.Meta
private bool TryRollFiveStar(GachaBannerState state, out bool pityTriggered)
{
state ??= beginnerState;
state ??= _beginnerState;
int pityPullNumber = Math.Max(0, state.fiveStarPityCounter) + 1;
pityTriggered = pityPullNumber >= FiveStarSoftPityStart;
double chance = GetFiveStarChance(state);
bool hit = chance >= 1d || random.NextDouble() < chance;
bool hit = chance >= 1d || _random.NextDouble() < chance;
if (!hit)
{
state.fiveStarPityCounter++;
@@ -376,18 +377,18 @@ namespace KernelPanic.Meta
private GachaReward RewardRandomFiveStar(bool pityTriggered)
{
if (bannerPool.Count == 0 || random.NextDouble() >= DistroChanceOnFeaturedTier)
if (_bannerPool.Count == 0 || _random.NextDouble() >= DistroChanceOnFeaturedTier)
{
return GachaReward.Equipment(5, "5-star equipment", pityTriggered, false);
}
DistroDefinition distro = bannerPool.Count == 0 ? null : bannerPool[random.Next(bannerPool.Count)];
DistroDefinition distro = _bannerPool.Count == 0 ? null : _bannerPool[_random.Next(_bannerPool.Count)];
return GachaReward.DistroReward(distro, 5, pityTriggered, false);
}
public bool ResolveFeaturedTierIsDistro()
{
return random.NextDouble() < DistroChanceOnFeaturedTier;
return _random.NextDouble() < DistroChanceOnFeaturedTier;
}
public bool ResolveLimitedFiveStarUsesStandardPool(GachaBannerState limitedState)
@@ -403,21 +404,21 @@ namespace KernelPanic.Meta
return false;
}
bool useStandardPool = random.NextDouble() < LimitedStandardFiveStarChance;
bool useStandardPool = _random.NextDouble() < LimitedStandardFiveStarChance;
limitedState.featuredFiveStarGuaranteed = useStandardPool;
return useStandardPool;
}
private GachaReward RewardGuaranteedBeginnerDistro(int guaranteeIndex, string reason)
{
beginnerState.pityCounter = 0;
_beginnerState.pityCounter = 0;
DistroDefinition distro = null;
if (guaranteeIndex >= 0 && guaranteeIndex < beginnerState.guaranteedDistroIds.Count)
if (guaranteeIndex >= 0 && guaranteeIndex < _beginnerState.guaranteedDistroIds.Count)
{
distro = FindBannerPoolDistro(beginnerState.guaranteedDistroIds[guaranteeIndex]);
distro = FindBannerPoolDistro(_beginnerState.guaranteedDistroIds[guaranteeIndex]);
}
distro ??= bannerPool.Count == 0 ? null : bannerPool[Math.Min(guaranteeIndex, bannerPool.Count - 1)];
distro ??= _bannerPool.Count == 0 ? null : _bannerPool[Math.Min(guaranteeIndex, _bannerPool.Count - 1)];
return distro == null
? GachaReward.Equipment(4, "4-star equipment", true, true)
: GachaReward.DistroReward(distro, 4, true, true, reason);
@@ -430,9 +431,9 @@ namespace KernelPanic.Meta
return null;
}
for (int i = 0; i < bannerPool.Count; i++)
for (int i = 0; i < _bannerPool.Count; i++)
{
DistroDefinition distro = bannerPool[i];
DistroDefinition distro = _bannerPool[i];
if (distro != null && string.Equals(distro.Id, id, StringComparison.OrdinalIgnoreCase))
{
return distro;
@@ -444,7 +445,7 @@ namespace KernelPanic.Meta
private void SetCurrencyBalanceSilently(GachaCurrencyType currencyType, int amount)
{
currencyBalances[currencyType] = Math.Max(0, amount);
_currencyBalances[currencyType] = Math.Max(0, amount);
}
private bool CanPullBeginnerWithEntropy(int pullCount, EntropyWallet wallet, int entropyTokenCount, out string failureReason)
@@ -461,7 +462,7 @@ namespace KernelPanic.Meta
return false;
}
int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls;
int remainingPulls = BeginnerMaxPulls - _beginnerState.totalPulls;
if (pullCount > remainingPulls)
{
failureReason = $"only {remainingPulls} beginner pull(s) remain";
+10 -10
View File
@@ -9,12 +9,12 @@ namespace KernelPanic.Meta
/// </summary>
public sealed class PlayerCollection
{
private readonly List<DistroDefinition> ownedUnits = new();
private int featuredIndex;
private readonly List<DistroDefinition> _ownedUnits = new();
private int _featuredIndex;
// TODO: Populate owned units from SaveService during bootstrap.
public IReadOnlyList<DistroDefinition> OwnedUnits => ownedUnits;
public DistroDefinition FeaturedUnit => ownedUnits.Count == 0 ? null : ownedUnits[Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1)];
public IReadOnlyList<DistroDefinition> OwnedUnits => _ownedUnits;
public DistroDefinition FeaturedUnit => _ownedUnits.Count == 0 ? null : _ownedUnits[Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1)];
public event Action Changed;
@@ -26,8 +26,8 @@ namespace KernelPanic.Meta
}
// Persistence is wired by the owner so this domain class stays IO-free.
ownedUnits.Add(unit);
featuredIndex = Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1);
_ownedUnits.Add(unit);
_featuredIndex = Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1);
Changed?.Invoke();
}
@@ -39,9 +39,9 @@ namespace KernelPanic.Meta
}
int count = 0;
for (int i = 0; i < ownedUnits.Count; i++)
for (int i = 0; i < _ownedUnits.Count; i++)
{
DistroDefinition unit = ownedUnits[i];
DistroDefinition unit = _ownedUnits[i];
if (unit != null && string.Equals(unit.Id, unitId, StringComparison.OrdinalIgnoreCase))
{
count++;
@@ -53,12 +53,12 @@ namespace KernelPanic.Meta
public void SelectNextFeatured()
{
if (ownedUnits.Count < 2)
if (_ownedUnits.Count < 2)
{
return;
}
featuredIndex = (featuredIndex + 1) % ownedUnits.Count;
_featuredIndex = (_featuredIndex + 1) % _ownedUnits.Count;
}
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ namespace KernelPanic.Meta
{
public bool starterChosen;
public int entropyBalance;
public int rootCredits;
public int rootCredits = GachaService.TestRootCreditsBalance;
public int standardPullCurrency;
public int limitedPullCurrency;
public List<string> ownedUnitIds = new();
+157
View File
@@ -0,0 +1,157 @@
using KernelPanic.Combat;
using KernelPanic.Data;
namespace KernelPanic.Run
{
public enum RepositoryOfferKind
{
NewCard,
CardUpgrade,
StatUpgrade
}
public enum CardUpgradeKind
{
CostDown,
MagnitudeUp,
DrawRider
}
public enum RunStatUpgradeKind
{
MaxCycles,
MaxUptime,
Heal,
Ram
}
public sealed class RepositoryOffer
{
private RepositoryOffer(
RepositoryOfferKind kind,
string commandName,
string displayName,
string description,
int price)
{
Kind = kind;
CommandName = commandName;
DisplayName = displayName;
Description = description;
Price = price;
}
public RepositoryOfferKind Kind { get; }
public string CommandName { get; }
public string DisplayName { get; }
public string Description { get; }
public int Price { get; }
public CardDefinition CardDefinition { get; private set; }
public CardInstance TargetCard { get; private set; }
public CardUpgradeKind CardUpgradeKind { get; private set; }
public RunStatUpgradeKind StatUpgradeKind { get; private set; }
public bool Sold { get; private set; }
public static RepositoryOffer NewCard(CardDefinition card, int price)
{
string name = DisplayNameFor(card);
RepositoryOffer offer = new(RepositoryOfferKind.NewCard, SafeCommandName(card?.Id, name), name, RulesTextFor(card), price)
{
CardDefinition = card
};
return offer;
}
public static RepositoryOffer CardUpgrade(CardInstance target, CardUpgradeKind upgradeKind, int price)
{
string cardName = DisplayNameFor(target?.Definition);
string description = UpgradeDescription(target, upgradeKind);
RepositoryOffer offer = new(RepositoryOfferKind.CardUpgrade, SafeCommandName(target?.Definition?.Id, cardName), $"upgrade {cardName}", description, price)
{
TargetCard = target,
CardUpgradeKind = upgradeKind
};
return offer;
}
public static RepositoryOffer StatUpgrade(RunStatUpgradeKind upgradeKind, int price)
{
return new RepositoryOffer(RepositoryOfferKind.StatUpgrade, upgradeKind.ToString().ToLowerInvariant(), StatName(upgradeKind), StatDescription(upgradeKind), price)
{
StatUpgradeKind = upgradeKind
};
}
public void MarkSold()
{
Sold = true;
}
private static string RulesTextFor(CardDefinition card)
{
if (card == null)
{
return "--";
}
CardInstance preview = new(card);
return CardEffectFactory.GetRulesText(preview);
}
private static string UpgradeDescription(CardInstance target, CardUpgradeKind upgradeKind)
{
if (target?.Definition == null)
{
return "--";
}
return upgradeKind switch
{
CardUpgradeKind.CostDown => $"cost {CombatManager.GetCardCost(target)} -> {UnityEngine.Mathf.Max(0, CombatManager.GetCardCost(target) - 1)}",
CardUpgradeKind.MagnitudeUp => $"effect magnitude +{CombatTuning.UpgradeMagnitudeBonus}",
CardUpgradeKind.DrawRider => "add rider: draw 1 after resolving",
_ => "--"
};
}
private static string StatName(RunStatUpgradeKind upgradeKind)
{
return upgradeKind switch
{
RunStatUpgradeKind.MaxCycles => "+1 max cycles",
RunStatUpgradeKind.MaxUptime => "+max uptime",
RunStatUpgradeKind.Heal => "heal uptime",
RunStatUpgradeKind.Ram => "+1 RAM",
_ => upgradeKind.ToString()
};
}
private static string StatDescription(RunStatUpgradeKind upgradeKind)
{
return upgradeKind switch
{
RunStatUpgradeKind.MaxCycles => $"+{CombatTuning.StatUpgradeMaxCycles} max cycles this run",
RunStatUpgradeKind.MaxUptime => $"+{CombatTuning.StatUpgradeMaxUptime} max uptime this run",
RunStatUpgradeKind.Heal => $"heal {CombatTuning.StatUpgradeHeal} uptime now",
RunStatUpgradeKind.Ram => $"+{CombatTuning.StatUpgradeRam} RAM this run",
_ => "--"
};
}
private static string DisplayNameFor(CardDefinition card)
{
if (card == null)
{
return "--";
}
return string.IsNullOrWhiteSpace(card.DisplayName) ? card.Id : card.DisplayName;
}
private static string SafeCommandName(string id, string fallback)
{
string value = string.IsNullOrWhiteSpace(id) ? fallback : id;
return string.IsNullOrWhiteSpace(value) ? "package" : value.Replace(' ', '-');
}
}
}
+2 -2
View File
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using KernelPanic.Data;
@@ -11,7 +10,8 @@ namespace KernelPanic.Run
{
public IReadOnlyList<RewardChoice> GeneratePostWaveChoices(RunConfig runConfig, int waveNumber)
{
throw new NotImplementedException();
// TODO: Generate real post-wave rewards once card reward pools and upgrade rules exist.
return new[] { new RewardChoice(RewardChoiceType.Skip, null) };
}
}
+7
View File
@@ -10,16 +10,23 @@ namespace KernelPanic.Run
public sealed class RunConfig
{
public RunConfig(DistroDefinition distro, Language primaryLanguage, Language secondaryLanguage, IReadOnlyList<CardDefinition> startingDeck)
: this(distro, primaryLanguage, secondaryLanguage, startingDeck, 0)
{
}
public RunConfig(DistroDefinition distro, Language primaryLanguage, Language secondaryLanguage, IReadOnlyList<CardDefinition> startingDeck, int runSeed)
{
Distro = distro;
PrimaryLanguage = primaryLanguage;
SecondaryLanguage = secondaryLanguage;
StartingDeck = startingDeck;
RunSeed = runSeed;
}
public DistroDefinition Distro { get; }
public Language PrimaryLanguage { get; }
public Language SecondaryLanguage { get; }
public IReadOnlyList<CardDefinition> StartingDeck { get; }
public int RunSeed { get; }
}
}
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using KernelPanic.Data;
namespace KernelPanic.Run
{
/// <summary>
/// Scene-transition handoff for a just-configured run. This is intentionally a plain static
/// context instead of a ScriptableObject asset because the payload is transient runtime state,
/// not reusable authored content.
/// </summary>
public static class RunContext
{
private static PendingRun pendingRun;
public static bool HasPendingRun => pendingRun != null;
public static void Set(DistroDefinition distro, IReadOnlyList<CardDefinition> equippedCards, Language primaryLanguage, Language secondaryLanguage)
{
pendingRun = new PendingRun(distro, equippedCards, primaryLanguage, secondaryLanguage, Environment.TickCount);
}
public static bool TryCreateRunConfig(LanguageDeckDatabase languageDeckDatabase, out RunConfig config)
{
if (pendingRun == null || pendingRun.Distro == null)
{
config = null;
return false;
}
config = pendingRun.CreateRunConfig(languageDeckDatabase);
pendingRun = null;
return true;
}
private sealed class PendingRun
{
private readonly DistroDefinition distro;
private readonly List<CardDefinition> equippedCards = new();
private readonly Language primaryLanguage;
private readonly Language secondaryLanguage;
private readonly int runSeed;
public PendingRun(DistroDefinition distro, IReadOnlyList<CardDefinition> cards, Language primaryLanguage, Language secondaryLanguage, int runSeed)
{
this.distro = distro;
this.primaryLanguage = primaryLanguage;
this.secondaryLanguage = secondaryLanguage;
this.runSeed = runSeed;
if (cards == null)
{
return;
}
for (int i = 0; i < cards.Count; i++)
{
if (cards[i] != null && !cards[i].IsRunOnly)
{
equippedCards.Add(cards[i]);
}
}
}
public DistroDefinition Distro => distro;
public RunConfig CreateRunConfig(LanguageDeckDatabase languageDeckDatabase)
{
List<CardDefinition> startingDeck = new(equippedCards);
AddLanguageDeck(languageDeckDatabase, primaryLanguage, startingDeck);
AddLanguageDeck(languageDeckDatabase, secondaryLanguage, startingDeck);
return new RunConfig(distro, primaryLanguage, secondaryLanguage, startingDeck, runSeed);
}
private static void AddLanguageDeck(LanguageDeckDatabase languageDeckDatabase, Language language, List<CardDefinition> target)
{
LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language);
if (deck == null)
{
return;
}
for (int entryIndex = 0; entryIndex < deck.Entries.Count; entryIndex++)
{
LanguageDeckDefinition.LanguageDeckEntry entry = deck.Entries[entryIndex];
for (int copy = 0; copy < entry.Count; copy++)
{
if (entry.Card != null && !entry.Card.IsRunOnly)
{
target.Add(entry.Card);
}
}
}
}
}
}
}
+377 -1
View File
@@ -1,5 +1,9 @@
using KernelPanic.Combat;
using KernelPanic.Core;
using KernelPanic.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace KernelPanic.Run
@@ -14,9 +18,30 @@ namespace KernelPanic.Run
[SerializeField] private bool isRunActive;
[SerializeField] private bool isPlayerDead;
private readonly List<CardInstance> runDeck = new();
private readonly List<RepositoryOffer> repositoryOffers = new();
private readonly HashSet<string> soldOfferKeys = new(StringComparer.OrdinalIgnoreCase);
private int bits;
private int rerollsThisVisit;
private int maxUptimeBonus;
private int maxCyclesBonus;
private int ramBonus;
private bool repositoryVisitActive;
public int CurrentWaveNumber => currentWaveNumber;
public bool IsRunActive => isRunActive;
public bool IsPlayerDead => isPlayerDead;
public RunConfig CurrentConfig { get; private set; }
public IReadOnlyList<CardInstance> RunDeck => runDeck;
public IReadOnlyList<RepositoryOffer> RepositoryOffers => repositoryOffers;
public bool RepositoryVisitActive => repositoryVisitActive;
public int Bits => bits;
public int RerollCost => CombatTuning.RerollBaseCost + (rerollsThisVisit * CombatTuning.RerollCostStep);
public int MaxUptimeBonus => maxUptimeBonus;
public int MaxCyclesBonus => maxCyclesBonus;
public int RamBonus => ramBonus;
public event Action RepositoryChanged;
private void OnEnable()
{
@@ -32,25 +57,376 @@ namespace KernelPanic.Run
public void StartRun(RunConfig config)
{
CurrentConfig = config;
isRunActive = true;
isPlayerDead = false;
currentWaveNumber = 1;
bits = 0;
rerollsThisVisit = 0;
maxUptimeBonus = 0;
maxCyclesBonus = 0;
ramBonus = 0;
InitializeRunDeck(config);
StartCombat();
}
public void StartCombat()
{
combatManager?.StartCombat();
combatManager?.StartCombat(CurrentConfig);
}
public void AddBits(int amount)
{
if (!isRunActive || amount <= 0)
{
return;
}
bits += amount;
RepositoryChanged?.Invoke();
}
public void GenerateRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
repositoryOffers.Clear();
rerollsThisVisit = 0;
soldOfferKeys.Clear();
FillRepositoryOffers(cardDatabase, languageDeckDatabase);
repositoryVisitActive = true;
RepositoryChanged?.Invoke();
}
public bool RerollRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
int cost = RerollCost;
if (bits < cost)
{
return false;
}
bits -= cost;
rerollsThisVisit++;
repositoryOffers.Clear();
FillRepositoryOffers(cardDatabase, languageDeckDatabase);
repositoryVisitActive = true;
RepositoryChanged?.Invoke();
return true;
}
public bool BuyOffer(RepositoryOffer offer, CombatantState playerState)
{
if (offer == null || offer.Sold || bits < offer.Price)
{
return false;
}
bool applied = offer.Kind switch
{
RepositoryOfferKind.NewCard => AddCardToRunDeck(offer.CardDefinition),
RepositoryOfferKind.CardUpgrade => ApplyCardUpgrade(offer.TargetCard, offer.CardUpgradeKind),
RepositoryOfferKind.StatUpgrade => ApplyStatUpgrade(offer.StatUpgradeKind, playerState),
_ => false
};
if (!applied)
{
return false;
}
bits -= offer.Price;
offer.MarkSold();
soldOfferKeys.Add(OfferKey(offer));
RepositoryChanged?.Invoke();
return true;
}
public bool RemoveCard(CardInstance card)
{
if (card == null || bits < CombatTuning.RemoveCardCost || runDeck.Count <= 1)
{
return false;
}
if (!runDeck.Remove(card))
{
return false;
}
bits -= CombatTuning.RemoveCardCost;
RepositoryChanged?.Invoke();
return true;
}
public int EffectiveMaxUptime()
{
return Mathf.Max(1, (CurrentConfig?.Distro?.BaseUptime ?? 1) + maxUptimeBonus);
}
public int EffectiveRam()
{
return Mathf.Max(1, (CurrentConfig?.Distro?.BaseRam ?? 1) + ramBonus);
}
public int EffectiveMaxCycles()
{
return Mathf.Max(1, (CurrentConfig?.Distro?.BaseCyclesPerTurn ?? 1) + maxCyclesBonus);
}
private void HandleWaveCleared(WaveClearedEvent payload)
{
currentWaveNumber = Mathf.Max(currentWaveNumber + 1, payload.WaveNumber + 1);
repositoryOffers.Clear();
rerollsThisVisit = 0;
soldOfferKeys.Clear();
repositoryVisitActive = false;
GameEvents.RaiseWaveAdvanced(new WaveAdvancedEvent(currentWaveNumber));
}
private void HandleRunEnded(RunEndedEvent payload)
{
isRunActive = false;
isPlayerDead = payload.PlayerDied;
bits = 0;
repositoryOffers.Clear();
repositoryVisitActive = false;
runDeck.Clear();
maxUptimeBonus = 0;
maxCyclesBonus = 0;
ramBonus = 0;
RepositoryChanged?.Invoke();
}
private void InitializeRunDeck(RunConfig config)
{
runDeck.Clear();
if (config?.StartingDeck == null)
{
return;
}
for (int i = 0; i < config.StartingDeck.Count; i++)
{
CardDefinition definition = config.StartingDeck[i];
if (definition != null && !definition.IsRunOnly)
{
runDeck.Add(new CardInstance(definition));
}
}
}
private void FillRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
int guard = 0;
while (repositoryOffers.Count < CombatTuning.ShopSize && guard < 80)
{
guard++;
RepositoryOffer offer = CreateRandomOffer(cardDatabase, languageDeckDatabase);
if (offer == null || soldOfferKeys.Contains(OfferKey(offer)) || repositoryOffers.Any(existing => IsDuplicateOffer(existing, offer)))
{
continue;
}
repositoryOffers.Add(offer);
}
}
private RepositoryOffer CreateRandomOffer(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
int roll = UnityEngine.Random.Range(0, 100);
if (roll < 45)
{
return CreateCardOffer(cardDatabase, languageDeckDatabase);
}
if (roll < 75)
{
return CreateUpgradeOffer();
}
return CreateStatOffer();
}
private RepositoryOffer CreateCardOffer(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
List<CardDefinition> pool = BuildCardOfferPool(cardDatabase, languageDeckDatabase);
if (pool.Count == 0)
{
return null;
}
CardDefinition selected = pool[UnityEngine.Random.Range(0, pool.Count)];
return RepositoryOffer.NewCard(selected, selected.IsRunOnly ? CombatTuning.NewCardOfferCost + 1 : CombatTuning.NewCardOfferCost);
}
private RepositoryOffer CreateUpgradeOffer()
{
List<CardInstance> candidates = runDeck.Where(card => card != null && card.CanReceiveRepositoryUpgrade).ToList();
if (candidates.Count == 0)
{
return CreateStatOffer();
}
CardInstance target = candidates[UnityEngine.Random.Range(0, candidates.Count)];
CardUpgradeKind kind = (CardUpgradeKind)UnityEngine.Random.Range(0, 3);
if (kind == CardUpgradeKind.CostDown && CombatManager.GetCardCost(target) <= 0)
{
kind = CardUpgradeKind.MagnitudeUp;
}
return RepositoryOffer.CardUpgrade(target, kind, CombatTuning.UpgradeOfferCost);
}
private static RepositoryOffer CreateStatOffer()
{
RunStatUpgradeKind kind = (RunStatUpgradeKind)UnityEngine.Random.Range(0, 4);
return RepositoryOffer.StatUpgrade(kind, CombatTuning.StatUpgradeCost);
}
private List<CardDefinition> BuildCardOfferPool(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase)
{
List<CardDefinition> pool = new();
AddLanguageCards(languageDeckDatabase, CurrentConfig?.PrimaryLanguage ?? Language.Python, pool, 3);
AddLanguageCards(languageDeckDatabase, CurrentConfig?.SecondaryLanguage ?? Language.JavaScript, pool, 3);
if (cardDatabase != null)
{
for (int i = 0; i < cardDatabase.AllCards.Count; i++)
{
CardDefinition card = cardDatabase.AllCards[i];
if (card == null || card.IsToken)
{
continue;
}
if (card.IsRunOnly || card.Language == CurrentConfig.PrimaryLanguage || card.Language == CurrentConfig.SecondaryLanguage)
{
pool.Add(card);
}
}
}
return pool;
}
private static void AddLanguageCards(LanguageDeckDatabase languageDeckDatabase, Language language, List<CardDefinition> pool, int weight)
{
LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language);
if (deck == null)
{
return;
}
for (int i = 0; i < deck.Entries.Count; i++)
{
CardDefinition card = deck.Entries[i].Card;
if (card == null || card.IsToken || card.IsRunOnly)
{
continue;
}
for (int copy = 0; copy < Mathf.Max(1, weight); copy++)
{
pool.Add(card);
}
}
}
private static bool IsDuplicateOffer(RepositoryOffer left, RepositoryOffer right)
{
if (left.Kind != right.Kind)
{
return false;
}
return left.Kind switch
{
RepositoryOfferKind.NewCard => left.CardDefinition == right.CardDefinition,
RepositoryOfferKind.CardUpgrade => left.TargetCard == right.TargetCard && left.CardUpgradeKind == right.CardUpgradeKind,
RepositoryOfferKind.StatUpgrade => left.StatUpgradeKind == right.StatUpgradeKind,
_ => false
};
}
private static string OfferKey(RepositoryOffer offer)
{
if (offer == null)
{
return string.Empty;
}
return offer.Kind switch
{
RepositoryOfferKind.NewCard => $"card:{offer.CardDefinition?.Id}",
RepositoryOfferKind.CardUpgrade => $"upgrade:{offer.TargetCard?.Definition?.Id}:{offer.TargetCard?.GetHashCode()}:{offer.CardUpgradeKind}",
RepositoryOfferKind.StatUpgrade => $"stat:{offer.StatUpgradeKind}",
_ => offer.DisplayName
};
}
private bool AddCardToRunDeck(CardDefinition card)
{
if (card == null)
{
return false;
}
runDeck.Add(new CardInstance(card));
return true;
}
private static bool ApplyCardUpgrade(CardInstance card, CardUpgradeKind kind)
{
if (card == null)
{
return false;
}
return kind switch
{
CardUpgradeKind.CostDown => card.ApplyCostUpgrade(-1),
CardUpgradeKind.MagnitudeUp => card.ApplyMagnitudeUpgrade(CombatTuning.UpgradeMagnitudeBonus),
CardUpgradeKind.DrawRider => card.ApplyDrawRider(1),
_ => false
};
}
private bool ApplyStatUpgrade(RunStatUpgradeKind kind, CombatantState playerState)
{
switch (kind)
{
case RunStatUpgradeKind.MaxCycles:
maxCyclesBonus += CombatTuning.StatUpgradeMaxCycles;
if (playerState != null)
{
playerState.MaxCycles += CombatTuning.StatUpgradeMaxCycles;
playerState.Cycles += CombatTuning.StatUpgradeMaxCycles;
}
return true;
case RunStatUpgradeKind.MaxUptime:
maxUptimeBonus += CombatTuning.StatUpgradeMaxUptime;
if (playerState != null)
{
playerState.MaxUptime += CombatTuning.StatUpgradeMaxUptime;
}
return true;
case RunStatUpgradeKind.Heal:
if (playerState == null)
{
return false;
}
playerState.CurrentUptime = Mathf.Min(playerState.MaxUptime, playerState.CurrentUptime + CombatTuning.StatUpgradeHeal);
return true;
case RunStatUpgradeKind.Ram:
ramBonus += CombatTuning.StatUpgradeRam;
if (playerState != null)
{
playerState.Ram += CombatTuning.StatUpgradeRam;
}
return true;
default:
return false;
}
}
}
}
+31 -31
View File
@@ -18,40 +18,40 @@ namespace KernelPanic.UI
private const float MonospaceTolerance = 0.02f;
private static readonly Dictionary<FontAsset, float> AdvanceRatioByFont = new();
private static readonly HashSet<FontAsset> WarnedFonts = new();
private static bool hasDefaultAdvanceRatio;
private static float defaultAdvanceRatio;
private static bool _hasDefaultAdvanceRatio;
private static float _defaultAdvanceRatio;
private readonly Label label;
private readonly FontAsset font;
private string art;
private readonly Label _label;
private readonly FontAsset _font;
private string _art;
public AsciiArtFitter(Label label, FontAsset font)
{
this.label = label;
this.font = font;
this._label = label;
this._font = font;
label.RegisterCallback<GeometryChangedEvent>(_ => Fit());
}
public void SetArt(string value)
{
art = value;
_art = value;
Fit();
}
private void Fit()
{
if (string.IsNullOrWhiteSpace(art))
if (string.IsNullOrWhiteSpace(_art))
{
return;
}
Rect region = label.layout;
Rect region = _label.layout;
if (!IsUsable(region.width) || !IsUsable(region.height))
{
return;
}
GetArtDimensions(art, out int rows, out int columns);
GetArtDimensions(_art, out int rows, out int columns);
if (rows <= 0 || columns <= 0)
{
return;
@@ -64,49 +64,49 @@ namespace KernelPanic.UI
float blockWidth = columns * fontSize * advanceRatio;
float blockHeight = rows * fontSize * LineHeightRatio;
label.style.fontSize = fontSize;
label.style.paddingLeft = Mathf.Max(0f, (region.width - blockWidth) * 0.5f);
label.style.paddingTop = Mathf.Max(0f, (region.height - blockHeight) * 0.5f);
label.style.paddingRight = 0f;
label.style.paddingBottom = 0f;
_label.style.fontSize = fontSize;
_label.style.paddingLeft = Mathf.Max(0f, (region.width - blockWidth) * 0.5f);
_label.style.paddingTop = Mathf.Max(0f, (region.height - blockHeight) * 0.5f);
_label.style.paddingRight = 0f;
_label.style.paddingBottom = 0f;
}
private float GetAdvanceRatio()
{
if (font == null && hasDefaultAdvanceRatio)
if (_font == null && _hasDefaultAdvanceRatio)
{
return defaultAdvanceRatio;
return _defaultAdvanceRatio;
}
if (font != null && AdvanceRatioByFont.TryGetValue(font, out float cached))
if (_font != null && AdvanceRatioByFont.TryGetValue(_font, out float cached))
{
return cached;
}
float previousFontSize = label.resolvedStyle.fontSize;
label.style.fontSize = ReferenceFontSize;
float previousFontSize = _label.resolvedStyle.fontSize;
_label.style.fontSize = ReferenceFontSize;
float mWidth = label.MeasureTextSize("MMMMMMMMMM", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x;
float iWidth = label.MeasureTextSize("iiiiiiiiii", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x;
label.style.fontSize = previousFontSize;
float mWidth = _label.MeasureTextSize("MMMMMMMMMM", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x;
float iWidth = _label.MeasureTextSize("iiiiiiiiii", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x;
_label.style.fontSize = previousFontSize;
float advanceRatio = mWidth > 0f ? mWidth / (10f * ReferenceFontSize) : 0.6f;
if (font == null)
if (_font == null)
{
defaultAdvanceRatio = advanceRatio;
hasDefaultAdvanceRatio = true;
_defaultAdvanceRatio = advanceRatio;
_hasDefaultAdvanceRatio = true;
}
else
{
AdvanceRatioByFont[font] = advanceRatio;
AdvanceRatioByFont[_font] = advanceRatio;
}
if (font != null && mWidth > 0f && iWidth > 0f)
if (_font != null && mWidth > 0f && iWidth > 0f)
{
float difference = Mathf.Abs(mWidth - iWidth) / Mathf.Max(mWidth, iWidth);
if (difference > MonospaceTolerance && WarnedFonts.Add(font))
if (difference > MonospaceTolerance && WarnedFonts.Add(_font))
{
Debug.LogWarning($"ASCII art font '{font.name}' is not monospace; art may misalign.");
Debug.LogWarning($"ASCII art font '{_font.name}' is not monospace; art may misalign.");
}
}
+36 -36
View File
@@ -14,31 +14,31 @@ namespace KernelPanic.UI
private const int ExtraLines = 2;
private const int TickMilliseconds = 450;
private readonly VisualElement layer;
private readonly IReadOnlyList<string> sourceLines;
private readonly List<Label> labels = new();
private IVisualElementScheduledItem tickSchedule;
private float lastRegionHeight;
private int cursor;
private bool reducedMotion;
private bool started;
private readonly VisualElement _layer;
private readonly IReadOnlyList<string> _sourceLines;
private readonly List<Label> _labels = new();
private IVisualElementScheduledItem _tickSchedule;
private float _lastRegionHeight;
private int _cursor;
private bool _reducedMotion;
private bool _started;
public BackgroundLogRingBuffer(VisualElement layer, IReadOnlyList<string> sourceLines)
{
this.layer = layer;
this.sourceLines = sourceLines ?? Array.Empty<string>();
this.layer.RegisterCallback<GeometryChangedEvent>(HandleGeometryChanged);
this._layer = layer;
this._sourceLines = sourceLines ?? Array.Empty<string>();
this._layer.RegisterCallback<GeometryChangedEvent>(HandleGeometryChanged);
}
public void Start(bool useReducedMotion)
{
reducedMotion = useReducedMotion;
started = true;
cursor = 0;
_reducedMotion = useReducedMotion;
_started = true;
_cursor = 0;
if (lastRegionHeight > 0f)
if (_lastRegionHeight > 0f)
{
RebuildLabelsForHeight(lastRegionHeight);
RebuildLabelsForHeight(_lastRegionHeight);
Render();
}
@@ -47,9 +47,9 @@ namespace KernelPanic.UI
public void Stop()
{
started = false;
tickSchedule?.Pause();
tickSchedule = null;
_started = false;
_tickSchedule?.Pause();
_tickSchedule = null;
}
private void HandleGeometryChanged(GeometryChangedEvent evt)
@@ -59,8 +59,8 @@ namespace KernelPanic.UI
return;
}
lastRegionHeight = evt.newRect.height;
if (!started)
_lastRegionHeight = evt.newRect.height;
if (!_started)
{
return;
}
@@ -72,52 +72,52 @@ namespace KernelPanic.UI
private void RebuildLabelsForHeight(float regionHeight)
{
int targetCount = Mathf.Max(ExtraLines, Mathf.CeilToInt(regionHeight / LineHeight) + ExtraLines);
if (labels.Count == targetCount)
if (_labels.Count == targetCount)
{
return;
}
layer.Clear();
labels.Clear();
_layer.Clear();
_labels.Clear();
for (int i = 0; i < targetCount; i++)
{
Label label = new() { pickingMode = PickingMode.Ignore };
label.AddToClassList("background-log-line");
layer.Add(label);
labels.Add(label);
_layer.Add(label);
_labels.Add(label);
}
}
private void RestartTick()
{
tickSchedule?.Pause();
tickSchedule = null;
_tickSchedule?.Pause();
_tickSchedule = null;
if (reducedMotion || sourceLines.Count == 0)
if (_reducedMotion || _sourceLines.Count == 0)
{
return;
}
tickSchedule = layer.schedule.Execute(Advance).Every(TickMilliseconds);
_tickSchedule = _layer.schedule.Execute(Advance).Every(TickMilliseconds);
}
private void Advance()
{
if (sourceLines.Count == 0)
if (_sourceLines.Count == 0)
{
return;
}
cursor = (cursor + 1) % sourceLines.Count;
_cursor = (_cursor + 1) % _sourceLines.Count;
Render();
}
private void Render()
{
if (sourceLines.Count == 0)
if (_sourceLines.Count == 0)
{
foreach (Label label in labels)
foreach (Label label in _labels)
{
label.text = string.Empty;
}
@@ -125,9 +125,9 @@ namespace KernelPanic.UI
return;
}
for (int i = 0; i < labels.Count; i++)
for (int i = 0; i < _labels.Count; i++)
{
labels[i].text = sourceLines[(cursor + i) % sourceLines.Count];
_labels[i].text = _sourceLines[(_cursor + i) % _sourceLines.Count];
}
}
}
+121 -121
View File
@@ -15,50 +15,50 @@ namespace KernelPanic.UI
[Serializable]
public sealed class CollectionScreenController
{
private readonly List<VisualElement> rows = new();
private readonly List<VisualElement> _rows = new();
private VisualElement list;
private VisualElement detail;
private FontAsset monospaceFont;
private LanguageDeckDatabase languageDeckDatabase;
private CardDatabase cardDatabase;
private PlayerCollection playerCollection;
private IReadOnlyList<DistroDefinition> units = Array.Empty<DistroDefinition>();
private IReadOnlyList<LanguageCatalogEntry> languages = LanguageCatalog.All;
private IReadOnlyList<CardEntry> cards = Array.Empty<CardEntry>();
private CollectionMode mode;
private CollectionMode returnMode;
private int selectedUnitIndex;
private int selectedLanguageIndex;
private int selectedCardIndex;
private string emptyCardMessage = "no cards installed";
private VisualElement _list;
private VisualElement _detail;
private FontAsset _monospaceFont;
private LanguageDeckDatabase _languageDeckDatabase;
private CardDatabase _cardDatabase;
private PlayerCollection _playerCollection;
private IReadOnlyList<DistroDefinition> _units = Array.Empty<DistroDefinition>();
private IReadOnlyList<LanguageCatalogEntry> _languages = LanguageCatalog.All;
private IReadOnlyList<CardEntry> _cards = Array.Empty<CardEntry>();
private CollectionMode _mode;
private CollectionMode _returnMode;
private int _selectedUnitIndex;
private int _selectedLanguageIndex;
private int _selectedCardIndex;
private string _emptyCardMessage = "no cards installed";
public event Action ViewChanged;
public void Bind(VisualElement root, FontAsset artFont, LanguageDeckDatabase deckDatabase, CardDatabase cardsDatabase, PlayerCollection collection)
{
monospaceFont = artFont;
languageDeckDatabase = deckDatabase;
cardDatabase = cardsDatabase;
playerCollection = collection;
list = root.Q<VisualElement>("CollectionList");
detail = root.Q<VisualElement>("CollectionDetail");
_monospaceFont = artFont;
_languageDeckDatabase = deckDatabase;
_cardDatabase = cardsDatabase;
_playerCollection = collection;
_list = root.Q<VisualElement>("CollectionList");
_detail = root.Q<VisualElement>("CollectionDetail");
}
public bool IsCardSubview => mode == CollectionMode.CardSubview;
public bool IsCardSubview => _mode == CollectionMode.CardSubview;
public string CurrentTitle { get; private set; } = "$ ls ~/collection";
public string CurrentHint { get; private set; } = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select";
public void RefreshUnits(IReadOnlyList<DistroDefinition> ownedUnits)
{
units = ownedUnits ?? Array.Empty<DistroDefinition>();
_units = ownedUnits ?? Array.Empty<DistroDefinition>();
ShowUnits();
}
public void ShowUnits()
{
mode = CollectionMode.Units;
_mode = CollectionMode.Units;
CurrentTitle = "$ ls ~/collection";
CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select";
RenderUnits();
@@ -67,7 +67,7 @@ namespace KernelPanic.UI
public void ShowLanguages()
{
mode = CollectionMode.Languages;
_mode = CollectionMode.Languages;
CurrentTitle = "$ ls ~/collection/languages";
CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select";
RenderLanguages();
@@ -81,7 +81,7 @@ namespace KernelPanic.UI
return false;
}
if (returnMode == CollectionMode.Languages)
if (_returnMode == CollectionMode.Languages)
{
ShowLanguages();
}
@@ -95,33 +95,33 @@ namespace KernelPanic.UI
public void SelectRelative(int delta)
{
if (mode == CollectionMode.Units)
if (_mode == CollectionMode.Units)
{
SelectUnit(selectedUnitIndex + delta);
SelectUnit(_selectedUnitIndex + delta);
return;
}
if (mode == CollectionMode.Languages)
if (_mode == CollectionMode.Languages)
{
SelectLanguage(selectedLanguageIndex + delta);
SelectLanguage(_selectedLanguageIndex + delta);
return;
}
SelectCard(selectedCardIndex + delta);
SelectCard(_selectedCardIndex + delta);
}
public void ActivateSelected()
{
if (mode == CollectionMode.Units && units.Count > 0)
if (_mode == CollectionMode.Units && _units.Count > 0)
{
OpenUnitCards(units[selectedUnitIndex]);
OpenUnitCards(_units[_selectedUnitIndex]);
return;
}
if (mode == CollectionMode.Languages && languages.Count > 0)
if (_mode == CollectionMode.Languages && _languages.Count > 0)
{
LanguageCatalogEntry language = languages[selectedLanguageIndex];
if (LanguageUnlock.IsUnlocked(language.Language, playerCollection))
LanguageCatalogEntry language = _languages[_selectedLanguageIndex];
if (LanguageUnlock.IsUnlocked(language.Language, _playerCollection))
{
OpenLanguageDeck(language);
}
@@ -130,27 +130,27 @@ namespace KernelPanic.UI
private void RenderUnits()
{
if (list == null || detail == null)
if (_list == null || _detail == null)
{
return;
}
rows.Clear();
list.Clear();
_rows.Clear();
_list.Clear();
if (units.Count == 0)
if (_units.Count == 0)
{
detail.Clear();
detail.Add(new Label("no units installed") { name = "CollectionEmptyTitle" });
detail.Add(new Label("run: curl gacha.sh | sh") { name = "CollectionEmptyHint" });
_detail.Clear();
_detail.Add(new Label("no units installed") { name = "CollectionEmptyTitle" });
_detail.Add(new Label("run: curl gacha.sh | sh") { name = "CollectionEmptyHint" });
return;
}
selectedUnitIndex = Mathf.Clamp(selectedUnitIndex, 0, units.Count - 1);
for (int i = 0; i < units.Count; i++)
_selectedUnitIndex = Mathf.Clamp(_selectedUnitIndex, 0, _units.Count - 1);
for (int i = 0; i < _units.Count; i++)
{
int index = i;
DistroDefinition unit = units[i];
DistroDefinition unit = _units[i];
VisualElement row = new();
row.AddToClassList("collection-row");
row.RegisterCallback<PointerEnterEvent>(_ => SelectUnit(index));
@@ -165,29 +165,29 @@ namespace KernelPanic.UI
row.Add(name);
row.Add(unitLanguages);
list.Add(row);
rows.Add(row);
_list.Add(row);
_rows.Add(row);
}
SelectUnit(selectedUnitIndex);
SelectUnit(_selectedUnitIndex);
}
private void RenderLanguages()
{
if (list == null || detail == null)
if (_list == null || _detail == null)
{
return;
}
rows.Clear();
list.Clear();
selectedLanguageIndex = Mathf.Clamp(selectedLanguageIndex, 0, languages.Count - 1);
_rows.Clear();
_list.Clear();
_selectedLanguageIndex = Mathf.Clamp(_selectedLanguageIndex, 0, _languages.Count - 1);
for (int i = 0; i < languages.Count; i++)
for (int i = 0; i < _languages.Count; i++)
{
int index = i;
LanguageCatalogEntry language = languages[i];
bool unlocked = LanguageUnlock.IsUnlocked(language.Language, playerCollection);
LanguageCatalogEntry language = _languages[i];
bool unlocked = LanguageUnlock.IsUnlocked(language.Language, _playerCollection);
VisualElement row = new();
row.AddToClassList("collection-row");
row.EnableInClassList("locked", !unlocked);
@@ -202,72 +202,72 @@ namespace KernelPanic.UI
row.Add(name);
row.Add(tag);
list.Add(row);
rows.Add(row);
_list.Add(row);
_rows.Add(row);
}
SelectLanguage(selectedLanguageIndex);
SelectLanguage(_selectedLanguageIndex);
}
private void SelectUnit(int index)
{
if (units.Count == 0)
if (_units.Count == 0)
{
return;
}
selectedUnitIndex = Mathf.Clamp(index, 0, units.Count - 1);
ApplySelection(selectedUnitIndex);
RenderUnitDetail(units[selectedUnitIndex]);
_selectedUnitIndex = Mathf.Clamp(index, 0, _units.Count - 1);
ApplySelection(_selectedUnitIndex);
RenderUnitDetail(_units[_selectedUnitIndex]);
}
private void SelectLanguage(int index)
{
selectedLanguageIndex = Mathf.Clamp(index, 0, languages.Count - 1);
ApplySelection(selectedLanguageIndex);
RenderLanguageDetail(languages[selectedLanguageIndex]);
_selectedLanguageIndex = Mathf.Clamp(index, 0, _languages.Count - 1);
ApplySelection(_selectedLanguageIndex);
RenderLanguageDetail(_languages[_selectedLanguageIndex]);
}
private void SelectCard(int index)
{
if (cards.Count == 0)
if (_cards.Count == 0)
{
return;
}
selectedCardIndex = Mathf.Clamp(index, 0, cards.Count - 1);
ApplySelection(selectedCardIndex);
RenderCardDetail(cards[selectedCardIndex]);
_selectedCardIndex = Mathf.Clamp(index, 0, _cards.Count - 1);
ApplySelection(_selectedCardIndex);
RenderCardDetail(_cards[_selectedCardIndex]);
}
private void ApplySelection(int selectedIndex)
{
for (int i = 0; i < rows.Count; i++)
for (int i = 0; i < _rows.Count; i++)
{
rows[i].EnableInClassList("selected", i == selectedIndex);
_rows[i].EnableInClassList("selected", i == selectedIndex);
}
}
private void RenderUnitDetail(DistroDefinition unit)
{
detail.Clear();
_detail.Clear();
Label name = new(DistroPresentation.DisplayName(unit));
name.AddToClassList("collection-detail-name");
name.style.color = new StyleColor(unit.AccentColor);
detail.Add(name);
_detail.Add(name);
Label description = new(string.IsNullOrWhiteSpace(unit.Description) ? "--" : unit.Description);
description.AddToClassList("collection-detail-description");
detail.Add(description);
AddPassiveDetails(detail, unit);
_detail.Add(description);
AddPassiveDetails(_detail, unit);
VisualElement readout = new();
readout.AddToClassList("collection-detail-readout");
Label artLabel = new();
DistroArtPresenter.ConfigureArtLabel(artLabel, monospaceFont);
AsciiArtFitter artFitter = new(artLabel, monospaceFont);
DistroArtPresenter.ConfigureArtLabel(artLabel, _monospaceFont);
AsciiArtFitter artFitter = new(artLabel, _monospaceFont);
VisualElement artPlaceholder = DistroArtPresenter.CreatePlaceholder();
artFitter.SetArt(DistroArtPresenter.Render(artLabel, artPlaceholder, unit));
readout.Add(artPlaceholder);
@@ -281,23 +281,23 @@ namespace KernelPanic.UI
details.Add(BuildDetailLine("cycles", unit.BaseCyclesPerTurn.ToString()));
readout.Add(details);
detail.Add(readout);
detail.Add(BuildSubviewCommand($"> cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed"));
_detail.Add(readout);
_detail.Add(BuildSubviewCommand($"> cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed"));
}
private void RenderLanguageDetail(LanguageCatalogEntry language)
{
detail.Clear();
bool unlocked = LanguageUnlock.IsUnlocked(language.Language, playerCollection);
_detail.Clear();
bool unlocked = LanguageUnlock.IsUnlocked(language.Language, _playerCollection);
Label name = new(language.DisplayName);
name.AddToClassList("collection-detail-name");
detail.Add(name);
detail.Add(BuildDetailLine("track", language.ResolutionTrack.ToString()));
_detail.Add(name);
_detail.Add(BuildDetailLine("track", language.ResolutionTrack.ToString()));
Label how = new(language.HowItWorks);
how.AddToClassList("collection-detail-description");
detail.Add(how);
_detail.Add(how);
if (!unlocked)
{
@@ -306,11 +306,11 @@ namespace KernelPanic.UI
: language.UnlockHint;
Label hint = new(hintText);
hint.AddToClassList("package-notice");
detail.Add(hint);
_detail.Add(hint);
return;
}
detail.Add(BuildSubviewCommand($"> cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null));
_detail.Add(BuildSubviewCommand($"> cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null));
}
private void OpenUnitCards(DistroDefinition unit)
@@ -320,9 +320,9 @@ namespace KernelPanic.UI
return;
}
returnMode = CollectionMode.Units;
cards = BuildUnitCardEntries(unit);
emptyCardMessage = "no packages installed";
_returnMode = CollectionMode.Units;
_cards = BuildUnitCardEntries(unit);
_emptyCardMessage = "no packages installed";
CurrentTitle = $"$ cat ~/units/{unit.Id}/cards";
CurrentHint = "[esc] back [arrows] navigate";
RenderCardSubview();
@@ -331,9 +331,9 @@ namespace KernelPanic.UI
private void OpenLanguageDeck(LanguageCatalogEntry language)
{
returnMode = CollectionMode.Languages;
cards = BuildStarterDeckEntries(language);
emptyCardMessage = "starter deck not yet available";
_returnMode = CollectionMode.Languages;
_cards = BuildStarterDeckEntries(language);
_emptyCardMessage = "starter deck not yet available";
CurrentTitle = $"$ cat ~/lang/{GetLanguageId(language.Language)}/deck";
CurrentHint = "[esc] back [arrows] navigate";
RenderCardSubview();
@@ -342,31 +342,31 @@ namespace KernelPanic.UI
private void RenderCardSubview()
{
mode = CollectionMode.CardSubview;
rows.Clear();
list.Clear();
detail.Clear();
selectedCardIndex = Mathf.Clamp(selectedCardIndex, 0, Math.Max(0, cards.Count - 1));
_mode = CollectionMode.CardSubview;
_rows.Clear();
_list.Clear();
_detail.Clear();
_selectedCardIndex = Mathf.Clamp(_selectedCardIndex, 0, Math.Max(0, _cards.Count - 1));
if (cards.Count == 0)
if (_cards.Count == 0)
{
detail.Add(new Label(emptyCardMessage) { name = "CollectionEmptyTitle" });
detail.Add(new Label("check back after deck data is installed") { name = "CollectionEmptyHint" });
_detail.Add(new Label(_emptyCardMessage) { name = "CollectionEmptyTitle" });
_detail.Add(new Label("check back after deck data is installed") { name = "CollectionEmptyHint" });
return;
}
for (int i = 0; i < cards.Count; i++)
for (int i = 0; i < _cards.Count; i++)
{
int index = i;
CardEntry entry = cards[i];
CardEntry entry = _cards[i];
VisualElement row = BuildCardRow(entry);
row.RegisterCallback<PointerEnterEvent>(_ => SelectCard(index));
row.RegisterCallback<ClickEvent>(_ => SelectCard(index));
list.Add(row);
rows.Add(row);
_list.Add(row);
_rows.Add(row);
}
SelectCard(selectedCardIndex);
SelectCard(_selectedCardIndex);
}
private VisualElement BuildCardRow(CardEntry entry)
@@ -398,28 +398,28 @@ namespace KernelPanic.UI
private void RenderCardDetail(CardEntry entry)
{
detail.Clear();
_detail.Clear();
CardDefinition card = entry.Card;
Label name = new(GetCardDisplayName(card));
name.AddToClassList("collection-detail-name");
detail.Add(name);
_detail.Add(name);
Label description = new(card == null || string.IsNullOrWhiteSpace(card.Description) ? "--" : card.Description);
description.AddToClassList("collection-detail-description");
detail.Add(description);
_detail.Add(description);
if (card != null && !string.IsNullOrWhiteSpace(card.FlavorText))
{
Label flavor = new(card.FlavorText);
flavor.AddToClassList("flavor-text");
detail.Add(flavor);
_detail.Add(flavor);
}
detail.Add(BuildDetailLine("lang", card == null ? "--" : card.Language.ToString()));
detail.Add(BuildDetailLine("rarity", card == null ? "--" : card.Rarity.ToString()));
detail.Add(BuildDetailLine("cost", card == null ? "--" : card.CycleCost.ToString()));
detail.Add(BuildDetailLine("track", card == null ? "--" : card.ResolutionTrack.ToString()));
_detail.Add(BuildDetailLine("lang", card == null ? "--" : card.Language.ToString()));
_detail.Add(BuildDetailLine("rarity", card == null ? "--" : card.Rarity.ToString()));
_detail.Add(BuildDetailLine("cost", card == null ? "--" : card.CycleCost.ToString()));
_detail.Add(BuildDetailLine("track", card == null ? "--" : card.ResolutionTrack.ToString()));
}
private static VisualElement BuildDetailLine(string key, string value)
@@ -497,7 +497,7 @@ namespace KernelPanic.UI
for (int cardIndex = 0; cardIndex < unit.ExclusiveCards.Count; cardIndex++)
{
CardDefinition card = unit.ExclusiveCards[cardIndex];
if (card == null || card.IsToken)
if (card == null || card.IsToken || card.IsRunOnly)
{
continue;
}
@@ -510,7 +510,7 @@ namespace KernelPanic.UI
private IReadOnlyList<CardEntry> BuildStarterDeckEntries(LanguageCatalogEntry language)
{
LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language.Language);
LanguageDeckDefinition deck = _languageDeckDatabase == null ? null : _languageDeckDatabase.FindByLanguage(language.Language);
if (deck == null)
{
return BuildRegisteredStarterDeckEntries(language.Language);
@@ -520,7 +520,7 @@ namespace KernelPanic.UI
for (int i = 0; i < deck.Entries.Count; i++)
{
LanguageDeckDefinition.LanguageDeckEntry deckEntry = deck.Entries[i];
if (deckEntry.Card == null || deckEntry.Count <= 0)
if (deckEntry.Card == null || deckEntry.Card.IsRunOnly || deckEntry.Count <= 0)
{
continue;
}
@@ -533,7 +533,7 @@ namespace KernelPanic.UI
private IReadOnlyList<CardEntry> BuildRegisteredStarterDeckEntries(Language language)
{
if (cardDatabase == null)
if (_cardDatabase == null)
{
return Array.Empty<CardEntry>();
}
@@ -557,8 +557,8 @@ namespace KernelPanic.UI
List<CardEntry> entries = new();
for (int i = 0; i < cardRefs.Length; i++)
{
CardDefinition card = cardDatabase.FindById(cardRefs[i].Id);
if (card == null)
CardDefinition card = _cardDatabase.FindById(cardRefs[i].Id);
if (card == null || card.IsRunOnly)
{
continue;
}
File diff suppressed because it is too large Load Diff
+61 -61
View File
@@ -15,43 +15,43 @@ namespace KernelPanic.UI
{
private const string EmptyAccent = "5cff91";
private VisualElement panel;
private Label titleLabel;
private Label asciiLabel;
private VisualElement asciiPlaceholder;
private Label unitNameLabel;
private Label languagesLabel;
private Label passiveLabel;
private Label bestWaveLabel;
private Label emptyTitleLabel;
private Label emptyHintLabel;
private VisualElement populatedState;
private VisualElement emptyState;
private FontAsset monospaceFont;
private AsciiArtFitter asciiFitter;
private IReadOnlyList<DistroDefinition> units = Array.Empty<DistroDefinition>();
private int selectedIndex;
private VisualElement _panel;
private Label _titleLabel;
private Label _asciiLabel;
private VisualElement _asciiPlaceholder;
private Label _unitNameLabel;
private Label _languagesLabel;
private Label _passiveLabel;
private Label _bestWaveLabel;
private Label _emptyTitleLabel;
private Label _emptyHintLabel;
private VisualElement _populatedState;
private VisualElement _emptyState;
private FontAsset _monospaceFont;
private AsciiArtFitter _asciiFitter;
private IReadOnlyList<DistroDefinition> _units = Array.Empty<DistroDefinition>();
private int _selectedIndex;
public void Bind(VisualElement root, FontAsset artFont)
{
monospaceFont = artFont;
panel = root.Q<VisualElement>("FeaturedUnitPanel");
titleLabel = root.Q<Label>("FeaturedUnitTitle");
asciiLabel = root.Q<Label>("FeaturedUnitAscii");
unitNameLabel = root.Q<Label>("FeaturedUnitName");
languagesLabel = root.Q<Label>("FeaturedUnitLanguages");
passiveLabel = root.Q<Label>("FeaturedUnitPassive");
bestWaveLabel = root.Q<Label>("FeaturedUnitBestWave");
emptyTitleLabel = root.Q<Label>("FeaturedUnitEmptyTitle");
emptyHintLabel = root.Q<Label>("FeaturedUnitEmptyHint");
populatedState = root.Q<VisualElement>("FeaturedUnitPopulatedState");
emptyState = root.Q<VisualElement>("FeaturedUnitEmptyState");
_monospaceFont = artFont;
_panel = root.Q<VisualElement>("FeaturedUnitPanel");
_titleLabel = root.Q<Label>("FeaturedUnitTitle");
_asciiLabel = root.Q<Label>("FeaturedUnitAscii");
_unitNameLabel = root.Q<Label>("FeaturedUnitName");
_languagesLabel = root.Q<Label>("FeaturedUnitLanguages");
_passiveLabel = root.Q<Label>("FeaturedUnitPassive");
_bestWaveLabel = root.Q<Label>("FeaturedUnitBestWave");
_emptyTitleLabel = root.Q<Label>("FeaturedUnitEmptyTitle");
_emptyHintLabel = root.Q<Label>("FeaturedUnitEmptyHint");
_populatedState = root.Q<VisualElement>("FeaturedUnitPopulatedState");
_emptyState = root.Q<VisualElement>("FeaturedUnitEmptyState");
DistroArtPresenter.ConfigureArtLabel(asciiLabel, monospaceFont);
asciiFitter = new AsciiArtFitter(asciiLabel, monospaceFont);
asciiPlaceholder = DistroArtPresenter.CreatePlaceholder();
asciiPlaceholder.AddToClassList("hidden");
populatedState.Insert(0, asciiPlaceholder);
DistroArtPresenter.ConfigureArtLabel(_asciiLabel, _monospaceFont);
_asciiFitter = new AsciiArtFitter(_asciiLabel, _monospaceFont);
_asciiPlaceholder = DistroArtPresenter.CreatePlaceholder();
_asciiPlaceholder.AddToClassList("hidden");
_populatedState.Insert(0, _asciiPlaceholder);
}
public void Refresh(IReadOnlyList<DistroDefinition> ownedUnits)
@@ -61,7 +61,7 @@ namespace KernelPanic.UI
public void Refresh(IReadOnlyList<DistroDefinition> ownedUnits, DistroDefinition featuredUnit)
{
units = ownedUnits ?? Array.Empty<DistroDefinition>();
_units = ownedUnits ?? Array.Empty<DistroDefinition>();
Refresh(featuredUnit);
}
@@ -72,25 +72,25 @@ namespace KernelPanic.UI
private void Refresh(DistroDefinition featuredUnit)
{
if (units == null || units.Count == 0)
if (_units == null || _units.Count == 0)
{
ShowEmptyState();
return;
}
selectedIndex = ResolveSelectedIndex(featuredUnit);
ShowUnit(units[selectedIndex]);
_selectedIndex = ResolveSelectedIndex(featuredUnit);
ShowUnit(_units[_selectedIndex]);
}
public void SelectNext()
{
int unitCount = units?.Count ?? 0;
int unitCount = _units?.Count ?? 0;
if (unitCount < 2)
{
return;
}
selectedIndex = (selectedIndex + 1) % unitCount;
_selectedIndex = (_selectedIndex + 1) % unitCount;
Refresh();
}
@@ -98,40 +98,40 @@ namespace KernelPanic.UI
{
if (featuredUnit != null)
{
for (int i = 0; i < units.Count; i++)
for (int i = 0; i < _units.Count; i++)
{
if (units[i] == featuredUnit)
if (_units[i] == featuredUnit)
{
return i;
}
}
}
return Mathf.Clamp(selectedIndex, 0, units.Count - 1);
return Mathf.Clamp(_selectedIndex, 0, _units.Count - 1);
}
private void ShowEmptyState()
{
populatedState.AddToClassList("hidden");
emptyState.RemoveFromClassList("hidden");
_populatedState.AddToClassList("hidden");
_emptyState.RemoveFromClassList("hidden");
titleLabel.text = "[ neofetch ]";
emptyTitleLabel.text = "no units installed";
emptyHintLabel.text = "run: curl gacha.sh | sh";
_titleLabel.text = "[ neofetch ]";
_emptyTitleLabel.text = "no units installed";
_emptyHintLabel.text = "run: curl gacha.sh | sh";
SetAccent(EmptyAccent);
}
private void ShowUnit(DistroDefinition unit)
{
populatedState.RemoveFromClassList("hidden");
emptyState.AddToClassList("hidden");
_populatedState.RemoveFromClassList("hidden");
_emptyState.AddToClassList("hidden");
titleLabel.text = "[ neofetch ]";
asciiFitter.SetArt(DistroArtPresenter.Render(asciiLabel, asciiPlaceholder, unit));
unitNameLabel.text = DistroPresentation.DisplayName(unit);
languagesLabel.text = DistroPresentation.FormatLanguages(unit);
passiveLabel.text = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name) ? "--" : unit.Passive.Name;
bestWaveLabel.text = "--"; // TODO: Bind best-wave stats from SaveService when stats exist.
_titleLabel.text = "[ neofetch ]";
_asciiFitter.SetArt(DistroArtPresenter.Render(_asciiLabel, _asciiPlaceholder, unit));
_unitNameLabel.text = DistroPresentation.DisplayName(unit);
_languagesLabel.text = DistroPresentation.FormatLanguages(unit);
_passiveLabel.text = unit.Passive == null || string.IsNullOrWhiteSpace(unit.Passive.Name) ? "--" : unit.Passive.Name;
_bestWaveLabel.text = "--"; // TODO: Bind best-wave stats from SaveService when stats exist.
SetAccent(ColorUtility.ToHtmlStringRGB(unit.AccentColor));
}
@@ -139,12 +139,12 @@ namespace KernelPanic.UI
{
string color = $"#{htmlColor}";
Color parsedColor = ColorUtility.TryParseHtmlString(color, out Color parsed) ? parsed : Color.green;
panel.style.borderTopColor = new StyleColor(parsedColor);
panel.style.borderRightColor = new StyleColor(parsedColor);
panel.style.borderBottomColor = new StyleColor(parsedColor);
panel.style.borderLeftColor = new StyleColor(parsedColor);
titleLabel.style.color = new StyleColor(parsedColor);
unitNameLabel.style.color = new StyleColor(parsedColor);
_panel.style.borderTopColor = new StyleColor(parsedColor);
_panel.style.borderRightColor = new StyleColor(parsedColor);
_panel.style.borderBottomColor = new StyleColor(parsedColor);
_panel.style.borderLeftColor = new StyleColor(parsedColor);
_titleLabel.style.color = new StyleColor(parsedColor);
_unitNameLabel.style.color = new StyleColor(parsedColor);
}
}
}
+109 -109
View File
@@ -16,49 +16,49 @@ namespace KernelPanic.UI
{
private const string SelectedClassName = "selected";
private readonly List<VisualElement> bannerRows = new();
private readonly string[] bannerIds =
private readonly List<VisualElement> _bannerRows = new();
private readonly string[] _bannerIds =
{
GachaService.BeginnerBannerId,
GachaService.StandardBannerId,
GachaService.LimitedBannerId
};
private VisualElement bannerList;
private VisualElement bannerDetail;
private DistroDatabase distroDatabase;
private FontAsset monospaceFont;
private GachaService gachaService;
private PlayerCollection playerCollection;
private EntropyWallet wallet;
private Action onChanged;
private Action requestRootCreditExchange;
private int selectedBannerIndex;
private int pendingPullCount;
private int pendingMissingTokens;
private bool hasOpened;
private string resultBannerId;
private string resultText;
private VisualElement _bannerList;
private VisualElement _bannerDetail;
private DistroDatabase _distroDatabase;
private FontAsset _monospaceFont;
private GachaService _gachaService;
private PlayerCollection _playerCollection;
private EntropyWallet _wallet;
private Action _onChanged;
private Action _requestRootCreditExchange;
private int _selectedBannerIndex;
private int _pendingPullCount;
private int _pendingMissingTokens;
private bool _hasOpened;
private string _resultBannerId;
private string _resultText;
public void Bind(VisualElement root, DistroDatabase database, FontAsset artFont, GachaService service, PlayerCollection collection, EntropyWallet entropyWallet, Action changedCallback, Action rootCreditExchangeCallback)
{
distroDatabase = database;
monospaceFont = artFont;
gachaService = service;
playerCollection = collection;
wallet = entropyWallet;
onChanged = changedCallback;
requestRootCreditExchange = rootCreditExchangeCallback;
bannerList = root.Q<VisualElement>("GachaBannerList");
bannerDetail = root.Q<VisualElement>("GachaBannerDetail");
_distroDatabase = database;
_monospaceFont = artFont;
_gachaService = service;
_playerCollection = collection;
_wallet = entropyWallet;
_onChanged = changedCallback;
_requestRootCreditExchange = rootCreditExchangeCallback;
_bannerList = root.Q<VisualElement>("GachaBannerList");
_bannerDetail = root.Q<VisualElement>("GachaBannerDetail");
}
public void Open()
{
if (!hasOpened)
if (!_hasOpened)
{
selectedBannerIndex = 0;
hasOpened = true;
_selectedBannerIndex = 0;
_hasOpened = true;
}
Refresh();
@@ -66,24 +66,24 @@ namespace KernelPanic.UI
public void Refresh()
{
if (bannerList == null || bannerDetail == null || gachaService == null)
if (_bannerList == null || _bannerDetail == null || _gachaService == null)
{
return;
}
bannerRows.Clear();
bannerList.Clear();
_bannerRows.Clear();
_bannerList.Clear();
for (int i = 0; i < bannerIds.Length; i++)
for (int i = 0; i < _bannerIds.Length; i++)
{
int index = i;
VisualElement row = BuildBannerRow(bannerIds[i]);
VisualElement row = BuildBannerRow(_bannerIds[i]);
row.RegisterCallback<ClickEvent>(_ => SelectBanner(index));
bannerList.Add(row);
bannerRows.Add(row);
_bannerList.Add(row);
_bannerRows.Add(row);
}
selectedBannerIndex = Mathf.Clamp(selectedBannerIndex, 0, bannerRows.Count - 1);
_selectedBannerIndex = Mathf.Clamp(_selectedBannerIndex, 0, _bannerRows.Count - 1);
ApplySelectedBanner();
}
@@ -111,22 +111,22 @@ namespace KernelPanic.UI
private void SelectBanner(int index)
{
if (bannerRows.Count == 0)
if (_bannerRows.Count == 0)
{
return;
}
selectedBannerIndex = (index + bannerRows.Count) % bannerRows.Count;
_selectedBannerIndex = (index + _bannerRows.Count) % _bannerRows.Count;
ApplySelectedBanner();
}
private void ApplySelectedBanner()
{
for (int i = 0; i < bannerRows.Count; i++)
for (int i = 0; i < _bannerRows.Count; i++)
{
bool selected = i == selectedBannerIndex;
bannerRows[i].EnableInClassList(SelectedClassName, selected);
bannerRows[i].Q<Label>(className: "command-cursor").visible = selected;
bool selected = i == _selectedBannerIndex;
_bannerRows[i].EnableInClassList(SelectedClassName, selected);
_bannerRows[i].Q<Label>(className: "command-cursor").visible = selected;
}
RenderSelectedBanner();
@@ -134,8 +134,8 @@ namespace KernelPanic.UI
private void RenderSelectedBanner()
{
bannerDetail.Clear();
string bannerId = bannerIds[selectedBannerIndex];
_bannerDetail.Clear();
string bannerId = _bannerIds[_selectedBannerIndex];
if (bannerId == GachaService.BeginnerBannerId)
{
RenderBeginnerBanner();
@@ -147,22 +147,22 @@ namespace KernelPanic.UI
private void RenderBeginnerBanner()
{
GachaBannerState state = gachaService.BeginnerState;
bannerDetail.Add(BuildBannerHero("beginner install media"));
GachaBannerState state = _gachaService.BeginnerState;
_bannerDetail.Add(BuildBannerHero("beginner install media"));
Label title = new("beginner install media");
title.AddToClassList("gacha-detail-title");
bannerDetail.Add(title);
_bannerDetail.Add(title);
bannerDetail.Add(BuildInfoLine("entropy", wallet == null ? "--" : wallet.Balance.ToString()));
bannerDetail.Add(BuildInfoLine("pulls", $"{state.totalPulls}/{GachaService.BeginnerMaxPulls}"));
bannerDetail.Add(BuildInfoLine("4-star pity", $"{state.pityCounter}/{GachaService.FourStarHardPity}"));
bannerDetail.Add(BuildInfoLine("ten pull", $"{GachaService.BeginnerTenPullCost} stable-pull-tokens for 10 pulls"));
bannerDetail.Add(BuildInfoLine("guarantees", FormatBeginnerGuarantees()));
_bannerDetail.Add(BuildInfoLine("entropy", _wallet == null ? "--" : _wallet.Balance.ToString()));
_bannerDetail.Add(BuildInfoLine("pulls", $"{state.totalPulls}/{GachaService.BeginnerMaxPulls}"));
_bannerDetail.Add(BuildInfoLine("4-star pity", $"{state.pityCounter}/{GachaService.FourStarHardPity}"));
_bannerDetail.Add(BuildInfoLine("ten pull", $"{GachaService.BeginnerTenPullCost} Commits for 10 pulls"));
_bannerDetail.Add(BuildInfoLine("guarantees", FormatBeginnerGuarantees()));
Label rules = new("Base result is a 3-star equipment cache. A 4-star equipment cache or starter distro can appear early, with one 4-star+ result guaranteed every 10 pulls.");
rules.AddToClassList("gacha-rules");
bannerDetail.Add(rules);
_bannerDetail.Add(rules);
VisualElement actions = new();
actions.AddToClassList("gacha-actions");
@@ -185,62 +185,62 @@ namespace KernelPanic.UI
tenButton.SetEnabled(CanAttemptBeginnerPull(10));
actions.Add(tenButton);
bannerDetail.Add(actions);
_bannerDetail.Add(actions);
if (pendingPullCount > 0)
if (_pendingPullCount > 0)
{
bannerDetail.Add(BuildEntropyPrompt());
_bannerDetail.Add(BuildEntropyPrompt());
}
if (resultBannerId == GachaService.BeginnerBannerId && !string.IsNullOrWhiteSpace(resultText))
if (_resultBannerId == GachaService.BeginnerBannerId && !string.IsNullOrWhiteSpace(_resultText))
{
Label result = new(resultText);
Label result = new(_resultText);
result.AddToClassList("gacha-result");
bannerDetail.Add(result);
_bannerDetail.Add(result);
}
}
private void RenderFutureBanner(string bannerId)
{
bannerDetail.Add(BuildBannerHero(GetBannerTitle(bannerId)));
_bannerDetail.Add(BuildBannerHero(GetBannerTitle(bannerId)));
Label title = new(GetBannerTitle(bannerId));
title.AddToClassList("gacha-detail-title");
bannerDetail.Add(title);
_bannerDetail.Add(title);
string currency = bannerId == GachaService.StandardBannerId
? GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)
: GachaService.FormatCurrencyName(GachaCurrencyType.LimitedPull);
bannerDetail.Add(BuildInfoLine("pull currency", currency));
bannerDetail.Add(BuildInfoLine("status", "not implemented yet"));
bannerDetail.Add(BuildInfoLine("pity", "own counter, 10-pull 4-star+ guarantee"));
bannerDetail.Add(new Label("Use the beginner implementation as the template: define the banner pool, persist a separate GachaBannerState, then route single/ten pulls through GachaService.") { name = "FutureBannerHint" });
bannerDetail.ElementAt(bannerDetail.childCount - 1).AddToClassList("gacha-rules");
_bannerDetail.Add(BuildInfoLine("pull currency", currency));
_bannerDetail.Add(BuildInfoLine("status", "not implemented yet"));
_bannerDetail.Add(BuildInfoLine("pity", "own counter, 10-pull 4-star+ guarantee"));
_bannerDetail.Add(new Label("Use the beginner implementation as the template: define the banner pool, persist a separate GachaBannerState, then route single/ten pulls through GachaService.") { name = "FutureBannerHint" });
_bannerDetail.ElementAt(_bannerDetail.childCount - 1).AddToClassList("gacha-rules");
if (resultBannerId == bannerId && !string.IsNullOrWhiteSpace(resultText))
if (_resultBannerId == bannerId && !string.IsNullOrWhiteSpace(_resultText))
{
Label result = new(resultText);
Label result = new(_resultText);
result.AddToClassList("gacha-result");
bannerDetail.Add(result);
_bannerDetail.Add(result);
}
}
private void PullSelectedBanner(int pullCount, int entropyTokenCount)
{
string bannerId = bannerIds[selectedBannerIndex];
string bannerId = _bannerIds[_selectedBannerIndex];
if (bannerId != GachaService.BeginnerBannerId)
{
resultBannerId = bannerId;
resultText = $"git pull origin {bannerId}: remote not implemented yet";
_resultBannerId = bannerId;
_resultText = $"git pull origin {bannerId}: remote not implemented yet";
RenderSelectedBanner();
return;
}
GachaPullResult result = gachaService.PerformBeginnerPull(pullCount, wallet, entropyTokenCount);
GachaPullResult result = _gachaService.PerformBeginnerPull(pullCount, _wallet, entropyTokenCount);
if (!result.Success)
{
resultBannerId = bannerId;
resultText = $"git pull failed: {result.FailureReason}";
_resultBannerId = bannerId;
_resultText = $"git pull failed: {result.FailureReason}";
RenderSelectedBanner();
return;
}
@@ -255,10 +255,10 @@ namespace KernelPanic.UI
for (int i = 0; i < result.Rewards.Count; i++)
{
GachaReward reward = result.Rewards[i];
bool duplicate = reward.Distro != null && playerCollection.GetOwnedCount(reward.Distro.Id) > 0;
bool duplicate = reward.Distro != null && _playerCollection.GetOwnedCount(reward.Distro.Id) > 0;
if (reward.Distro != null)
{
playerCollection.Add(reward.Distro);
_playerCollection.Add(reward.Distro);
}
string suffix = reward.Guaranteed ? " guaranteed" : reward.PityTriggered ? " pity" : string.Empty;
@@ -266,23 +266,23 @@ namespace KernelPanic.UI
lines.Add($"{i + 1:00}: {reward.StarRating}-star {reward.DisplayName}{suffix}{duplicateText}");
}
resultBannerId = bannerId;
resultText = string.Join("\n", lines);
onChanged?.Invoke();
_resultBannerId = bannerId;
_resultText = string.Join("\n", lines);
_onChanged?.Invoke();
Refresh();
}
private void RequestPullSelectedBanner(int pullCount)
{
string bannerId = bannerIds[selectedBannerIndex];
string bannerId = _bannerIds[_selectedBannerIndex];
if (bannerId != GachaService.BeginnerBannerId)
{
PullSelectedBanner(pullCount, 0);
return;
}
int cost = gachaService.GetBeginnerPullCost(pullCount);
int missingTokens = gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, cost);
int cost = _gachaService.GetBeginnerPullCost(pullCount);
int missingTokens = _gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, cost);
if (missingTokens <= 0)
{
ClearEntropyPrompt();
@@ -290,24 +290,24 @@ namespace KernelPanic.UI
return;
}
if (!gachaService.CanCoverMissingPullTokensWithEntropy(wallet, missingTokens))
if (!_gachaService.CanCoverMissingPullTokensWithEntropy(_wallet, missingTokens))
{
if (gachaService.RootCredits > 0)
if (_gachaService.RootCredits > 0)
{
requestRootCreditExchange?.Invoke();
_requestRootCreditExchange?.Invoke();
return;
}
resultBannerId = bannerId;
resultText = $"git pull failed: need {missingTokens} {GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)} or {missingTokens * GachaService.EntropyPerPullToken} entropy";
_resultBannerId = bannerId;
_resultText = $"git pull failed: need {missingTokens} {GachaService.FormatCurrencyName(GachaCurrencyType.StandardPull)} or {missingTokens * GachaService.EntropyPerPullToken} entropy";
RenderSelectedBanner();
return;
}
pendingPullCount = pullCount;
pendingMissingTokens = missingTokens;
resultBannerId = bannerId;
resultText = null;
_pendingPullCount = pullCount;
_pendingMissingTokens = missingTokens;
_resultBannerId = bannerId;
_resultText = null;
RenderSelectedBanner();
}
@@ -316,8 +316,8 @@ namespace KernelPanic.UI
VisualElement prompt = new();
prompt.AddToClassList("gacha-entropy-prompt");
int entropyCost = pendingMissingTokens * GachaService.EntropyPerPullToken;
Label copy = new($"missing {pendingMissingTokens} stable-pull-token(s). spend {entropyCost} entropy to complete git pull?");
int entropyCost = _pendingMissingTokens * GachaService.EntropyPerPullToken;
Label copy = new($"missing {_pendingMissingTokens} Commits. spend {entropyCost} entropy to complete git pull?");
copy.AddToClassList("gacha-result");
prompt.Add(copy);
@@ -338,8 +338,8 @@ namespace KernelPanic.UI
Button confirm = new(() =>
{
int pullCount = pendingPullCount;
int missingTokens = pendingMissingTokens;
int pullCount = _pendingPullCount;
int missingTokens = _pendingMissingTokens;
ClearEntropyPrompt();
PullSelectedBanner(pullCount, missingTokens);
})
@@ -356,26 +356,26 @@ namespace KernelPanic.UI
private bool CanAttemptBeginnerPull(int pullCount)
{
if (gachaService.CanPullBeginner(pullCount, out _))
if (_gachaService.CanPullBeginner(pullCount, out _))
{
return true;
}
if (!gachaService.IsBeginnerBannerAvailable || pullCount > GachaService.BeginnerMaxPulls - gachaService.BeginnerState.totalPulls)
if (!_gachaService.IsBeginnerBannerAvailable || pullCount > GachaService.BeginnerMaxPulls - _gachaService.BeginnerState.totalPulls)
{
return false;
}
int missingTokens = gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, gachaService.GetBeginnerPullCost(pullCount));
int missingTokens = _gachaService.GetMissingPullTokens(GachaCurrencyType.StandardPull, _gachaService.GetBeginnerPullCost(pullCount));
return missingTokens > 0 &&
(gachaService.CanCoverMissingPullTokensWithEntropy(wallet, missingTokens) ||
gachaService.RootCredits > 0);
(_gachaService.CanCoverMissingPullTokensWithEntropy(_wallet, missingTokens) ||
_gachaService.RootCredits > 0);
}
private void ClearEntropyPrompt()
{
pendingPullCount = 0;
pendingMissingTokens = 0;
_pendingPullCount = 0;
_pendingMissingTokens = 0;
}
private VisualElement BuildBannerHero(string bannerTitle)
@@ -386,7 +386,7 @@ namespace KernelPanic.UI
VisualElement artRow = new();
artRow.AddToClassList("gacha-hero-art-row");
IReadOnlyList<DistroDefinition> distros = distroDatabase == null ? Array.Empty<DistroDefinition>() : distroDatabase.AllDistros;
IReadOnlyList<DistroDefinition> distros = _distroDatabase == null ? Array.Empty<DistroDefinition>() : _distroDatabase.AllDistros;
int artCount = Mathf.Min(3, distros.Count);
for (int i = 0; i < artCount; i++)
{
@@ -417,11 +417,11 @@ namespace KernelPanic.UI
artShell.AddToClassList("gacha-art-shell");
Label artLabel = new();
DistroArtPresenter.ConfigureArtLabel(artLabel, monospaceFont);
DistroArtPresenter.ConfigureArtLabel(artLabel, _monospaceFont);
artLabel.AddToClassList("gacha-ascii-art");
VisualElement placeholder = DistroArtPresenter.CreatePlaceholder();
placeholder.AddToClassList("gacha-ascii-placeholder");
AsciiArtFitter artFitter = new(artLabel, monospaceFont);
AsciiArtFitter artFitter = new(artLabel, _monospaceFont);
artFitter.SetArt(DistroArtPresenter.Render(artLabel, placeholder, distro));
if (distro != null)
{
@@ -445,7 +445,7 @@ namespace KernelPanic.UI
private string FormatBeginnerGuarantees()
{
IReadOnlyList<string> ids = gachaService.BeginnerState.guaranteedDistroIds;
IReadOnlyList<string> ids = _gachaService.BeginnerState.guaranteedDistroIds;
string first = ids.Count > 0 ? ids[0] : "--";
string second = ids.Count > 1 ? ids[1] : "--";
return $"20={first}, 40={second}, 50=future standard 5-star";
@@ -466,7 +466,7 @@ namespace KernelPanic.UI
{
return bannerId switch
{
GachaService.BeginnerBannerId => gachaService.IsBeginnerBannerAvailable ? $"{gachaService.BeginnerState.totalPulls}/{GachaService.BeginnerMaxPulls}" : "closed",
GachaService.BeginnerBannerId => _gachaService.IsBeginnerBannerAvailable ? $"{_gachaService.BeginnerState.totalPulls}/{GachaService.BeginnerMaxPulls}" : "closed",
GachaService.StandardBannerId => "soon",
GachaService.LimitedBannerId => "soon",
_ => "--"
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -8,39 +8,39 @@ namespace KernelPanic.UI
/// </summary>
public sealed class ScreenFrameController
{
private Label titleLabel;
private Label hintLabel;
private Label _titleLabel;
private Label _hintLabel;
public void Bind(VisualElement panel, string title, string hint, Action onBack)
{
titleLabel = panel.Q<Label>(className: "screen-frame-title");
hintLabel = panel.Q<Label>(className: "screen-frame-hint");
_titleLabel = panel.Q<Label>(className: "screen-frame-title");
_hintLabel = panel.Q<Label>(className: "screen-frame-hint");
if (titleLabel != null)
if (_titleLabel != null)
{
titleLabel.text = title;
_titleLabel.text = title;
}
if (hintLabel != null)
if (_hintLabel != null)
{
hintLabel.text = hint;
hintLabel.RegisterCallback<ClickEvent>(_ => onBack?.Invoke());
_hintLabel.text = hint;
_hintLabel.RegisterCallback<ClickEvent>(_ => onBack?.Invoke());
}
}
public void SetHint(string hint)
{
if (hintLabel != null)
if (_hintLabel != null)
{
hintLabel.text = hint;
_hintLabel.text = hint;
}
}
public void SetTitle(string title)
{
if (titleLabel != null)
if (_titleLabel != null)
{
titleLabel.text = title;
_titleLabel.text = title;
}
}
}
+105 -105
View File
@@ -16,52 +16,52 @@ namespace KernelPanic.UI
{
private const int StarterCount = 3;
private readonly List<VisualElement> cards = new();
private readonly List<Label> names = new();
private readonly List<Label> languages = new();
private readonly List<Label> descriptions = new();
private readonly List<VisualElement> _cards = new();
private readonly List<Label> _names = new();
private readonly List<Label> _languages = new();
private readonly List<Label> _descriptions = new();
private VisualElement root;
private VisualElement modal;
private VisualElement options;
private Label confirmLabel;
private Label errorLabel;
private DistroDatabase database;
private Action<DistroDefinition, IReadOnlyList<DistroDefinition>> onConfirmed;
private VisualElement _root;
private VisualElement _modal;
private VisualElement _options;
private Label _confirmLabel;
private Label _errorLabel;
private DistroDatabase _database;
private Action<DistroDefinition, IReadOnlyList<DistroDefinition>> _onConfirmed;
private IReadOnlyList<DistroDefinition> activeStarters = Array.Empty<DistroDefinition>();
private int selectedIndex;
private bool active;
private bool showingError;
private bool confirming;
private IVisualElementScheduledItem closeSchedule;
private IReadOnlyList<DistroDefinition> _activeStarters = Array.Empty<DistroDefinition>();
private int _selectedIndex;
private bool _active;
private bool _showingError;
private bool _confirming;
private IVisualElementScheduledItem _closeSchedule;
public bool IsActive => active;
public bool IsActive => _active;
public void Bind(VisualElement documentRoot, DistroDatabase distroDatabase, Action<DistroDefinition, IReadOnlyList<DistroDefinition>> onStarterConfirmed)
{
root = documentRoot;
database = distroDatabase;
onConfirmed = onStarterConfirmed;
_root = documentRoot;
_database = distroDatabase;
_onConfirmed = onStarterConfirmed;
modal = root.Q<VisualElement>("StarterModal");
options = root.Q<VisualElement>("StarterOptions");
confirmLabel = root.Q<Label>("StarterConfirmLabel");
errorLabel = root.Q<Label>("StarterErrorLabel");
_modal = _root.Q<VisualElement>("StarterModal");
_options = _root.Q<VisualElement>("StarterOptions");
_confirmLabel = _root.Q<Label>("StarterConfirmLabel");
_errorLabel = _root.Q<Label>("StarterErrorLabel");
for (int i = 0; i < StarterCount; i++)
{
cards.Add(root.Q<VisualElement>($"StarterOption{i}"));
names.Add(root.Q<Label>($"StarterName{i}"));
languages.Add(root.Q<Label>($"StarterLanguages{i}"));
descriptions.Add(root.Q<Label>($"StarterDescription{i}"));
_cards.Add(_root.Q<VisualElement>($"StarterOption{i}"));
_names.Add(_root.Q<Label>($"StarterName{i}"));
_languages.Add(_root.Q<Label>($"StarterLanguages{i}"));
_descriptions.Add(_root.Q<Label>($"StarterDescription{i}"));
}
for (int i = 0; i < cards.Count; i++)
for (int i = 0; i < _cards.Count; i++)
{
int index = i;
cards[i].RegisterCallback<PointerEnterEvent>(_ => Select(index));
cards[i].RegisterCallback<ClickEvent>(_ =>
_cards[i].RegisterCallback<PointerEnterEvent>(_ => Select(index));
_cards[i].RegisterCallback<ClickEvent>(_ =>
{
Select(index);
ConfirmIntent();
@@ -83,31 +83,31 @@ namespace KernelPanic.UI
return;
}
activeStarters = starters;
active = true;
showingError = false;
confirming = false;
errorLabel.AddToClassList("hidden");
options.RemoveFromClassList("hidden");
confirmLabel.AddToClassList("hidden");
modal.RemoveFromClassList("hidden");
_activeStarters = starters;
_active = true;
_showingError = false;
_confirming = false;
_errorLabel.AddToClassList("hidden");
_options.RemoveFromClassList("hidden");
_confirmLabel.AddToClassList("hidden");
_modal.RemoveFromClassList("hidden");
for (int i = 0; i < cards.Count; i++)
for (int i = 0; i < _cards.Count; i++)
{
DistroDefinition unit = starters[i];
names[i].text = DistroPresentation.DisplayName(unit);
names[i].style.color = new StyleColor(unit.AccentColor);
languages[i].text = DistroPresentation.FormatLanguages(unit);
descriptions[i].text = unit.Description;
_names[i].text = DistroPresentation.DisplayName(unit);
_names[i].style.color = new StyleColor(unit.AccentColor);
_languages[i].text = DistroPresentation.FormatLanguages(unit);
_descriptions[i].text = unit.Description;
}
Select(0);
root.Focus();
_root.Focus();
}
public void HandleKeyDown(KeyDownEvent evt)
{
if (showingError)
if (_showingError)
{
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter ||
evt.keyCode == KeyCode.Escape || evt.keyCode == KeyCode.Space)
@@ -120,13 +120,13 @@ namespace KernelPanic.UI
if (evt.keyCode == KeyCode.LeftArrow)
{
Select(selectedIndex - 1);
Select(_selectedIndex - 1);
return;
}
if (evt.keyCode == KeyCode.RightArrow)
{
Select(selectedIndex + 1);
Select(_selectedIndex + 1);
return;
}
@@ -139,7 +139,7 @@ namespace KernelPanic.UI
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
if (confirming)
if (_confirming)
{
Confirm();
return;
@@ -149,7 +149,7 @@ namespace KernelPanic.UI
return;
}
if (evt.keyCode == KeyCode.Y && confirming)
if (evt.keyCode == KeyCode.Y && _confirming)
{
Confirm();
return;
@@ -157,32 +157,32 @@ namespace KernelPanic.UI
if (evt.keyCode == KeyCode.Escape || evt.keyCode == KeyCode.N)
{
confirming = false;
confirmLabel.AddToClassList("hidden");
_confirming = false;
_confirmLabel.AddToClassList("hidden");
}
}
public void PauseSchedules()
{
closeSchedule?.Pause();
_closeSchedule?.Pause();
}
private bool TryGetStarters(out IReadOnlyList<DistroDefinition> starters, out string error)
{
if (database == null)
if (_database == null)
{
starters = Array.Empty<DistroDefinition>();
error = "no DistroDatabase is assigned";
return false;
}
if (!database.TryValidate(out error))
if (!_database.TryValidate(out error))
{
starters = Array.Empty<DistroDefinition>();
return false;
}
IReadOnlyList<DistroDefinition> allDistros = database.AllDistros;
IReadOnlyList<DistroDefinition> allDistros = _database.AllDistros;
if (allDistros.Count < StarterCount)
{
starters = Array.Empty<DistroDefinition>();
@@ -197,103 +197,103 @@ namespace KernelPanic.UI
private void ShowConfigError(string error)
{
active = true;
showingError = true;
confirming = false;
options.AddToClassList("hidden");
confirmLabel.AddToClassList("hidden");
errorLabel.text = $"starter selection unavailable: {error}. press enter to continue.";
errorLabel.RemoveFromClassList("hidden");
modal.RemoveFromClassList("hidden");
root.Focus();
_active = true;
_showingError = true;
_confirming = false;
_options.AddToClassList("hidden");
_confirmLabel.AddToClassList("hidden");
_errorLabel.text = $"starter selection unavailable: {error}. press enter to continue.";
_errorLabel.RemoveFromClassList("hidden");
_modal.RemoveFromClassList("hidden");
_root.Focus();
}
private void Select(int index)
{
if (activeStarters.Count == 0)
if (_activeStarters.Count == 0)
{
return;
}
int slotCount = Mathf.Min(activeStarters.Count, cards.Count);
selectedIndex = (index + slotCount) % slotCount;
confirming = false;
confirmLabel.AddToClassList("hidden");
int slotCount = Mathf.Min(_activeStarters.Count, _cards.Count);
_selectedIndex = (index + slotCount) % slotCount;
_confirming = false;
_confirmLabel.AddToClassList("hidden");
for (int i = 0; i < cards.Count; i++)
for (int i = 0; i < _cards.Count; i++)
{
bool selected = i == selectedIndex;
cards[i].EnableInClassList("selected", selected);
if (i < activeStarters.Count && selected)
bool selected = i == _selectedIndex;
_cards[i].EnableInClassList("selected", selected);
if (i < _activeStarters.Count && selected)
{
StyleColor accent = new(activeStarters[i].AccentColor);
cards[i].style.borderTopColor = accent;
cards[i].style.borderRightColor = accent;
cards[i].style.borderBottomColor = accent;
cards[i].style.borderLeftColor = accent;
StyleColor accent = new(_activeStarters[i].AccentColor);
_cards[i].style.borderTopColor = accent;
_cards[i].style.borderRightColor = accent;
_cards[i].style.borderBottomColor = accent;
_cards[i].style.borderLeftColor = accent;
}
else
{
cards[i].style.borderTopColor = StyleKeyword.Null;
cards[i].style.borderRightColor = StyleKeyword.Null;
cards[i].style.borderBottomColor = StyleKeyword.Null;
cards[i].style.borderLeftColor = StyleKeyword.Null;
_cards[i].style.borderTopColor = StyleKeyword.Null;
_cards[i].style.borderRightColor = StyleKeyword.Null;
_cards[i].style.borderBottomColor = StyleKeyword.Null;
_cards[i].style.borderLeftColor = StyleKeyword.Null;
}
}
}
private void ConfirmIntent()
{
if (selectedIndex >= activeStarters.Count)
if (_selectedIndex >= _activeStarters.Count)
{
return;
}
confirming = true;
confirmLabel.text = $"install {DistroPresentation.DisplayName(activeStarters[selectedIndex])}? this cannot be undone [Y/n]";
confirmLabel.RemoveFromClassList("hidden");
_confirming = true;
_confirmLabel.text = $"install {DistroPresentation.DisplayName(_activeStarters[_selectedIndex])}? this cannot be undone [Y/n]";
_confirmLabel.RemoveFromClassList("hidden");
}
private void Confirm()
{
if (selectedIndex >= activeStarters.Count)
if (_selectedIndex >= _activeStarters.Count)
{
return;
}
DistroDefinition picked = activeStarters[selectedIndex];
DistroDefinition picked = _activeStarters[_selectedIndex];
List<DistroDefinition> remaining = new();
for (int i = 0; i < activeStarters.Count; i++)
for (int i = 0; i < _activeStarters.Count; i++)
{
if (i != selectedIndex)
if (i != _selectedIndex)
{
remaining.Add(activeStarters[i]);
remaining.Add(_activeStarters[i]);
}
}
onConfirmed?.Invoke(picked, remaining);
_onConfirmed?.Invoke(picked, remaining);
confirmLabel.text = $"installing {picked.Id} (1/1)... done";
confirmLabel.RemoveFromClassList("hidden");
_confirmLabel.text = $"installing {picked.Id} (1/1)... done";
_confirmLabel.RemoveFromClassList("hidden");
if (UIPreferences.ReducedMotion)
{
Close();
return;
}
closeSchedule?.Pause();
closeSchedule = root.schedule.Execute(Close).StartingIn(600);
_closeSchedule?.Pause();
_closeSchedule = _root.schedule.Execute(Close).StartingIn(600);
}
private void Close()
{
closeSchedule?.Pause();
closeSchedule = null;
active = false;
confirming = false;
showingError = false;
modal.AddToClassList("hidden");
root.Focus();
_closeSchedule?.Pause();
_closeSchedule = null;
_active = false;
_confirming = false;
_showingError = false;
_modal.AddToClassList("hidden");
_root.Focus();
}
}
}