feat(spireclimb): add 57 new cards and major combat mechanics

Add 25 Warrior, 24 Rogue, and 8 Neutral cards (frames 34–90), expanding
the card pool from 34 to 91. Implement new card mechanics including:

- Clash (0-cost if hand is all attacks), Innate cards in opening hand
- Temp Strength (Flex), Next-Turn Energy (Flying Knee, Outmaneuver)
- Barricade (block carries over), Noxious Fumes (turn-start poison)
- Envenom (attacks apply poison), Thousand Cuts (per-card damage)
- Heavy Blade strength multiplier, Bane bonus vs poisoned enemies
- Second Wind, Fiend Fire, Reaper, Finisher, Flechettes, Grand Finale
- Double Block, Recycle Discard, Shuffle Discard, Lose Enemy Strength
- Add Card to Hand (Cloak and Dagger → Shiv)

Add potion usage confirmation modal with backdrop and cancel.
Visual improvements: enemy debuff labels, stat drain floater numbers,
player debuff flash animation, card-fly animations for discard/draw.
Update artwork docs and frame map for 91 cards (10×10 grid).
This commit is contained in:
Brian Fertig 2026-06-28 13:54:36 -06:00
parent d2fd2481b8
commit 33ef300d1d
7 changed files with 794 additions and 68 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@ -33,7 +33,14 @@
"daggerthrow": 18, "poisonedstab": 19, "deadlypoison": 20, "backflip": 21, "sneakyhit": 22,
"footwork": 23, "bladedance": 24, "caltrops": 25, "bouncingblade": 26, "crippling": 27, "catalyst": 28,
"bandage": 29, "flashofsteel": 30,
"wound": 31, "dazed": 32, "burn": 33
"wound": 31, "dazed": 32, "burn": 33,
"anger": 34, "clash": 35, "flex": 36, "headbutt": 37, "heavyblade": 38, "twinstrike": 39, "thunderclap": 40, "wildstrike": 41,
"bloodletting": 42, "carnage": 43, "disarm": 44, "entrench": 45, "hemokinesis": 46, "intimidate": 47, "pummel": 48, "secondwind": 49, "seeingred": 50, "shockwave": 51,
"barricade": 52, "bludgeon": 53, "fiendfire": 54, "immolate": 55, "impervious": 56, "offering": 57, "reaper": 58,
"acrobatics": 59, "bane": 60, "cloakdagger": 61, "daggerspray": 62, "deflect": 63, "flyingknee": 64, "outmaneuver": 65, "prepared": 66, "quickslash": 67, "slice": 68, "suckerpunch": 69,
"backstab": 70, "dash": 71, "eviscerate": 72, "finisher": 73, "flechettes": 74, "legsweep": 75, "noxiousfumes": 76, "riddlewithholes": 77,
"thousandcuts": 78, "adrenaline": 79, "diedidie": 80, "envenom": 81, "grandfinale": 82,
"shiv": 83, "bite": 84, "blind": 85, "deepbreath": 86, "dramaticEntrance": 87, "finesse": 88, "goodinstincts": 89, "swiftstrike": 90
},
"creatures": {

View File

@ -86,6 +86,54 @@ export const CARDS = {
},
// —— Warrior common ——
anger: {
id: 'anger', name: 'Anger', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', effects: [{ op: 'damage', amount: 6 }, { op: 'addCardToDiscard', card: '__self__' }],
text: 'Deal 6 damage. Add a copy of this card to your discard pile.',
upgrade: { effects: [{ op: 'damage', amount: 8 }, { op: 'addCardToDiscard', card: '__self__' }], text: 'Deal 8 damage. Add a copy to your discard pile.' },
},
clash: {
id: 'clash', name: 'Clash', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', clashOnly: true, effects: [{ op: 'damage', amount: 14 }],
text: 'Can only be played if every card in your hand is an Attack. Deal 14 damage.',
upgrade: { effects: [{ op: 'damage', amount: 18 }], text: 'Can only be played if every card in your hand is an Attack. Deal 18 damage.' },
},
flex: {
id: 'flex', name: 'Flex', cls: 'warrior', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'tempStrength', amount: 2 }],
text: 'Gain 2 Strength. At the end of your turn, lose 2 Strength.',
upgrade: { effects: [{ op: 'tempStrength', amount: 4 }], text: 'Gain 4 Strength. At the end of your turn, lose 4 Strength.' },
},
headbutt: {
id: 'headbutt', name: 'Headbutt', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'recycleDiscard' }],
text: 'Deal 9 damage. Put the top card of your discard pile on top of your draw pile.',
upgrade: { effects: [{ op: 'damage', amount: 12 }, { op: 'recycleDiscard' }], text: 'Deal 12 damage. Put the top card of your discard pile on top of your draw pile.' },
},
heavyblade: {
id: 'heavyblade', name: 'Heavy Blade', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 2, target: 'enemy', effects: [{ op: 'damage', amount: 14, strengthMul: 3 }],
text: 'Deal 14 damage. Strength affects this card 3 times.',
upgrade: { effects: [{ op: 'damage', amount: 14, strengthMul: 5 }], text: 'Deal 14 damage. Strength affects this card 5 times.' },
},
twinstrike: {
id: 'twinstrike', name: 'Twin Strike', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 5, times: 2 }],
text: 'Deal 5 damage twice.',
upgrade: { effects: [{ op: 'damage', amount: 7, times: 2 }], text: 'Deal 7 damage twice.' },
},
thunderclap: {
id: 'thunderclap', name: 'Thunder Clap', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 4 }, { op: 'debuffAll', status: 'vulnerable', amount: 1 }],
text: 'Deal 4 damage to ALL enemies. Apply 1 Vulnerable to ALL enemies.',
upgrade: { effects: [{ op: 'damageAll', amount: 7 }, { op: 'debuffAll', status: 'vulnerable', amount: 1 }], text: 'Deal 7 damage to ALL enemies. Apply 1 Vulnerable to ALL enemies.' },
},
wildstrike: {
id: 'wildstrike', name: 'Wild Strike', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 12 }, { op: 'addCardToDiscard', card: 'wound' }],
text: 'Deal 12 damage. Add a Wound to your draw pile.',
upgrade: { effects: [{ op: 'damage', amount: 17 }, { op: 'addCardToDiscard', card: 'wound' }], text: 'Deal 17 damage. Add a Wound to your draw pile.' },
},
ironwave: {
id: 'ironwave', name: 'Iron Wave', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'block', amount: 5 }, { op: 'damage', amount: 5 }],
@ -154,20 +202,189 @@ export const CARDS = {
text: 'Deal 5 damage to ALL enemies 3 times.',
upgrade: { effects: [{ op: 'damageAll', amount: 8, times: 3 }], text: 'Deal 8 damage to ALL enemies 3 times.' },
},
bloodletting: {
id: 'bloodletting', name: 'Bloodletting', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 0, target: 'self', effects: [{ op: 'loseHp', amount: 3 }, { op: 'energy', amount: 2 }],
text: 'Lose 3 HP. Gain 2 Energy.',
upgrade: { effects: [{ op: 'loseHp', amount: 3 }, { op: 'energy', amount: 3 }], text: 'Lose 3 HP. Gain 3 Energy.' },
},
carnage: {
id: 'carnage', name: 'Carnage', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 2, target: 'enemy', ethereal: true, effects: [{ op: 'damage', amount: 20 }],
text: 'Ethereal. Deal 20 damage.',
upgrade: { effects: [{ op: 'damage', amount: 28 }], text: 'Ethereal. Deal 28 damage.' },
},
disarm: {
id: 'disarm', name: 'Disarm', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 1, target: 'enemy', exhaust: true, effects: [{ op: 'loseEnemyStrength', amount: 2 }],
text: 'Enemy loses 2 Strength. Exhaust.',
upgrade: { effects: [{ op: 'loseEnemyStrength', amount: 3 }], text: 'Enemy loses 3 Strength. Exhaust.' },
},
entrench: {
id: 'entrench', name: 'Entrench', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 2, target: 'self', effects: [{ op: 'doubleBlock' }],
text: 'Double your current Block.',
upgrade: { cost: 1, text: 'Double your current Block.' },
},
hemokinesis: {
id: 'hemokinesis', name: 'Hemokinesis', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', effects: [{ op: 'loseHp', amount: 2 }, { op: 'damage', amount: 15 }],
text: 'Lose 2 HP. Deal 15 damage.',
upgrade: { effects: [{ op: 'loseHp', amount: 2 }, { op: 'damage', amount: 20 }], text: 'Lose 2 HP. Deal 20 damage.' },
},
intimidate: {
id: 'intimidate', name: 'Intimidate', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 0, target: 'all', exhaust: true, effects: [{ op: 'debuffAll', status: 'weak', amount: 1 }],
text: 'Apply 1 Weak to ALL enemies. Exhaust.',
upgrade: { effects: [{ op: 'debuffAll', status: 'weak', amount: 2 }], text: 'Apply 2 Weak to ALL enemies. Exhaust.' },
},
pummel: {
id: 'pummel', name: 'Pummel', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', exhaust: true, effects: [{ op: 'damage', amount: 2, times: 4 }],
text: 'Deal 2 damage 4 times. Exhaust.',
upgrade: { effects: [{ op: 'damage', amount: 2, times: 5 }], text: 'Deal 2 damage 5 times. Exhaust.' },
},
secondwind: {
id: 'secondwind', name: 'Second Wind', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'secondWind', amount: 5 }],
text: 'Exhaust all non-Attack cards in your hand. Gain 5 Block for each card Exhausted.',
upgrade: { effects: [{ op: 'secondWind', amount: 7 }], text: 'Exhaust all non-Attack cards in your hand. Gain 7 Block for each card Exhausted.' },
},
seeingred: {
id: 'seeingred', name: 'Seeing Red', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 1, target: 'self', exhaust: true, effects: [{ op: 'energy', amount: 2 }],
text: 'Gain 2 Energy. Exhaust.',
upgrade: { cost: 0, text: 'Gain 2 Energy. Exhaust.' },
},
shockwave: {
id: 'shockwave', name: 'Shockwave', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 2, target: 'all', exhaust: true,
effects: [{ op: 'debuffAll', status: 'vulnerable', amount: 3 }, { op: 'debuffAll', status: 'weak', amount: 3 }],
text: 'Apply 3 Vulnerable and 3 Weak to ALL enemies. Exhaust.',
upgrade: { effects: [{ op: 'debuffAll', status: 'vulnerable', amount: 5 }, { op: 'debuffAll', status: 'weak', amount: 5 }], text: 'Apply 5 Vulnerable and 5 Weak to ALL enemies. Exhaust.' },
},
limitbreak: {
id: 'limitbreak', name: 'Limit Break', cls: 'warrior', type: 'skill', rarity: 'rare',
cost: 1, target: 'self', exhaust: true, effects: [{ op: 'doubleStrength' }],
text: 'Double your Strength. Exhaust.',
upgrade: { exhaust: false, text: 'Double your Strength.' },
},
barricade: {
id: 'barricade', name: 'Barricade', cls: 'warrior', type: 'power', rarity: 'rare',
cost: 3, target: 'self', effects: [{ op: 'barricade' }],
text: 'Block is no longer removed at the start of your turn.',
upgrade: { cost: 2, text: 'Block is no longer removed at the start of your turn.' },
},
bludgeon: {
id: 'bludgeon', name: 'Bludgeon', cls: 'warrior', type: 'attack', rarity: 'rare',
cost: 3, target: 'enemy', effects: [{ op: 'damage', amount: 32 }],
text: 'Deal 32 damage.',
upgrade: { effects: [{ op: 'damage', amount: 42 }], text: 'Deal 42 damage.' },
},
demonform: {
id: 'demonform', name: 'Demon Form', cls: 'warrior', type: 'power', rarity: 'rare',
cost: 3, target: 'self', effects: [{ op: 'buffSelf', status: 'ritual', amount: 2 }],
text: 'At the start of each turn, gain 2 Strength.',
upgrade: { effects: [{ op: 'buffSelf', status: 'ritual', amount: 3 }], text: 'At the start of each turn, gain 3 Strength.' },
},
fiendfire: {
id: 'fiendfire', name: 'Fiend Fire', cls: 'warrior', type: 'attack', rarity: 'rare',
cost: 2, target: 'enemy', exhaust: true, effects: [{ op: 'fiendFire', amount: 7 }],
text: 'Exhaust your entire hand. Deal 7 damage for each card Exhausted.',
upgrade: { effects: [{ op: 'fiendFire', amount: 10 }], text: 'Exhaust your entire hand. Deal 10 damage for each card Exhausted.' },
},
immolate: {
id: 'immolate', name: 'Immolate', cls: 'warrior', type: 'attack', rarity: 'rare',
cost: 2, target: 'all', effects: [{ op: 'damageAll', amount: 21 }, { op: 'addCardToDiscard', card: 'burn' }],
text: 'Deal 21 damage to ALL enemies. Add a Burn to your discard pile.',
upgrade: { effects: [{ op: 'damageAll', amount: 28 }, { op: 'addCardToDiscard', card: 'burn' }], text: 'Deal 28 damage to ALL enemies. Add a Burn to your discard pile.' },
},
impervious: {
id: 'impervious', name: 'Impervious', cls: 'warrior', type: 'skill', rarity: 'rare',
cost: 2, target: 'self', exhaust: true, effects: [{ op: 'block', amount: 30 }],
text: 'Gain 30 Block. Exhaust.',
upgrade: { effects: [{ op: 'block', amount: 40 }], text: 'Gain 40 Block. Exhaust.' },
},
offering: {
id: 'offering', name: 'Offering', cls: 'warrior', type: 'skill', rarity: 'rare',
cost: 0, target: 'self', exhaust: true, effects: [{ op: 'loseHp', amount: 6 }, { op: 'energy', amount: 2 }, { op: 'draw', amount: 3 }],
text: 'Lose 6 HP. Gain 2 Energy. Draw 3 cards. Exhaust.',
upgrade: { effects: [{ op: 'loseHp', amount: 6 }, { op: 'energy', amount: 2 }, { op: 'draw', amount: 5 }], text: 'Lose 6 HP. Gain 2 Energy. Draw 5 cards. Exhaust.' },
},
reaper: {
id: 'reaper', name: 'Reaper', cls: 'warrior', type: 'attack', rarity: 'rare',
cost: 2, target: 'all', exhaust: true, effects: [{ op: 'reaper', amount: 4 }],
text: 'Deal 4 damage to ALL enemies. Heal HP equal to unblocked damage dealt. Exhaust.',
upgrade: { effects: [{ op: 'reaper', amount: 5 }], text: 'Deal 5 damage to ALL enemies. Heal HP equal to unblocked damage dealt. Exhaust.' },
},
// —— Rogue common ——
acrobatics: {
id: 'acrobatics', name: 'Acrobatics', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 1, target: 'self', effects: [{ op: 'draw', amount: 3 }, { op: 'discard', amount: 1 }],
text: 'Draw 3 cards. Discard 1 card.',
upgrade: { effects: [{ op: 'draw', amount: 3 }], text: 'Draw 3 cards.' },
},
bane: {
id: 'bane', name: 'Bane', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 7, bonusDamageIfPoisoned: 7 }],
text: 'Deal 7 damage. If the enemy is Poisoned, deal 7 additional damage.',
upgrade: { effects: [{ op: 'damage', amount: 10, bonusDamageIfPoisoned: 10 }], text: 'Deal 10 damage. If the enemy is Poisoned, deal 10 additional damage.' },
},
cloakdagger: {
id: 'cloakdagger', name: 'Cloak and Dagger', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 6 }, { op: 'addCardToHand', card: 'shiv', amount: 1 }],
text: 'Gain 6 Block. Add 1 Shiv to your hand.',
upgrade: { effects: [{ op: 'block', amount: 6 }, { op: 'addCardToHand', card: 'shiv', amount: 2 }], text: 'Gain 6 Block. Add 2 Shivs to your hand.' },
},
daggerspray: {
id: 'daggerspray', name: 'Dagger Spray', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 4, times: 2 }],
text: 'Deal 4 damage to ALL enemies twice.',
upgrade: { effects: [{ op: 'damageAll', amount: 6, times: 2 }], text: 'Deal 6 damage to ALL enemies twice.' },
},
deflect: {
id: 'deflect', name: 'Deflect', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'block', amount: 4 }],
text: 'Gain 4 Block.',
upgrade: { effects: [{ op: 'block', amount: 7 }], text: 'Gain 7 Block.' },
},
flyingknee: {
id: 'flyingknee', name: 'Flying Knee', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 8 }, { op: 'nextTurnEnergy', amount: 1 }],
text: 'Deal 8 damage. Next turn, gain 1 Energy.',
upgrade: { effects: [{ op: 'damage', amount: 11 }, { op: 'nextTurnEnergy', amount: 1 }], text: 'Deal 11 damage. Next turn, gain 1 Energy.' },
},
outmaneuver: {
id: 'outmaneuver', name: 'Outmaneuver', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 1, target: 'self', exhaust: true, effects: [{ op: 'nextTurnEnergy', amount: 2 }],
text: 'Next turn, gain 2 Energy. Exhaust.',
upgrade: { cost: 0, text: 'Next turn, gain 2 Energy. Exhaust.' },
},
prepared: {
id: 'prepared', name: 'Prepared', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }],
text: 'Draw 1 card. Discard 1 card.',
upgrade: { effects: [{ op: 'draw', amount: 2 }, { op: 'discard', amount: 1 }], text: 'Draw 2 cards. Discard 1 card.' },
},
quickslash: {
id: 'quickslash', name: 'Quick Slash', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 8 }, { op: 'draw', amount: 1 }],
text: 'Deal 8 damage. Draw 1 card.',
upgrade: { effects: [{ op: 'damage', amount: 12 }, { op: 'draw', amount: 1 }], text: 'Deal 12 damage. Draw 1 card.' },
},
slice: {
id: 'slice', name: 'Slice', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', effects: [{ op: 'damage', amount: 6 }],
text: 'Deal 6 damage.',
upgrade: { effects: [{ op: 'damage', amount: 9 }], text: 'Deal 9 damage.' },
},
suckerpunch: {
id: 'suckerpunch', name: 'Sucker Punch', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 7 }, { op: 'debuff', status: 'weak', amount: 1 }],
text: 'Deal 7 damage. Apply 1 Weak.',
upgrade: { effects: [{ op: 'damage', amount: 9 }, { op: 'debuff', status: 'weak', amount: 2 }], text: 'Deal 9 damage. Apply 2 Weak.' },
},
daggerthrow: {
id: 'daggerthrow', name: 'Dagger Throw', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }],
@ -223,6 +440,56 @@ export const CARDS = {
text: 'Deal 3 damage to ALL enemies 3 times.',
upgrade: { effects: [{ op: 'damageAll', amount: 4, times: 3 }], text: 'Deal 4 damage to ALL enemies 3 times.' },
},
// —— Rogue uncommon ——
backstab: {
id: 'backstab', name: 'Backstab', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 0, target: 'enemy', innate: true, exhaust: true, effects: [{ op: 'damage', amount: 11 }],
text: 'Innate. Deal 11 damage. Exhaust.',
upgrade: { effects: [{ op: 'damage', amount: 15 }], text: 'Innate. Deal 15 damage. Exhaust.' },
},
dash: {
id: 'dash', name: 'Dash', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 2, target: 'enemy', effects: [{ op: 'block', amount: 10 }, { op: 'damage', amount: 10 }],
text: 'Gain 10 Block. Deal 10 damage.',
upgrade: { effects: [{ op: 'block', amount: 13 }, { op: 'damage', amount: 13 }], text: 'Gain 13 Block. Deal 13 damage.' },
},
eviscerate: {
id: 'eviscerate', name: 'Eviscerate', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 3, target: 'enemy', effects: [{ op: 'damage', amount: 7, times: 3 }],
text: 'Deal 7 damage 3 times.',
upgrade: { effects: [{ op: 'damage', amount: 9, times: 3 }], text: 'Deal 9 damage 3 times.' },
},
finisher: {
id: 'finisher', name: 'Finisher', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', effects: [{ op: 'finisher', amount: 6 }],
text: 'Deal 6 damage for each Attack played this turn.',
upgrade: { effects: [{ op: 'finisher', amount: 8 }], text: 'Deal 8 damage for each Attack played this turn.' },
},
flechettes: {
id: 'flechettes', name: 'Flechettes', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', effects: [{ op: 'flechettes', amount: 4 }],
text: 'Deal 4 damage for each Skill card in your hand.',
upgrade: { effects: [{ op: 'flechettes', amount: 6 }], text: 'Deal 6 damage for each Skill card in your hand.' },
},
legsweep: {
id: 'legsweep', name: 'Leg Sweep', cls: 'rogue', type: 'skill', rarity: 'uncommon',
cost: 2, target: 'enemy', effects: [{ op: 'debuff', status: 'weak', amount: 2 }, { op: 'block', amount: 11 }],
text: 'Apply 2 Weak. Gain 11 Block.',
upgrade: { effects: [{ op: 'debuff', status: 'weak', amount: 3 }, { op: 'block', amount: 14 }], text: 'Apply 3 Weak. Gain 14 Block.' },
},
noxiousfumes: {
id: 'noxiousfumes', name: 'Noxious Fumes', cls: 'rogue', type: 'power', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'noxiousFumes', amount: 2 }],
text: 'At the start of each turn, apply 2 Poison to ALL enemies.',
upgrade: { effects: [{ op: 'noxiousFumes', amount: 3 }], text: 'At the start of each turn, apply 3 Poison to ALL enemies.' },
},
riddlewithholes: {
id: 'riddlewithholes', name: 'Riddle with Holes', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 2, target: 'enemy', effects: [{ op: 'damage', amount: 3, times: 5 }],
text: 'Deal 3 damage 5 times.',
upgrade: { effects: [{ op: 'damage', amount: 4, times: 5 }], text: 'Deal 4 damage 5 times.' },
},
// —— Rogue rare ——
crippling: {
id: 'crippling', name: 'Crippling Cloud', cls: 'rogue', type: 'skill', rarity: 'rare',
cost: 2, target: 'all', exhaust: true,
@ -236,8 +503,86 @@ export const CARDS = {
text: "Double an enemy's Poison. Exhaust.",
upgrade: { effects: [{ op: 'multiplyPoison', factor: 3 }], text: "Triple an enemy's Poison. Exhaust." },
},
thousandcuts: {
id: 'thousandcuts', name: 'A Thousand Cuts', cls: 'rogue', type: 'power', rarity: 'rare',
cost: 2, target: 'self', effects: [{ op: 'thousandCuts', amount: 1 }],
text: 'Whenever you play a card, deal 1 damage to ALL enemies.',
upgrade: { effects: [{ op: 'thousandCuts', amount: 2 }], text: 'Whenever you play a card, deal 2 damage to ALL enemies.' },
},
adrenaline: {
id: 'adrenaline', name: 'Adrenaline', cls: 'rogue', type: 'skill', rarity: 'rare',
cost: 0, target: 'self', exhaust: true, effects: [{ op: 'energy', amount: 1 }, { op: 'draw', amount: 2 }],
text: 'Gain 1 Energy. Draw 2 cards. Exhaust.',
upgrade: { effects: [{ op: 'energy', amount: 2 }, { op: 'draw', amount: 2 }], text: 'Gain 2 Energy. Draw 2 cards. Exhaust.' },
},
diedidie: {
id: 'diedidie', name: 'Die Die Die', cls: 'rogue', type: 'attack', rarity: 'rare',
cost: 1, target: 'all', exhaust: true, effects: [{ op: 'damageAll', amount: 13 }],
text: 'Deal 13 damage to ALL enemies. Exhaust.',
upgrade: { effects: [{ op: 'damageAll', amount: 17 }], text: 'Deal 17 damage to ALL enemies. Exhaust.' },
},
envenom: {
id: 'envenom', name: 'Envenom', cls: 'rogue', type: 'power', rarity: 'rare',
cost: 2, target: 'self', effects: [{ op: 'envenom', amount: 1 }],
text: 'Whenever you deal unblocked Attack damage, apply 1 Poison.',
upgrade: { effects: [{ op: 'envenom', amount: 2 }], text: 'Whenever you deal unblocked Attack damage, apply 2 Poison.' },
},
grandfinale: {
id: 'grandfinale', name: 'Grand Finale', cls: 'rogue', type: 'attack', rarity: 'rare',
cost: 0, target: 'all', exhaust: true, effects: [{ op: 'grandFinale', amount: 50 }],
text: 'Can only be played when your draw pile is empty. Deal 50 damage to ALL enemies. Exhaust.',
upgrade: { effects: [{ op: 'grandFinale', amount: 60 }], text: 'Can only be played when your draw pile is empty. Deal 60 damage to ALL enemies. Exhaust.' },
},
// —— Neutral / colorless ——
shiv: {
id: 'shiv', name: 'Shiv', cls: 'neutral', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', exhaust: true, effects: [{ op: 'damage', amount: 4 }],
text: 'Deal 4 damage. Exhaust.',
upgrade: { effects: [{ op: 'damage', amount: 6 }], text: 'Deal 6 damage. Exhaust.' },
},
bite: {
id: 'bite', name: 'Bite', cls: 'neutral', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 7 }, { op: 'heal', amount: 2 }],
text: 'Deal 7 damage. Heal 2 HP.',
upgrade: { effects: [{ op: 'damage', amount: 8 }, { op: 'heal', amount: 3 }], text: 'Deal 8 damage. Heal 3 HP.' },
},
blind: {
id: 'blind', name: 'Blind', cls: 'neutral', type: 'skill', rarity: 'common',
cost: 0, target: 'enemy', exhaust: true, effects: [{ op: 'debuff', status: 'weak', amount: 2 }],
text: 'Apply 2 Weak. Exhaust.',
upgrade: { exhaust: true, target: 'all', effects: [{ op: 'debuffAll', status: 'weak', amount: 2 }], text: 'Apply 2 Weak to ALL enemies. Exhaust.' },
},
deepbreath: {
id: 'deepbreath', name: 'Deep Breath', cls: 'neutral', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'shuffleDiscardIntoDraw', draw: 1 }],
text: 'Shuffle your discard pile into your draw pile. Draw 1 card.',
upgrade: { effects: [{ op: 'shuffleDiscardIntoDraw', draw: 2 }], text: 'Shuffle your discard pile into your draw pile. Draw 2 cards.' },
},
dramaticEntrance: {
id: 'dramaticEntrance', name: 'Dramatic Entrance', cls: 'neutral', type: 'attack', rarity: 'common',
cost: 0, target: 'all', innate: true, ethereal: true, effects: [{ op: 'damageAll', amount: 8 }],
text: 'Innate. Ethereal. Deal 8 damage to ALL enemies.',
upgrade: { effects: [{ op: 'damageAll', amount: 12 }], text: 'Innate. Ethereal. Deal 12 damage to ALL enemies.' },
},
finesse: {
id: 'finesse', name: 'Finesse', cls: 'neutral', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'block', amount: 2 }, { op: 'draw', amount: 1 }],
text: 'Gain 2 Block. Draw 1 card.',
upgrade: { effects: [{ op: 'block', amount: 4 }, { op: 'draw', amount: 1 }], text: 'Gain 4 Block. Draw 1 card.' },
},
goodinstincts: {
id: 'goodinstincts', name: 'Good Instincts', cls: 'neutral', type: 'skill', rarity: 'common',
cost: 0, target: 'self', effects: [{ op: 'block', amount: 6 }],
text: 'Gain 6 Block.',
upgrade: { effects: [{ op: 'block', amount: 9 }], text: 'Gain 9 Block.' },
},
swiftstrike: {
id: 'swiftstrike', name: 'Swift Strike', cls: 'neutral', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', effects: [{ op: 'damage', amount: 7 }],
text: 'Deal 7 damage.',
upgrade: { effects: [{ op: 'damage', amount: 10 }], text: 'Deal 10 damage.' },
},
bandage: {
id: 'bandage', name: 'Bandage Up', cls: 'neutral', type: 'skill', rarity: 'uncommon',
cost: 0, target: 'self', exhaust: true, effects: [{ op: 'heal', amount: 4 }],

View File

@ -37,6 +37,7 @@ export default class SpireClimbGame extends Phaser.Scene {
this.view = 'classselect';
this.pendingCard = null;
this.pendingPotion = null;
this._potionModal = null;
this.animating = false;
this.shop = null;
this.activeEvent = null;
@ -188,6 +189,7 @@ export default class SpireClimbGame extends Phaser.Scene {
setView(v) {
// Real view transitions clear any in-progress target selection.
this.dismissPotionModal();
this.view = v;
this.pendingCard = null;
this.pendingPotion = null;
@ -471,9 +473,9 @@ export default class SpireClimbGame extends Phaser.Scene {
const c = this.add.circle(x, y, 26, 0x35204a).setStrokeStyle(2, 0xb05fd0).setInteractive({ useHandCursor: true });
this.add2(c);
this.add2(this.add.text(x, y, '⚗', { fontSize: '26px', color: '#d7a8ff' }).setOrigin(0.5));
c.on('pointerover', () => this.showTip(x, y + 40, `${pot.name}: ${pot.desc}${inCombat ? ' (click to use)' : ''}`));
c.on('pointerover', () => this.showTip(x, y + 40, `${pot.name}${inCombat ? ' (click to use)' : ''}`));
c.on('pointerout', () => this.hideTip());
if (inCombat) c.on('pointerdown', () => this.onUsePotion(idx, pid));
if (inCombat) c.on('pointerdown', () => this.showPotionConfirm(idx, pid));
}
onUsePotion(idx, pid) {
@ -490,6 +492,73 @@ export default class SpireClimbGame extends Phaser.Scene {
this.afterAction();
}
showPotionConfirm(idx, pid) {
if (this.animating || this.combat.phase !== 'player') return;
this.hideTip();
this.dismissPotionModal();
const pot = POTIONS[pid];
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const W = 520, H = 290;
const cont = this.add.container(0, 0).setDepth(115);
this._potionModal = cont;
const mv = (obj) => { cont.add(obj); return obj; };
// Dim backdrop — blocks input to game elements below
mv(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive()
.on('pointerdown', () => this.dismissPotionModal()));
// Panel
const bg = mv(this.add.graphics());
bg.fillStyle(C.panel, 1); bg.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 18);
bg.lineStyle(2, 0xb05fd0, 1); bg.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 18);
// Icon + name
mv(this.add.text(cx, cy - H / 2 + 44, '⚗', { fontSize: '44px' }).setOrigin(0.5));
mv(this.add.text(cx, cy - H / 2 + 96, pot.name, {
fontFamily: 'Righteous', fontSize: '30px', color: C.gold,
}).setOrigin(0.5));
// Divider
const div = mv(this.add.graphics());
div.lineStyle(1, 0x3a2f4d, 0.9);
div.lineBetween(cx - W / 2 + 40, cy - H / 2 + 124, cx + W / 2 - 40, cy - H / 2 + 124);
// Description
mv(this.add.text(cx, cy - H / 2 + 138, pot.desc, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.ink,
wordWrap: { width: W - 80 }, align: 'center',
}).setOrigin(0.5, 0));
// — Use Potion button —
const useX = cx - 110, useY = cy + H / 2 - 46;
const useBg = mv(this.add.graphics());
const drawUse = (h) => { useBg.clear(); useBg.fillStyle(h ? 0xffd66b : C.goldI, 1); useBg.fillRoundedRect(useX - 100, useY - 24, 200, 48, 9); };
drawUse(false);
mv(this.add.text(useX, useY, 'Use Potion', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#17131d' }).setOrigin(0.5));
const useHit = mv(this.add.rectangle(useX, useY, 200, 48, 0, 0).setInteractive({ useHandCursor: true }));
useHit.on('pointerover', () => drawUse(true)).on('pointerout', () => drawUse(false))
.on('pointerdown', () => { this.dismissPotionModal(); this.onUsePotion(idx, pid); });
// — Cancel button —
const cxBtn = cx + 110, cyBtn = useY;
const cancelBg = mv(this.add.graphics());
const drawCancel = (h) => { cancelBg.clear(); cancelBg.lineStyle(2, h ? 0xb05fd0 : 0x3a2f4d, 1); cancelBg.strokeRoundedRect(cxBtn - 90, cyBtn - 24, 180, 48, 9); };
drawCancel(false);
const cancelTxt = mv(this.add.text(cxBtn, cyBtn, 'Cancel', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.muted }).setOrigin(0.5));
const cancelHit = mv(this.add.rectangle(cxBtn, cyBtn, 180, 48, 0, 0).setInteractive({ useHandCursor: true }));
cancelHit.on('pointerover', () => { drawCancel(true); cancelTxt.setColor(C.ink); })
.on('pointerout', () => { drawCancel(false); cancelTxt.setColor(C.muted); })
.on('pointerdown', () => this.dismissPotionModal());
// Fade in
cont.setAlpha(0);
this.tweens.add({ targets: cont, alpha: 1, duration: 140 });
}
dismissPotionModal() {
if (this._potionModal) { this._potionModal.destroy(); this._potionModal = null; }
}
renderEnemy(e, x, y) {
if (!e.alive) {
this.text(x, y, '☠', 60, '#5a4f6c', { ox: 0.5, oy: 0.5 });
@ -1106,6 +1175,10 @@ export default class SpireClimbGame extends Phaser.Scene {
// Shake + colored neon outline + a type-specific signifier while the buff applies.
animateEnemyBuff(e, sprite, home, done) {
const cat = this.buffCategory(e);
const effs = (e.intent && e.intent.move && e.intent.move.effects) || [];
const debuffEffs = effs.filter(x => x.op === 'debuffPlayer' && x.status && STATUS[x.status]);
const cardEffs = effs.filter(x => x.op === 'addCardToDiscard');
const statusBefore = this.captureStatuses();
resolveEnemyMove(this.combat, e);
this.flushStatusFx(statusBefore);
@ -1114,7 +1187,99 @@ export default class SpireClimbGame extends Phaser.Scene {
this.neonOutline(home.x, home.y, cat.color, 860);
this.spawnBuffSignifier(home.x, home.y, cat, sprite);
this.shakeSprite(sprite, null, 7, 320);
this.time.delayedCall(900, () => done());
debuffEffs.forEach((eff, i) => this.spawnEnemyDebuffLabel(home.x, home.y, eff.status, eff.amount, i));
// Animate the landing on the player.
if (debuffEffs.length > 0 && this._playerPos) {
// Stat drains (Lagavulin Siphon: Strength, Dexterity) → red damage-style floaters.
debuffEffs.filter(x => x.amount < 0).forEach((eff, i) =>
this.spawnStatDrainFx(eff.status, eff.amount, i));
// Player visually reacts: hit frame + purple flash.
this.setPlayerFrame(2);
this.time.delayedCall(350, () => this.setPlayerFrame(0));
this.spawnDebuffFlash(this._playerPos.x, this._playerPos.y);
}
const cardDoneMs = this.spawnEnemyCardAdds(cardEffs, home.x, home.y);
this.time.delayedCall(Math.max(900, cardDoneMs), () => done());
}
// Red floater numbers for stat drains (e.g. "1 Strength"), styled like HP damage numbers.
spawnStatDrainFx(statusKey, delta, batchIdx = 0) {
const sd = STATUS[statusKey]; if (!sd) return;
const pp = this._playerPos || { x: 300, y: 635 };
const cx = pp.x + (Math.random() * 40 - 20);
const cy = pp.y - 80 - batchIdx * 46;
const t = this.add.text(cx, cy, `${delta} ${sd.name}`, {
fontFamily: 'Righteous', fontSize: '38px', color: '#ff6a6a',
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setDepth(91);
this.fxLayer.add(t);
this.tweens.add({ targets: t, y: cy - 70, alpha: 0, duration: 850, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
}
// Brief purple flash covering the player, signalling a debuff landed.
spawnDebuffFlash(x, y) {
const g = this.add.graphics().setDepth(86);
g.fillStyle(0x9a3fcc, 0.38);
g.fillRoundedRect(x - 110, y - 220, 220, 440, 20);
this.fxLayer.add(g);
this.tweens.add({ targets: g, alpha: 0, duration: 520, ease: 'Cubic.easeOut', onComplete: () => g.destroy() });
}
// Float the debuff name in STATUS color above the casting enemy, then fade out.
spawnEnemyDebuffLabel(cx, cy, statusKey, amount, idx) {
const sd = STATUS[statusKey]; if (!sd) return;
const isNegative = amount < 0;
const label = isNegative ? `${sd.name}` : sd.name;
const colorStr = isNegative ? '#ff6a6a' : Phaser.Display.Color.IntegerToColor(sd.color).rgba;
const startY = cy - 80 - idx * 52;
const txt = this.add.text(cx, startY, label, {
fontFamily: 'Righteous', fontSize: '40px', color: colorStr,
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setDepth(93).setAlpha(0);
this.fxLayer.add(txt);
this.tweens.add({ targets: txt, alpha: 1, y: startY - 18, duration: 200, ease: 'Cubic.easeOut' });
this.time.delayedCall(800, () => {
this.tweens.add({ targets: txt, alpha: 0, y: startY - 70, duration: 420, ease: 'Cubic.easeIn', onComplete: () => txt.destroy() });
});
}
// Animate each added card floating above the enemy, then flying to its destination pile.
// Returns the ms until the last card has landed (safe to call done() then).
spawnEnemyCardAdds(cardEffs, ex, ey) {
if (!cardEffs.length) return 0;
const discardX = GAME_WIDTH - 120, discardY = 1000;
const drawX = 120, drawY = 1000;
let stagger = 0;
let lastDoneMs = 0;
for (const eff of cardEffs) {
const amt = eff.amount || 1;
const destX = eff.pile === 'draw' ? drawX : discardX;
const destY = eff.pile === 'draw' ? drawY : discardY;
for (let k = 0; k < amt; k++) {
const delay = stagger;
const inst = { id: eff.card, uid: `${eff.card}-enemy-anim-${k}`, upgraded: false };
this.time.delayedCall(delay, () => {
const card = this.makeCardSprite(ex, ey - 90, inst, 0.68, { parent: this.fxLayer });
card.setDepth(88).setAlpha(0);
this.tweens.add({ targets: card, alpha: 1, duration: 200 });
this.time.delayedCall(750, () => {
this.tweens.add({
targets: card, x: destX, y: destY, scaleX: 0.22, scaleY: 0.22,
duration: 600, ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({ targets: card, alpha: 0, duration: 750, onComplete: () => card.destroy() });
},
});
});
});
lastDoneMs = delay + 750 + 600; // up to landing (fade can overlap done)
stagger += 220;
}
}
return lastDoneMs;
}
// Which kind of self-buff is the enemy doing? Drives color + signifier.
@ -1290,18 +1455,25 @@ export default class SpireClimbGame extends Phaser.Scene {
// Compare before/after snapshots; spawn a text animation for each new or increased status.
flushStatusFx(before) {
const cb = this.combat;
const check = (unit, unitKey, beforeSnap) => {
const byUnit = {};
const collect = (unit, unitKey, beforeSnap) => {
for (const [key, val] of Object.entries(unit.statuses)) {
const old = (beforeSnap || {})[key] || 0;
if (val > old) this.spawnStatusFx(unitKey, key, unit);
if (val > old) {
if (!byUnit[unitKey]) byUnit[unitKey] = [];
byUnit[unitKey].push({ key, unit });
}
}
};
check(cb.player, 'player', before.player);
cb.enemies.forEach((e) => check(e, 'e' + e.slot, before['e' + e.slot]));
collect(cb.player, 'player', before.player);
cb.enemies.forEach((e) => collect(e, 'e' + e.slot, before['e' + e.slot]));
for (const [unitKey, items] of Object.entries(byUnit)) {
items.forEach(({ key, unit }, batchIdx) => this.spawnStatusFx(unitKey, key, unit, batchIdx));
}
}
// Animate a buff/debuff label appearing on a character, then shrinking to its status circle.
spawnStatusFx(unitKey, statusKey, unit) {
spawnStatusFx(unitKey, statusKey, unit, batchIdx = 0) {
const sd = STATUS[statusKey]; if (!sd) return;
const currentVal = unit.statuses[statusKey]; if (!currentVal) return;
@ -1317,6 +1489,9 @@ export default class SpireClimbGame extends Phaser.Scene {
cx = ref.x; cy = ref.y - 60; baseX = ref.x - 90; baseY = ref.y + 142;
}
// Stack multiple simultaneous labels upward so they don't overlap.
cy -= batchIdx * 54;
// Destination: the slot this status occupies in the status row.
const statusEntries = Object.keys(unit.statuses).filter((k) => unit.statuses[k] && STATUS[k]);
const idx = Math.max(0, statusEntries.indexOf(statusKey));

View File

@ -214,6 +214,14 @@ export function startCombat(run, enemyIds, seed) {
run, // back-reference for persistence on combat end
};
applyCombatStartRelics(combat);
// innate cards always start in the opening hand
const innates = [];
combat.draw = combat.draw.filter((inst) => {
const c = resolvedCard(inst);
if (c?.innate) { innates.push(inst); return false; }
return true;
});
combat.hand.push(...innates);
// choose initial intents
combat.enemies.forEach((e) => chooseEnemyMove(combat, e));
startPlayerTurn(combat, true);
@ -243,10 +251,15 @@ function startPlayerTurn(combat, first = false) {
const p = combat.player;
combat.turn += 1;
combat.phase = 'player';
p.block = 0;
if (!statusOf(p, 'barricade')) p.block = 0; // barricade: block carries over
p.attacksThisTurn = 0;
p.energy = p.maxEnergy + relicVal(combat, 'energy-core');
// ritual / demon form, regen handled at relevant ends; strength-from-ritual is enemy-only here
// flying knee / outmaneuver: bonus energy granted next turn
const nte = statusOf(p, 'nextTurnEnergy');
if (nte > 0) { p.energy += nte; delete p.statuses.nextTurnEnergy; }
// noxious fumes: apply poison to all enemies at start of player turn
const nf = statusOf(p, 'noxiousFumes');
if (nf > 0) combat.enemies.filter((e) => e.alive).forEach((e) => addStatus(e, 'poison', nf));
// turn-start poison on player
tickPoison(combat, p, 'You');
if (combat.phase === 'lost') return;
@ -300,7 +313,8 @@ function dealRaw(combat, unit, amount, source) {
// attack damage with modifiers (strength already added by caller for attacker)
function dealAttack(combat, attacker, target, baseAmount, opts = {}) {
let dmg = baseAmount;
// attacker strength
// attacker strength (strengthMul > 1 for heavy-blade-style cards)
if (attacker) dmg += statusOf(attacker, 'strength') * ((opts.strengthMul || 1) - 1);
if (attacker) dmg += statusOf(attacker, 'strength');
// weak on attacker
if (attacker && statusOf(attacker, 'weak') > 0) dmg = Math.floor(dmg * 0.75);
@ -314,6 +328,11 @@ function dealAttack(combat, attacker, target, baseAmount, opts = {}) {
if (target === combat.player && dealt > 0 && hasRelic(combat, 'bronze-scales') && attacker && attacker !== combat.player && attacker.alive) {
dealRaw(combat, attacker, relicVal(combat, 'bronze-scales'), 'thorns');
}
// envenom: player attacks apply poison to enemies
if (attacker === combat.player && target !== combat.player && target.alive) {
const env = statusOf(combat.player, 'envenom');
if (env > 0) addStatus(target, 'poison', env);
}
return dealt;
}
@ -350,18 +369,20 @@ export function isCombatOver(combat) {
// Cards that can legally be played right now (cost affordable, playable).
export function playableHand(combat) {
return combat.hand.filter((inst) => {
const c = resolvedCard(inst);
if (c.unplayable) return false;
return combat.player.energy >= Math.max(0, c.cost);
});
return combat.hand.filter((inst) => canPlay(combat, inst));
}
export function canPlay(combat, inst) {
const c = resolvedCard(inst);
if (!c || c.unplayable) return false;
if (combat.phase !== 'player') return false;
return combat.player.energy >= Math.max(0, c.cost);
if (combat.player.energy < Math.max(0, c.cost)) return false;
// clash: only playable if every other card in hand is an attack
if (c.clashOnly) {
const others = combat.hand.filter((h) => h !== inst);
if (!others.every((h) => { const hc = resolvedCard(h); return hc && hc.type === 'attack'; })) return false;
}
return true;
}
// Play a card from hand. targetSlot = enemy slot index (for enemy-target cards).
@ -374,13 +395,18 @@ export function playCard(combat, inst, targetSlot = null) {
combat.player.energy -= Math.max(0, c.cost);
const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat);
resolveEffects(combat, c.effects || [], { isCard: true, cardType: c.type, target });
resolveEffects(combat, c.effects || [], { isCard: true, cardType: c.type, target, selfCardId: c.id });
// exhaust vs discard
if (c.exhaust) combat.exhaust.push(inst);
else combat.discard.push(inst);
// post-resolution death checks already handled inside deal*
// thousand cuts: deal N damage to all enemies after every card played
const cuts = statusOf(combat.player, 'thousandCuts');
if (cuts > 0 && combat.phase === 'player') {
combat.enemies.filter((e) => e.alive).forEach((e) => dealRaw(combat, e, cuts, 'thousand cuts'));
}
if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat);
return true;
}
@ -444,8 +470,10 @@ function resolveEffects(combat, effects, ctx) {
const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat);
for (let i = 0; i < times; i++) {
if (!tgt || !tgt.alive) break;
let amount = e.amount;
if (e.bonusDamageIfPoisoned && tgt && statusOf(tgt, 'poison') > 0) amount += e.bonusDamageIfPoisoned;
countAttack(combat, p, isPlayerSource);
dealAttack(combat, attacker, tgt, e.amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p) });
dealAttack(combat, attacker, tgt, amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p), strengthMul: e.strengthMul });
}
break;
}
@ -479,7 +507,91 @@ function resolveEffects(combat, effects, ctx) {
case 'healPct': p.hp = Math.min(p.maxHp, p.hp + Math.floor(p.maxHp * e.amount)); break;
case 'loseHp': dealRaw(combat, p, e.amount, 'self'); break;
case 'doubleStrength': p.statuses.strength = statusOf(p, 'strength') * 2; if (!p.statuses.strength) delete p.statuses.strength; break;
case 'addCardToDiscard': for (let i = 0; i < (e.amount || 1); i++) combat.discard.push(cardInstance(e.card)); break;
case 'addCardToDiscard': {
const cardId = e.card === '__self__' ? ctx.selfCardId : e.card;
if (cardId) for (let i = 0; i < (e.amount || 1); i++) combat.discard.push(cardInstance(cardId));
break;
}
case 'addCardToHand': {
for (let i = 0; i < (e.amount || 1); i++) combat.hand.push(cardInstance(e.card));
break;
}
case 'doubleBlock': p.block = p.block * 2; break;
case 'loseEnemyStrength':
if (ctx.target && ctx.target.alive) addStatus(ctx.target, 'strength', -e.amount);
break;
case 'tempStrength':
addStatus(p, 'strength', e.amount);
addStatus(p, 'tempStrength', e.amount);
break;
case 'barricade': addStatus(p, 'barricade', 1); break;
case 'noxiousFumes': addStatus(p, 'noxiousFumes', e.amount); break;
case 'envenom': addStatus(p, 'envenom', e.amount); break;
case 'thousandCuts': addStatus(p, 'thousandCuts', e.amount); break;
case 'nextTurnEnergy': addStatus(p, 'nextTurnEnergy', e.amount); break;
case 'recycleDiscard':
if (combat.discard.length > 0) combat.draw.unshift(combat.discard.pop());
break;
case 'shuffleDiscardIntoDraw':
combat.draw = combat.rng.shuffle([...combat.draw, ...combat.discard]);
combat.discard = [];
if (e.draw) drawCards(combat, e.draw);
break;
case 'secondWind': {
const nonAttacks = combat.hand.filter((inst) => { const c = resolvedCard(inst); return c && c.type !== 'attack'; });
const count = nonAttacks.length;
for (const inst of nonAttacks) { const i = combat.hand.indexOf(inst); if (i >= 0) { combat.hand.splice(i, 1); combat.exhaust.push(inst); } }
gainBlock(p, (e.amount || 5) * count, true);
break;
}
case 'fiendFire': {
const handCopy = [...combat.hand];
combat.hand = [];
for (const inst of handCopy) combat.exhaust.push(inst);
const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat);
for (let i = 0; i < handCopy.length; i++) {
if (!tgt || !tgt.alive) break;
countAttack(combat, p, true);
dealAttack(combat, p, tgt, e.amount || 7, { source: 'card' });
}
break;
}
case 'reaper': {
let healed = 0;
combat.enemies.filter((en) => en.alive).forEach((en) => {
countAttack(combat, p, true);
healed += dealAttack(combat, p, en, e.amount, { source: 'card' });
});
if (healed > 0) p.hp = Math.min(p.maxHp, p.hp + healed);
break;
}
case 'finisher': {
const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat);
if (tgt && tgt.alive && p.attacksThisTurn > 0) {
countAttack(combat, p, true);
dealAttack(combat, p, tgt, (e.amount || 6) * p.attacksThisTurn, { source: 'card' });
}
break;
}
case 'flechettes': {
const skillCount = combat.hand.filter((inst) => { const hc = resolvedCard(inst); return hc && hc.type === 'skill'; }).length;
const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat);
for (let i = 0; i < skillCount; i++) {
if (!tgt || !tgt.alive) break;
countAttack(combat, p, true);
dealAttack(combat, p, tgt, e.amount || 4, { source: 'card' });
}
break;
}
case 'grandFinale': {
if (combat.draw.length === 0) {
combat.enemies.filter((en) => en.alive).forEach((en) => {
countAttack(combat, p, true);
dealAttack(combat, p, en, e.amount || 50, { source: 'card' });
});
}
break;
}
default: break;
}
if (combat.phase === 'lost' || combat.phase === 'won') break;
@ -510,9 +622,11 @@ function penNibTriggers(combat, p) {
export function endTurn(combat) {
if (combat.phase !== 'player') return;
const p = combat.player;
// end-of-turn: metallicize, regen, burn cards in hand
// end-of-turn: metallicize, regen, tempStrength, burn cards in hand
if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize');
if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); }
const ts = statusOf(p, 'tempStrength');
if (ts > 0) { addStatus(p, 'strength', -ts); delete p.statuses.tempStrength; }
for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); }
if (combat.phase === 'lost') return;
// ethereal cards exhaust at end of turn; the rest discard
@ -559,6 +673,8 @@ export function beginEnemyPhase(combat) {
const p = combat.player;
if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize');
if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); }
const ts = statusOf(p, 'tempStrength');
if (ts > 0) { addStatus(p, 'strength', -ts); delete p.statuses.tempStrength; }
for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); }
if (combat.phase === 'lost') return;
for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.ethereal) combat.exhaust.push(inst); else combat.discard.push(inst); }

View File

@ -72,7 +72,7 @@ on the battlefield cleanly — but an opaque square also works.
|---|---|
| **Path** | `public/assets/images/spireclimb-cards.png` |
| **Frame size** | **250 × 160 px** (aspect ≈ 1.565 : 1, matches the card art window) |
| **Sheet size** | your choice — e.g. **a 6 × 6 grid = 1500 × 960 px** holds all 34 frames |
| **Sheet size** | **10 columns × 10 rows = 2500 × 1600 px** holds all 91 frames (9 spare) |
| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) |
| **JSON** | `cardSheet` (set `path`) + `cards` map |
@ -84,52 +84,129 @@ card). Art is scaled to **fit** that window (no cropping), so anything at the
rectangular art is fine; design it to read at small size (the window is ~144×92
on a resting hand card, ~266×170 during the play-to-center zoom).
You don't have to fill all 34 frames at once — any card without art (or any
frame index missing from the `cards` map) simply stays procedural.
You don't have to fill all 91 frames at once — any card without art (or any
frame index missing from the `cards` map) simply stays procedural. Paint the
most iconic/frequently-seen cards first (starters and commons).
### Frame map
Class legend: **N** = neutral, **W** = Warrior, **R** = Rogue, **S** = status.
Rarity legend: **str** = starter, **com** = common, **unc** = uncommon, **rare** = rare.
| Frame | id | Name | Class | Type |
|---:|---|---|---|---|
| 0 | `strike` | Strike | N | attack |
| 1 | `defend` | Defend | N | skill |
| 2 | `bash` | Bash | W | attack |
| 3 | `neutralize` | Neutralize | R | attack |
| 4 | `survivor` | Survivor | R | skill |
| 5 | `ironwave` | Iron Wave | W | attack |
| 6 | `pommelstrike` | Pommel Strike | W | attack |
| 7 | `cleave` | Cleave | W | attack |
| 8 | `shrugitoff` | Shrug It Off | W | skill |
| 9 | `clothesline` | Clothesline | W | attack |
| 10 | `inflame` | Inflame | W | power |
| 11 | `uppercut` | Uppercut | W | attack |
| 12 | `ghostlyarmor` | Ghostly Armor | W | skill |
| 13 | `bodyslam` | Body Slam | W | attack |
| 14 | `metallicize` | Metallicize | W | power |
| 15 | `whirlwind` | Whirlwind | W | attack |
| 16 | `limitbreak` | Limit Break | W | skill |
| 17 | `demonform` | Demon Form | W | power |
| 18 | `daggerthrow` | Dagger Throw | R | attack |
| 19 | `poisonedstab` | Poisoned Stab | R | attack |
| 20 | `deadlypoison` | Deadly Poison | R | skill |
| 21 | `backflip` | Backflip | R | skill |
| 22 | `sneakyhit` | Sneaky Hit | R | attack |
| 23 | `footwork` | Footwork | R | power |
| 24 | `bladedance` | Blade Dance | R | attack |
| 25 | `caltrops` | Caltrops | R | power |
| 26 | `bouncingblade` | Bouncing Blade | R | attack |
| 27 | `crippling` | Crippling Cloud | R | skill |
| 28 | `catalyst` | Catalyst | R | skill |
| 29 | `bandage` | Bandage Up | N | skill |
| 30 | `flashofsteel` | Flash of Steel | N | attack |
| 31 | `wound` | Wound | S | status (clutter) |
| 32 | `dazed` | Dazed | S | status (clutter) |
| 33 | `burn` | Burn | S | status (clutter) |
#### Original cards (frames 033)
> The three status cards (3133) are unplayable junk cards enemies shuffle into
> your deck — give them a grimy/cursed look. Lowest priority.
| Frame | id | Name | Class | Type | Rarity |
|---:|---|---|---|---|---|
| 0 | `strike` | Strike | N | attack | str |
| 1 | `defend` | Defend | N | skill | str |
| 2 | `bash` | Bash | W | attack | str |
| 3 | `neutralize` | Neutralize | R | attack | str |
| 4 | `survivor` | Survivor | R | skill | str |
| 5 | `ironwave` | Iron Wave | W | attack | com |
| 6 | `pommelstrike` | Pommel Strike | W | attack | com |
| 7 | `cleave` | Cleave | W | attack | com |
| 8 | `shrugitoff` | Shrug It Off | W | skill | com |
| 9 | `clothesline` | Clothesline | W | attack | com |
| 10 | `inflame` | Inflame | W | power | unc |
| 11 | `uppercut` | Uppercut | W | attack | unc |
| 12 | `ghostlyarmor` | Ghostly Armor | W | skill | unc |
| 13 | `bodyslam` | Body Slam | W | attack | unc |
| 14 | `metallicize` | Metallicize | W | power | unc |
| 15 | `whirlwind` | Whirlwind | W | attack | unc |
| 16 | `limitbreak` | Limit Break | W | skill | rare |
| 17 | `demonform` | Demon Form | W | power | rare |
| 18 | `daggerthrow` | Dagger Throw | R | attack | com |
| 19 | `poisonedstab` | Poisoned Stab | R | attack | com |
| 20 | `deadlypoison` | Deadly Poison | R | skill | com |
| 21 | `backflip` | Backflip | R | skill | com |
| 22 | `sneakyhit` | Sneaky Hit | R | attack | com |
| 23 | `footwork` | Footwork | R | power | unc |
| 24 | `bladedance` | Blade Dance | R | attack | unc |
| 25 | `caltrops` | Caltrops | R | power | unc |
| 26 | `bouncingblade` | Bouncing Blade | R | attack | unc |
| 27 | `crippling` | Crippling Cloud | R | skill | rare |
| 28 | `catalyst` | Catalyst | R | skill | rare |
| 29 | `bandage` | Bandage Up | N | skill | unc |
| 30 | `flashofsteel` | Flash of Steel | N | attack | com |
| 31 | `wound` | Wound | S | status | — |
| 32 | `dazed` | Dazed | S | status | — |
| 33 | `burn` | Burn | S | status | — |
> Status cards (3133) are unplayable junk enemies shuffle into your deck — give them a grimy/cursed look. Lowest priority.
#### New Warrior cards (frames 3458)
| Frame | id | Name | Class | Type | Rarity | Notes |
|---:|---|---|---|---|---|---|
| 34 | `anger` | Anger | W | attack | com | deal dmg, copy self to discard |
| 35 | `clash` | Clash | W | attack | com | 0-cost big hit, only if all attacks in hand |
| 36 | `flex` | Flex | W | skill | com | gain temp Strength |
| 37 | `headbutt` | Headbutt | W | attack | com | deal dmg, recycle top discard |
| 38 | `heavyblade` | Heavy Blade | W | attack | com | big hit, Strength ×3 |
| 39 | `twinstrike` | Twin Strike | W | attack | com | 5 dmg × 2 |
| 40 | `thunderclap` | Thunder Clap | W | attack | com | 4 to all + Vulnerable to all |
| 41 | `wildstrike` | Wild Strike | W | attack | com | 12 dmg, add Wound to draw |
| 42 | `bloodletting` | Bloodletting | W | skill | unc | lose 3 HP, gain 2 Energy |
| 43 | `carnage` | Carnage | W | attack | unc | 20 dmg, ethereal |
| 44 | `disarm` | Disarm | W | skill | unc | enemy loses 2 Strength, exhaust |
| 45 | `entrench` | Entrench | W | skill | unc | double current Block |
| 46 | `hemokinesis` | Hemokinesis | W | attack | unc | lose 2 HP, deal 15 dmg |
| 47 | `intimidate` | Intimidate | W | skill | unc | 1 Weak to all, exhaust |
| 48 | `pummel` | Pummel | W | attack | unc | 2 dmg × 4, exhaust |
| 49 | `secondwind` | Second Wind | W | skill | unc | exhaust non-attacks, 5 block each |
| 50 | `seeingred` | Seeing Red | W | skill | unc | gain 2 Energy, exhaust |
| 51 | `shockwave` | Shockwave | W | skill | unc | 3 Vuln + 3 Weak to all, exhaust |
| 52 | `barricade` | Barricade | W | power | rare | block no longer expires |
| 53 | `bludgeon` | Bludgeon | W | attack | rare | 32 dmg |
| 54 | `fiendfire` | Fiend Fire | W | attack | rare | exhaust hand, 7 dmg per card |
| 55 | `immolate` | Immolate | W | attack | rare | 21 to all + Burn to discard |
| 56 | `impervious` | Impervious | W | skill | rare | 30 block, exhaust |
| 57 | `offering` | Offering | W | skill | rare | lose 6 HP, +2 energy, draw 3, exhaust |
| 58 | `reaper` | Reaper | W | attack | rare | 4 to all, heal unblocked dmg, exhaust |
#### New Rogue cards (frames 5982)
| Frame | id | Name | Class | Type | Rarity | Notes |
|---:|---|---|---|---|---|---|
| 59 | `acrobatics` | Acrobatics | R | skill | com | draw 3, discard 1 |
| 60 | `bane` | Bane | R | attack | com | 7 dmg, +7 if target is poisoned |
| 61 | `cloakdagger` | Cloak and Dagger | R | skill | com | 6 block, add Shiv to hand |
| 62 | `daggerspray` | Dagger Spray | R | attack | com | 4 to all × 2 |
| 63 | `deflect` | Deflect | R | skill | com | 4 block |
| 64 | `flyingknee` | Flying Knee | R | attack | com | 8 dmg, next turn +1 energy |
| 65 | `outmaneuver` | Outmaneuver | R | skill | com | next turn +2 energy, exhaust |
| 66 | `prepared` | Prepared | R | skill | com | draw 1, discard 1 |
| 67 | `quickslash` | Quick Slash | R | attack | com | 8 dmg, draw 1 |
| 68 | `slice` | Slice | R | attack | com | 6 dmg, free |
| 69 | `suckerpunch` | Sucker Punch | R | attack | com | 7 dmg, 1 Weak |
| 70 | `backstab` | Backstab | R | attack | unc | innate, 11 dmg, exhaust |
| 71 | `dash` | Dash | R | attack | unc | 10 block + 10 dmg |
| 72 | `eviscerate` | Eviscerate | R | attack | unc | 7 dmg × 3 |
| 73 | `finisher` | Finisher | R | attack | unc | 6 dmg per attack played this turn |
| 74 | `flechettes` | Flechettes | R | attack | unc | 4 dmg per skill in hand |
| 75 | `legsweep` | Leg Sweep | R | skill | unc | 2 Weak + 11 block |
| 76 | `noxiousfumes` | Noxious Fumes | R | power | unc | apply 2 poison to all each turn |
| 77 | `riddlewithholes` | Riddle with Holes | R | attack | unc | 3 dmg × 5 |
| 78 | `thousandcuts` | A Thousand Cuts | R | power | rare | deal 1 to all per card played |
| 79 | `adrenaline` | Adrenaline | R | skill | rare | +1 energy, draw 2, exhaust |
| 80 | `diedidie` | Die Die Die | R | attack | rare | 13 to all, exhaust |
| 81 | `envenom` | Envenom | R | power | rare | attacks apply 1 Poison |
| 82 | `grandfinale` | Grand Finale | R | attack | rare | 50 to all if draw empty, exhaust |
#### New Colorless / Neutral cards (frames 8390)
| Frame | id | Name | Class | Type | Rarity | Notes |
|---:|---|---|---|---|---|---|
| 83 | `shiv` | Shiv | N | attack | com | 4 dmg, exhaust; also generated by Cloak and Dagger |
| 84 | `bite` | Bite | N | attack | com | 7 dmg, heal 2 HP |
| 85 | `blind` | Blind | N | skill | com | 2 Weak to target (upgraded: all), exhaust |
| 86 | `deepbreath` | Deep Breath | N | skill | com | shuffle discard into draw, draw 1 |
| 87 | `dramaticEntrance` | Dramatic Entrance | N | attack | com | innate, ethereal, 8 to all |
| 88 | `finesse` | Finesse | N | skill | com | 2 block, draw 1 |
| 89 | `goodinstincts` | Good Instincts | N | skill | com | 6 block |
| 90 | `swiftstrike` | Swift Strike | N | attack | com | 7 dmg, free |
> Frames 9199 are spare capacity in the 10×10 grid.
---
@ -168,9 +245,15 @@ gradient.
## Quick checklist
- [ ] `spireclimb-creatures.png` — 1800×1200, 24 × (300×300). *(placeholder exists; replace to taste)*
- [ ] `spireclimb-cards.png` — 250×160 frames, 34 cards; then set `cardSheet.path` in `spireclimb-artwork.json`.
- [ ] `spireclimb-cards.png` — 2500×1600 (10×10 grid), 250×160 per frame, 91 frames (090); then set `cardSheet.path` in `spireclimb-artwork.json`.
- Priority 1 (starters + highest-frequency): frames 09 (Strike, Defend, Bash, Neutralize, Survivor + 5 warrior commons)
- Priority 2 (remaining commons): frames 1022, 2930, 5969, 8390
- Priority 3 (uncommons): frames 2328, 4251, 7077
- Priority 4 (rares): frames 5258, 7882
- Priority 5 (status clutter, lowest): frames 3133
- [ ] `game-icons.png` frame 74 — 44×44 menu icon.
After dropping in `spireclimb-cards.png`, set its `path` (and confirm
`frameWidth`/`frameHeight`) in `public/data/spireclimb-artwork.json`. The
creature sheet path is already set; the icon needs no config.
After dropping in `spireclimb-cards.png`, the `cardSheet.path` is already set in
`public/data/spireclimb-artwork.json` — the frame→id mapping is also already
there for all 91 cards. The creature sheet path is already set; the icon needs
no config.