diff --git a/public/assets/images/spireclimb-cards.png b/public/assets/images/spireclimb-cards.png index f917290..a68b1d2 100644 Binary files a/public/assets/images/spireclimb-cards.png and b/public/assets/images/spireclimb-cards.png differ diff --git a/public/assets/images/spireclimb-cards.psd b/public/assets/images/spireclimb-cards.psd index b557d91..ded52d2 100644 Binary files a/public/assets/images/spireclimb-cards.psd and b/public/assets/images/spireclimb-cards.psd differ diff --git a/public/data/spireclimb-artwork.json b/public/data/spireclimb-artwork.json index 99e9a31..2787596 100644 --- a/public/data/spireclimb-artwork.json +++ b/public/data/spireclimb-artwork.json @@ -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": { diff --git a/public/src/games/spireclimb/SpireClimbData.js b/public/src/games/spireclimb/SpireClimbData.js index 18494a9..6ef2f71 100644 --- a/public/src/games/spireclimb/SpireClimbData.js +++ b/public/src/games/spireclimb/SpireClimbData.js @@ -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 }], diff --git a/public/src/games/spireclimb/SpireClimbGame.js b/public/src/games/spireclimb/SpireClimbGame.js index f03dcff..da0e69f 100644 --- a/public/src/games/spireclimb/SpireClimbGame.js +++ b/public/src/games/spireclimb/SpireClimbGame.js @@ -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)); diff --git a/public/src/games/spireclimb/SpireClimbLogic.js b/public/src/games/spireclimb/SpireClimbLogic.js index 1aa4db1..f8cd197 100644 --- a/public/src/games/spireclimb/SpireClimbLogic.js +++ b/public/src/games/spireclimb/SpireClimbLogic.js @@ -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); } diff --git a/public/src/games/spireclimb/sprites.md b/public/src/games/spireclimb/sprites.md index 01282d2..3708f49 100644 --- a/public/src/games/spireclimb/sprites.md +++ b/public/src/games/spireclimb/sprites.md @@ -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 0–33) -> The three status cards (31–33) 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 (31–33) are unplayable junk enemies shuffle into your deck — give them a grimy/cursed look. Lowest priority. + +#### New Warrior cards (frames 34–58) + +| 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 59–82) + +| 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 83–90) + +| 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 91–99 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 (0–90); then set `cardSheet.path` in `spireclimb-artwork.json`. + - Priority 1 (starters + highest-frequency): frames 0–9 (Strike, Defend, Bash, Neutralize, Survivor + 5 warrior commons) + - Priority 2 (remaining commons): frames 10–22, 29–30, 59–69, 83–90 + - Priority 3 (uncommons): frames 23–28, 42–51, 70–77 + - Priority 4 (rares): frames 52–58, 78–82 + - Priority 5 (status clutter, lowest): frames 31–33 - [ ] `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.