feat: add Balatro — poker roguelike game
Implement a complete Balatro clone with: - Pure logic engine (BalatroLogic.js): seeded RNG, ante/blind state machine, shop, consumables, boss-blind hooks, save/load, endless mode - Scoring pipeline (BalatroScoring.js): full poker evaluator (including secret hands, wilds, stones, smeared/fourFingers/shortcut passives) and chips×mult trace for deterministic scoring animation - 114 Joker definitions with rich effect hooks (onScored, onHeld, independent, cardRetriggers, scaling state, copy via Blueprint/Brainstorm) - 22 Tarots, 12 Planets, 15 Spectrals, 8 decks, 16 vouchers, 10 booster pack types, 20 boss blinds + 3 finishers - Phaser scene with procedural card/Joker rendering, art-frame drop-in support, melty swirl background shader, and trace-based replay animation - Headless verification suite (tools/verifyBalatro.js) with evaluator table tests, scoring goldens, 200-bot smoke test, determinism & mid-run serialize round-trip checks, and endless scaling tests - Tutorial text and sprite-sheet layout documentation
This commit is contained in:
parent
61b86d909a
commit
160f6010b1
Binary file not shown.
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 299 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 MiB After Width: | Height: | Size: 6.8 MiB |
Binary file not shown.
|
|
@ -0,0 +1,292 @@
|
|||
{
|
||||
"_readme": [
|
||||
"Drop-in art for Balatro. Set a sheet path (e.g. assets/images/balatro-jokers.png) to load it;",
|
||||
"until then everything renders procedurally. Frames are 0-based, row-major, in the exact id order below.",
|
||||
"See src/games/balatro/sprites.md for grid layouts and sizes."
|
||||
],
|
||||
"jokerSheet": {
|
||||
"key": "balatro-jokers",
|
||||
"path": null,
|
||||
"frameWidth": 150,
|
||||
"frameHeight": 200
|
||||
},
|
||||
"tarotSheet": {
|
||||
"key": "balatro-tarots",
|
||||
"path": null,
|
||||
"frameWidth": 150,
|
||||
"frameHeight": 200
|
||||
},
|
||||
"planetSheet": {
|
||||
"key": "balatro-planets",
|
||||
"path": null,
|
||||
"frameWidth": 150,
|
||||
"frameHeight": 200
|
||||
},
|
||||
"spectralSheet": {
|
||||
"key": "balatro-spectrals",
|
||||
"path": null,
|
||||
"frameWidth": 150,
|
||||
"frameHeight": 200
|
||||
},
|
||||
"voucherSheet": {
|
||||
"key": "balatro-vouchers",
|
||||
"path": null,
|
||||
"frameWidth": 190,
|
||||
"frameHeight": 250
|
||||
},
|
||||
"packSheet": {
|
||||
"key": "balatro-packs",
|
||||
"path": null,
|
||||
"frameWidth": 180,
|
||||
"frameHeight": 240
|
||||
},
|
||||
"deckBackSheet": {
|
||||
"key": "balatro-deckbacks",
|
||||
"path": null,
|
||||
"frameWidth": 120,
|
||||
"frameHeight": 164
|
||||
},
|
||||
"bossIconSheet": {
|
||||
"key": "balatro-bossicons",
|
||||
"path": null,
|
||||
"frameWidth": 64,
|
||||
"frameHeight": 64
|
||||
},
|
||||
"jokers": {
|
||||
"joker": 0,
|
||||
"greedy": 1,
|
||||
"lusty": 2,
|
||||
"wrathful": 3,
|
||||
"gluttonous": 4,
|
||||
"jolly": 5,
|
||||
"zany": 6,
|
||||
"mad": 7,
|
||||
"crazy": 8,
|
||||
"droll": 9,
|
||||
"halfjoker": 10,
|
||||
"misprint": 11,
|
||||
"sly": 12,
|
||||
"wily": 13,
|
||||
"clever": 14,
|
||||
"devious": 15,
|
||||
"crafty": 16,
|
||||
"banner": 17,
|
||||
"stuntman": 18,
|
||||
"fibonacci": 19,
|
||||
"scaryface": 20,
|
||||
"evensteven": 21,
|
||||
"oddtodd": 22,
|
||||
"scholar": 23,
|
||||
"smileyface": 24,
|
||||
"walkietalkie": 25,
|
||||
"roughgem": 26,
|
||||
"bloodstone": 27,
|
||||
"arrowhead": 28,
|
||||
"onyxagate": 29,
|
||||
"blackboard": 30,
|
||||
"baron": 31,
|
||||
"photograph": 32,
|
||||
"ancient": 33,
|
||||
"cavendish": 34,
|
||||
"cardsharp": 35,
|
||||
"acrobat": 36,
|
||||
"duo": 37,
|
||||
"trio": 38,
|
||||
"family": 39,
|
||||
"order": 40,
|
||||
"tribe": 41,
|
||||
"flowerpot": 42,
|
||||
"runner": 43,
|
||||
"icecream": 44,
|
||||
"bluejoker": 45,
|
||||
"greenjoker": 46,
|
||||
"ridethebus": 47,
|
||||
"squarejoker": 48,
|
||||
"supernova": 49,
|
||||
"obelisk": 50,
|
||||
"constellation": 51,
|
||||
"hologram": 52,
|
||||
"vampire": 53,
|
||||
"madness": 54,
|
||||
"castle": 55,
|
||||
"weejoker": 56,
|
||||
"erosion": 57,
|
||||
"hiker": 58,
|
||||
"ramen": 59,
|
||||
"popcorn": 60,
|
||||
"throwback": 61,
|
||||
"goldenjoker": 62,
|
||||
"delayedgrat": 63,
|
||||
"businesscard": 64,
|
||||
"faceless": 65,
|
||||
"cloud9": 66,
|
||||
"rocket": 67,
|
||||
"egg": 68,
|
||||
"tothemoon": 69,
|
||||
"satellite": 70,
|
||||
"bull": 71,
|
||||
"bootstraps": 72,
|
||||
"goldenticket": 73,
|
||||
"redcard": 74,
|
||||
"swashbuckler": 75,
|
||||
"fortuneteller": 76,
|
||||
"tradingcard": 77,
|
||||
"hack": 78,
|
||||
"dusk": 79,
|
||||
"sockandbuskin": 80,
|
||||
"seltzer": 81,
|
||||
"hangingchad": 82,
|
||||
"mime": 83,
|
||||
"juggler": 84,
|
||||
"drunkard": 85,
|
||||
"troubadour": 86,
|
||||
"merryandy": 87,
|
||||
"turtlebean": 88,
|
||||
"burglar": 89,
|
||||
"creditcard": 90,
|
||||
"chaostheclown": 91,
|
||||
"oops": 92,
|
||||
"pareidolia": 93,
|
||||
"splash": 94,
|
||||
"smeared": 95,
|
||||
"shortcut": 96,
|
||||
"fourfingers": 97,
|
||||
"stencil": 98,
|
||||
"eightball": 99,
|
||||
"superposition": 100,
|
||||
"vagabond": 101,
|
||||
"cartomancer": 102,
|
||||
"mysticsummit": 103,
|
||||
"loyaltycard": 104,
|
||||
"raisedfist": 105,
|
||||
"shootthemoon": 106,
|
||||
"abstract": 107,
|
||||
"grosmichel": 108,
|
||||
"stonejoker": 109,
|
||||
"baseball": 110,
|
||||
"dna": 111,
|
||||
"blueprint": 112,
|
||||
"brainstorm": 113
|
||||
},
|
||||
"tarots": {
|
||||
"fool": 0,
|
||||
"magician": 1,
|
||||
"highpriestess": 2,
|
||||
"empress": 3,
|
||||
"emperor": 4,
|
||||
"hierophant": 5,
|
||||
"lovers": 6,
|
||||
"chariot": 7,
|
||||
"justice": 8,
|
||||
"hermit": 9,
|
||||
"wheeloffortune": 10,
|
||||
"strength": 11,
|
||||
"hangedman": 12,
|
||||
"death": 13,
|
||||
"temperance": 14,
|
||||
"devil": 15,
|
||||
"tower": 16,
|
||||
"star": 17,
|
||||
"moon": 18,
|
||||
"sun": 19,
|
||||
"judgement": 20,
|
||||
"world": 21
|
||||
},
|
||||
"planets": {
|
||||
"pluto": 0,
|
||||
"mercury": 1,
|
||||
"uranus": 2,
|
||||
"venus": 3,
|
||||
"saturn": 4,
|
||||
"jupiter": 5,
|
||||
"earth": 6,
|
||||
"mars": 7,
|
||||
"neptune": 8,
|
||||
"planetx": 9,
|
||||
"ceres": 10,
|
||||
"eris": 11
|
||||
},
|
||||
"spectrals": {
|
||||
"familiar": 0,
|
||||
"grim": 1,
|
||||
"incantation": 2,
|
||||
"talisman": 3,
|
||||
"dejavu": 4,
|
||||
"trance": 5,
|
||||
"medium": 6,
|
||||
"aura": 7,
|
||||
"wraith": 8,
|
||||
"sigil": 9,
|
||||
"ouija": 10,
|
||||
"ectoplasm": 11,
|
||||
"immolate": 12,
|
||||
"blackhole": 13,
|
||||
"hex": 14
|
||||
},
|
||||
"vouchers": {
|
||||
"overstock": 0,
|
||||
"overstockplus": 1,
|
||||
"clearance": 2,
|
||||
"liquidation": 3,
|
||||
"hone": 4,
|
||||
"glowup": 5,
|
||||
"rerollsurplus": 6,
|
||||
"rerollglut": 7,
|
||||
"grabber": 8,
|
||||
"nachotong": 9,
|
||||
"wasteful": 10,
|
||||
"recyclomancy": 11,
|
||||
"seedmoney": 12,
|
||||
"moneytree": 13,
|
||||
"crystalball": 14,
|
||||
"telescope": 15
|
||||
},
|
||||
"packs": {
|
||||
"standard_normal": 0,
|
||||
"standard_jumbo": 1,
|
||||
"arcana_normal": 2,
|
||||
"arcana_jumbo": 3,
|
||||
"celestial_normal": 4,
|
||||
"celestial_jumbo": 5,
|
||||
"buffoon_normal": 6,
|
||||
"buffoon_jumbo": 7,
|
||||
"spectral_normal": 8,
|
||||
"spectral_jumbo": 9
|
||||
},
|
||||
"deckBacks": {
|
||||
"red": 0,
|
||||
"blue": 1,
|
||||
"yellow": 2,
|
||||
"green": 3,
|
||||
"black": 4,
|
||||
"magic": 5,
|
||||
"nebula": 6,
|
||||
"ghost": 7
|
||||
},
|
||||
"bosses": {
|
||||
"the_hook": 0,
|
||||
"the_ox": 1,
|
||||
"the_house": 2,
|
||||
"the_wall": 3,
|
||||
"the_wheel": 4,
|
||||
"the_arm": 5,
|
||||
"the_club": 6,
|
||||
"the_goad": 7,
|
||||
"the_window": 8,
|
||||
"the_head": 9,
|
||||
"the_psychic": 10,
|
||||
"the_fish": 11,
|
||||
"the_water": 12,
|
||||
"the_manacle": 13,
|
||||
"the_eye": 14,
|
||||
"the_mouth": 15,
|
||||
"the_plant": 16,
|
||||
"the_needle": 17,
|
||||
"the_flint": 18,
|
||||
"the_mark": 19,
|
||||
"the_pillar": 20,
|
||||
"violet_vessel": 21,
|
||||
"crimson_heart": 22,
|
||||
"cerulean_bell": 23
|
||||
}
|
||||
}
|
||||
|
|
@ -106,3 +106,4 @@ registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2,
|
|||
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
|
||||
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77, defaultPlayfield: 'fantasy' });
|
||||
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' });
|
||||
registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
// BalatroData.js
|
||||
// Static content for Balatro: poker hand table + level curves, ante/blind chip
|
||||
// requirements (incl. endless), decks, enhancements/editions/seals, tarot /
|
||||
// planet / spectral consumables, vouchers, booster packs, boss blinds, prices
|
||||
// and rarity weights. Joker definitions live in BalatroJokers.js; the engine
|
||||
// is BalatroLogic.js + BalatroScoring.js.
|
||||
|
||||
// ── Cards ────────────────────────────────────────────────────────────────────
|
||||
export const SUITS = ['S', 'H', 'D', 'C'];
|
||||
export const SUIT_NAMES = { S: 'Spades', H: 'Hearts', D: 'Diamonds', C: 'Clubs' };
|
||||
export const RANK_NAMES = { 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: 'J', 12: 'Q', 13: 'K', 14: 'A' };
|
||||
|
||||
export function rankChips(rank) {
|
||||
if (rank === 14) return 11;
|
||||
if (rank >= 11) return 10;
|
||||
return rank;
|
||||
}
|
||||
export function isFaceRank(rank) { return rank === 11 || rank === 12 || rank === 13; }
|
||||
|
||||
// ── Poker hands (id order = detection priority, strongest first) ─────────────
|
||||
// base chips × mult at level 1; each level adds levelChips / levelMult.
|
||||
export const HAND_TYPES = [
|
||||
{ id: 'flushfive', name: 'Flush Five', chips: 160, mult: 16, levelChips: 50, levelMult: 3, secret: true },
|
||||
{ id: 'flushhouse', name: 'Flush House', chips: 140, mult: 14, levelChips: 40, levelMult: 4, secret: true },
|
||||
{ id: 'fiveofakind', name: 'Five of a Kind', chips: 120, mult: 12, levelChips: 35, levelMult: 3, secret: true },
|
||||
{ id: 'straightflush', name: 'Straight Flush', chips: 100, mult: 8, levelChips: 40, levelMult: 4 },
|
||||
{ id: 'fourofakind', name: 'Four of a Kind', chips: 60, mult: 7, levelChips: 30, levelMult: 3 },
|
||||
{ id: 'fullhouse', name: 'Full House', chips: 40, mult: 4, levelChips: 25, levelMult: 2 },
|
||||
{ id: 'flush', name: 'Flush', chips: 35, mult: 4, levelChips: 15, levelMult: 2 },
|
||||
{ id: 'straight', name: 'Straight', chips: 30, mult: 4, levelChips: 30, levelMult: 3 },
|
||||
{ id: 'threeofakind', name: 'Three of a Kind', chips: 30, mult: 3, levelChips: 20, levelMult: 2 },
|
||||
{ id: 'twopair', name: 'Two Pair', chips: 20, mult: 2, levelChips: 20, levelMult: 1 },
|
||||
{ id: 'pair', name: 'Pair', chips: 10, mult: 2, levelChips: 15, levelMult: 1 },
|
||||
{ id: 'highcard', name: 'High Card', chips: 5, mult: 1, levelChips: 10, levelMult: 1 },
|
||||
];
|
||||
export const HAND_BY_ID = Object.fromEntries(HAND_TYPES.map((h) => [h.id, h]));
|
||||
|
||||
export function handBase(handId, level) {
|
||||
const h = HAND_BY_ID[handId];
|
||||
const lv = Math.max(1, level | 0);
|
||||
return { chips: h.chips + h.levelChips * (lv - 1), mult: h.mult + h.levelMult * (lv - 1) };
|
||||
}
|
||||
|
||||
// ── Antes & blinds ───────────────────────────────────────────────────────────
|
||||
const ANTE_BASE = { 1: 300, 2: 800, 3: 2000, 4: 5000, 5: 11000, 6: 20000, 7: 35000, 8: 50000 };
|
||||
const CHIP_CAP = Number.MAX_VALUE / 1e6; // requirements clamp here; a hand at Infinity ends an endless run
|
||||
|
||||
export function anteBase(ante) {
|
||||
if (ante <= 8) return ANTE_BASE[Math.max(1, ante)];
|
||||
const n = ante - 8;
|
||||
const v = Math.floor(50000 * Math.pow(2.1 + 0.12 * n, n));
|
||||
return Number.isFinite(v) ? Math.min(v, CHIP_CAP) : CHIP_CAP;
|
||||
}
|
||||
|
||||
export const BLINDS = {
|
||||
small: { name: 'Small Blind', mult: 1, reward: 3, skippable: true },
|
||||
big: { name: 'Big Blind', mult: 1.5, reward: 4, skippable: true },
|
||||
boss: { name: 'Boss Blind', mult: 2, reward: 5, skippable: false },
|
||||
};
|
||||
|
||||
export function fmtChips(n) {
|
||||
if (!Number.isFinite(n)) return '∞';
|
||||
if (n >= 1e11) return n.toExponential(2).replace('e+', 'e');
|
||||
return Math.floor(n).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
// ── Run defaults ─────────────────────────────────────────────────────────────
|
||||
export const DEFAULTS = {
|
||||
handSize: 8, hands: 4, discards: 3, jokerSlots: 5, consumableSlots: 2,
|
||||
startGold: 4, interestPer: 5, interestCap: 5, rerollBase: 5,
|
||||
};
|
||||
|
||||
// ── Enhancements / editions / seals ──────────────────────────────────────────
|
||||
export const ENHANCEMENTS = {
|
||||
bonus: { name: 'Bonus', desc: '+30 Chips when scored' },
|
||||
mult: { name: 'Mult', desc: '+4 Mult when scored' },
|
||||
wild: { name: 'Wild', desc: 'Counts as every suit' },
|
||||
glass: { name: 'Glass', desc: 'x2 Mult when scored; 1 in 4 chance to shatter' },
|
||||
steel: { name: 'Steel', desc: 'x1.5 Mult while held in hand' },
|
||||
stone: { name: 'Stone', desc: '+50 Chips; no rank or suit; always scores' },
|
||||
gold: { name: 'Gold', desc: '$3 if held in hand at end of round' },
|
||||
lucky: { name: 'Lucky', desc: '1 in 5 chance for +20 Mult; 1 in 15 chance for $20' },
|
||||
};
|
||||
export const EDITIONS = {
|
||||
foil: { name: 'Foil', desc: '+50 Chips', cost: 2 },
|
||||
holo: { name: 'Holographic', desc: '+10 Mult', cost: 3 },
|
||||
poly: { name: 'Polychrome', desc: 'x1.5 Mult', cost: 5 },
|
||||
negative: { name: 'Negative', desc: '+1 Joker slot', cost: 5, jokerOnly: true },
|
||||
};
|
||||
export const SEALS = {
|
||||
red: { name: 'Red Seal', desc: 'Retrigger this card once' },
|
||||
blue: { name: 'Blue Seal', desc: 'Creates the Planet of the last played hand if held at end of round' },
|
||||
gold: { name: 'Gold Seal', desc: '$3 when this card is played and scores' },
|
||||
purple: { name: 'Purple Seal', desc: 'Creates a Tarot when discarded' },
|
||||
};
|
||||
|
||||
// ── Decks ────────────────────────────────────────────────────────────────────
|
||||
export const DECKS = [
|
||||
{ id: 'red', name: 'Red Deck', color: 0xd83a3a, desc: '+1 discard every round',
|
||||
mods: { discards: 1 } },
|
||||
{ id: 'blue', name: 'Blue Deck', color: 0x3a6ad8, desc: '+1 hand every round',
|
||||
mods: { hands: 1 } },
|
||||
{ id: 'yellow', name: 'Yellow Deck', color: 0xd8b23a, desc: 'Start with an extra $10',
|
||||
mods: { gold: 10 } },
|
||||
{ id: 'green', name: 'Green Deck', color: 0x3ad86a, desc: 'No interest; $2 per unused hand, $1 per unused discard at end of round',
|
||||
mods: { noInterest: true, unusedHand: 2, unusedDiscard: 1 } },
|
||||
{ id: 'black', name: 'Black Deck', color: 0x333344, desc: '+1 Joker slot, -1 hand every round',
|
||||
mods: { jokerSlots: 1, hands: -1 } },
|
||||
{ id: 'magic', name: 'Magic Deck', color: 0x8a3ad8, desc: 'Start with the Crystal Ball voucher and 2 copies of The Fool',
|
||||
mods: { startVouchers: ['crystalball'], startConsumables: [{ kind: 'tarot', id: 'fool' }, { kind: 'tarot', id: 'fool' }] } },
|
||||
{ id: 'nebula', name: 'Nebula Deck', color: 0x3ac8d8, desc: 'Start with the Telescope voucher, -1 consumable slot',
|
||||
mods: { startVouchers: ['telescope'], consumableSlots: -1 } },
|
||||
{ id: 'ghost', name: 'Ghost Deck', color: 0xbfc8e8, desc: 'Spectral cards may appear in the shop; start with a Hex',
|
||||
mods: { shopSpectral: true, startConsumables: [{ kind: 'spectral', id: 'hex' }] } },
|
||||
];
|
||||
export const DECK_BY_ID = Object.fromEntries(DECKS.map((d) => [d.id, d]));
|
||||
|
||||
// ── Consumables ──────────────────────────────────────────────────────────────
|
||||
// Declarative `effect` specs interpreted by BalatroLogic.useConsumable.
|
||||
// `targets: n` = the player must select up to n cards in hand first.
|
||||
export const TAROTS = [
|
||||
{ id: 'fool', name: 'The Fool', effect: { kind: 'fool' }, desc: 'Creates a copy of the last Tarot or Planet used this run (The Fool excluded)' },
|
||||
{ id: 'magician', name: 'The Magician', targets: 2, effect: { kind: 'enhance', enh: 'lucky' }, desc: 'Enhances up to 2 selected cards to Lucky Cards' },
|
||||
{ id: 'highpriestess', name: 'The High Priestess', effect: { kind: 'create', what: 'planet', count: 2 }, desc: 'Creates up to 2 random Planet cards' },
|
||||
{ id: 'empress', name: 'The Empress', targets: 2, effect: { kind: 'enhance', enh: 'mult' }, desc: 'Enhances up to 2 selected cards to Mult Cards' },
|
||||
{ id: 'emperor', name: 'The Emperor', effect: { kind: 'create', what: 'tarot', count: 2 }, desc: 'Creates up to 2 random Tarot cards' },
|
||||
{ id: 'hierophant', name: 'The Hierophant', targets: 2, effect: { kind: 'enhance', enh: 'bonus' }, desc: 'Enhances up to 2 selected cards to Bonus Cards' },
|
||||
{ id: 'lovers', name: 'The Lovers', targets: 1, effect: { kind: 'enhance', enh: 'wild' }, desc: 'Enhances 1 selected card into a Wild Card' },
|
||||
{ id: 'chariot', name: 'The Chariot', targets: 1, effect: { kind: 'enhance', enh: 'steel' }, desc: 'Enhances 1 selected card into a Steel Card' },
|
||||
{ id: 'justice', name: 'Justice', targets: 1, effect: { kind: 'enhance', enh: 'glass' }, desc: 'Enhances 1 selected card into a Glass Card' },
|
||||
{ id: 'hermit', name: 'The Hermit', effect: { kind: 'doubleMoney', max: 20 }, desc: 'Doubles money (max +$20)' },
|
||||
{ id: 'wheeloffortune', name: 'Wheel of Fortune', effect: { kind: 'wheel' }, desc: '1 in 4 chance to add Foil, Holographic, or Polychrome to a random Joker' },
|
||||
{ id: 'strength', name: 'Strength', targets: 2, effect: { kind: 'rankUp' }, desc: 'Raises the rank of up to 2 selected cards by 1' },
|
||||
{ id: 'hangedman', name: 'The Hanged Man', targets: 2, effect: { kind: 'destroy' }, desc: 'Destroys up to 2 selected cards' },
|
||||
{ id: 'death', name: 'Death', targets: 2, effect: { kind: 'death' }, desc: 'Select 2 cards: the left card becomes a copy of the right card' },
|
||||
{ id: 'temperance', name: 'Temperance', effect: { kind: 'temperance', max: 50 }, desc: 'Gives the total sell value of all current Jokers (max $50)' },
|
||||
{ id: 'devil', name: 'The Devil', targets: 1, effect: { kind: 'enhance', enh: 'gold' }, desc: 'Enhances 1 selected card into a Gold Card' },
|
||||
{ id: 'tower', name: 'The Tower', targets: 1, effect: { kind: 'enhance', enh: 'stone' }, desc: 'Enhances 1 selected card into a Stone Card' },
|
||||
{ id: 'star', name: 'The Star', targets: 3, effect: { kind: 'suit', suit: 'D' }, desc: 'Converts up to 3 selected cards to Diamonds' },
|
||||
{ id: 'moon', name: 'The Moon', targets: 3, effect: { kind: 'suit', suit: 'C' }, desc: 'Converts up to 3 selected cards to Clubs' },
|
||||
{ id: 'sun', name: 'The Sun', targets: 3, effect: { kind: 'suit', suit: 'H' }, desc: 'Converts up to 3 selected cards to Hearts' },
|
||||
{ id: 'judgement', name: 'Judgement', effect: { kind: 'create', what: 'joker', count: 1 }, desc: 'Creates a random Joker (must have room)' },
|
||||
{ id: 'world', name: 'The World', targets: 3, effect: { kind: 'suit', suit: 'S' }, desc: 'Converts up to 3 selected cards to Spades' },
|
||||
];
|
||||
|
||||
export const PLANETS = [
|
||||
{ id: 'pluto', name: 'Pluto', hand: 'highcard' },
|
||||
{ id: 'mercury', name: 'Mercury', hand: 'pair' },
|
||||
{ id: 'uranus', name: 'Uranus', hand: 'twopair' },
|
||||
{ id: 'venus', name: 'Venus', hand: 'threeofakind' },
|
||||
{ id: 'saturn', name: 'Saturn', hand: 'straight' },
|
||||
{ id: 'jupiter', name: 'Jupiter', hand: 'flush' },
|
||||
{ id: 'earth', name: 'Earth', hand: 'fullhouse' },
|
||||
{ id: 'mars', name: 'Mars', hand: 'fourofakind' },
|
||||
{ id: 'neptune', name: 'Neptune', hand: 'straightflush' },
|
||||
{ id: 'planetx', name: 'Planet X', hand: 'fiveofakind', secret: true },
|
||||
{ id: 'ceres', name: 'Ceres', hand: 'flushhouse', secret: true },
|
||||
{ id: 'eris', name: 'Eris', hand: 'flushfive', secret: true },
|
||||
];
|
||||
export const PLANET_BY_HAND = Object.fromEntries(PLANETS.map((p) => [p.hand, p]));
|
||||
|
||||
export const SPECTRALS = [
|
||||
{ id: 'familiar', name: 'Familiar', effect: { kind: 'spectralAdd', destroy: 1, add: 3, addKind: 'face' }, desc: 'Destroy 1 random held card; add 3 random enhanced face cards to your hand' },
|
||||
{ id: 'grim', name: 'Grim', effect: { kind: 'spectralAdd', destroy: 1, add: 2, addKind: 'ace' }, desc: 'Destroy 1 random held card; add 2 random enhanced Aces to your hand' },
|
||||
{ id: 'incantation', name: 'Incantation', effect: { kind: 'spectralAdd', destroy: 1, add: 4, addKind: 'number' }, desc: 'Destroy 1 random held card; add 4 random enhanced numbered cards to your hand' },
|
||||
{ id: 'talisman', name: 'Talisman', targets: 1, effect: { kind: 'seal', seal: 'gold' }, desc: 'Adds a Gold Seal to 1 selected card' },
|
||||
{ id: 'dejavu', name: 'Deja Vu', targets: 1, effect: { kind: 'seal', seal: 'red' }, desc: 'Adds a Red Seal to 1 selected card' },
|
||||
{ id: 'trance', name: 'Trance', targets: 1, effect: { kind: 'seal', seal: 'blue' }, desc: 'Adds a Blue Seal to 1 selected card' },
|
||||
{ id: 'medium', name: 'Medium', targets: 1, effect: { kind: 'seal', seal: 'purple' }, desc: 'Adds a Purple Seal to 1 selected card' },
|
||||
{ id: 'aura', name: 'Aura', targets: 1, effect: { kind: 'aura' }, desc: 'Adds Foil, Holographic, or Polychrome to 1 selected card' },
|
||||
{ id: 'wraith', name: 'Wraith', effect: { kind: 'wraith' }, desc: 'Creates a random Rare Joker; sets money to $0' },
|
||||
{ id: 'sigil', name: 'Sigil', effect: { kind: 'sigil' }, desc: 'Converts all cards in hand to a single random suit' },
|
||||
{ id: 'ouija', name: 'Ouija', effect: { kind: 'ouija' }, desc: 'Converts all cards in hand to a single random rank; -1 hand size' },
|
||||
{ id: 'ectoplasm', name: 'Ectoplasm', effect: { kind: 'ectoplasm' }, desc: 'Adds Negative to a random Joker; -1 hand size' },
|
||||
{ id: 'immolate', name: 'Immolate', effect: { kind: 'immolate' }, desc: 'Destroys 5 random cards in hand; gain $20' },
|
||||
{ id: 'blackhole', name: 'Black Hole', effect: { kind: 'blackhole' }, desc: 'Upgrades every poker hand by 1 level' },
|
||||
{ id: 'hex', name: 'Hex', effect: { kind: 'hex' }, desc: 'Adds Polychrome to a random Joker; destroys all other Jokers' },
|
||||
];
|
||||
|
||||
export const TAROT_BY_ID = Object.fromEntries(TAROTS.map((t) => [t.id, t]));
|
||||
export const PLANET_BY_ID = Object.fromEntries(PLANETS.map((p) => [p.id, p]));
|
||||
export const SPECTRAL_BY_ID = Object.fromEntries(SPECTRALS.map((s) => [s.id, s]));
|
||||
|
||||
// ── Vouchers (8 two-tier chains; tier 2 offered only once tier 1 is owned) ───
|
||||
export const VOUCHERS = [
|
||||
{ id: 'overstock', name: 'Overstock', tier: 1, desc: '+1 card slot in the shop', mods: { shopCards: 1 } },
|
||||
{ id: 'overstockplus', name: 'Overstock Plus', tier: 2, requires: 'overstock', desc: '+1 more card slot in the shop', mods: { shopCards: 1 } },
|
||||
{ id: 'clearance', name: 'Clearance Sale', tier: 1, desc: 'All shop items 25% off', mods: { discount: 0.25 } },
|
||||
{ id: 'liquidation', name: 'Liquidation', tier: 2, requires: 'clearance', desc: 'All shop items 50% off', mods: { discount: 0.5 } },
|
||||
{ id: 'hone', name: 'Hone', tier: 1, desc: 'Foil, Holographic and Polychrome cards appear 2x more often', mods: { editionOdds: 2 } },
|
||||
{ id: 'glowup', name: 'Glow Up', tier: 2, requires: 'hone', desc: 'Foil, Holographic and Polychrome cards appear 4x more often', mods: { editionOdds: 2 } },
|
||||
{ id: 'rerollsurplus', name: 'Reroll Surplus', tier: 1, desc: 'Rerolls cost $2 less', mods: { rerollOff: 2 } },
|
||||
{ id: 'rerollglut', name: 'Reroll Glut', tier: 2, requires: 'rerollsurplus', desc: 'Rerolls cost $2 less again', mods: { rerollOff: 2 } },
|
||||
{ id: 'grabber', name: 'Grabber', tier: 1, desc: '+1 hand every round', mods: { hands: 1 } },
|
||||
{ id: 'nachotong', name: 'Nacho Tong', tier: 2, requires: 'grabber', desc: '+1 more hand every round', mods: { hands: 1 } },
|
||||
{ id: 'wasteful', name: 'Wasteful', tier: 1, desc: '+1 discard every round', mods: { discards: 1 } },
|
||||
{ id: 'recyclomancy', name: 'Recyclomancy', tier: 2, requires: 'wasteful', desc: '+1 more discard every round', mods: { discards: 1 } },
|
||||
{ id: 'seedmoney', name: 'Seed Money', tier: 1, desc: 'Raises the interest cap to $10', mods: { interestCap: 10 } },
|
||||
{ id: 'moneytree', name: 'Money Tree', tier: 2, requires: 'seedmoney', desc: 'Raises the interest cap to $20', mods: { interestCap: 20 } },
|
||||
{ id: 'crystalball', name: 'Crystal Ball', tier: 1, desc: '+1 consumable slot', mods: { consumableSlots: 1 } },
|
||||
{ id: 'telescope', name: 'Telescope', tier: 2, requires: 'crystalball', desc: 'Celestial packs always contain the Planet of your most played hand', mods: { telescope: true } },
|
||||
];
|
||||
export const VOUCHER_BY_ID = Object.fromEntries(VOUCHERS.map((v) => [v.id, v]));
|
||||
export const VOUCHER_COST = 10;
|
||||
|
||||
// ── Booster packs ────────────────────────────────────────────────────────────
|
||||
export const BOOSTERS = [
|
||||
{ id: 'standard_normal', name: 'Standard Pack', what: 'playing', options: 3, picks: 1, cost: 4 },
|
||||
{ id: 'standard_jumbo', name: 'Jumbo Standard Pack', what: 'playing', options: 5, picks: 1, cost: 6 },
|
||||
{ id: 'arcana_normal', name: 'Arcana Pack', what: 'tarot', options: 3, picks: 1, cost: 4 },
|
||||
{ id: 'arcana_jumbo', name: 'Jumbo Arcana Pack', what: 'tarot', options: 5, picks: 1, cost: 6 },
|
||||
{ id: 'celestial_normal', name: 'Celestial Pack', what: 'planet', options: 3, picks: 1, cost: 4 },
|
||||
{ id: 'celestial_jumbo', name: 'Jumbo Celestial Pack', what: 'planet', options: 5, picks: 1, cost: 6 },
|
||||
{ id: 'buffoon_normal', name: 'Buffoon Pack', what: 'joker', options: 2, picks: 1, cost: 4 },
|
||||
{ id: 'buffoon_jumbo', name: 'Jumbo Buffoon Pack', what: 'joker', options: 3, picks: 1, cost: 6 },
|
||||
{ id: 'spectral_normal', name: 'Spectral Pack', what: 'spectral', options: 2, picks: 1, cost: 4 },
|
||||
{ id: 'spectral_jumbo', name: 'Jumbo Spectral Pack', what: 'spectral', options: 3, picks: 1, cost: 6 },
|
||||
];
|
||||
export const BOOSTER_BY_ID = Object.fromEntries(BOOSTERS.map((b) => [b.id, b]));
|
||||
// Weighted roll for the shop's two pack slots.
|
||||
export const BOOSTER_WEIGHTS = [
|
||||
['standard_normal', 20], ['standard_jumbo', 8],
|
||||
['arcana_normal', 20], ['arcana_jumbo', 8],
|
||||
['celestial_normal', 20], ['celestial_jumbo', 8],
|
||||
['buffoon_normal', 10], ['buffoon_jumbo', 4],
|
||||
['spectral_normal', 3], ['spectral_jumbo', 1],
|
||||
];
|
||||
|
||||
// ── Shop odds & prices ───────────────────────────────────────────────────────
|
||||
export const SHOP_CARD_WEIGHTS = { joker: 68, tarot: 16, planet: 12, playing: 4 };
|
||||
export const SHOP_SPECTRAL_WEIGHT = 8; // added for Ghost deck
|
||||
export const RARITY_WEIGHTS = { common: 62, uncommon: 30, rare: 8 };
|
||||
export const CONSUMABLE_PRICES = { tarot: 3, planet: 3, spectral: 4 };
|
||||
// Base odds that a shop joker rolls an edition (multiplied by Hone/Glow Up).
|
||||
export const SHOP_EDITION_ODDS = { foil: 0.02, holo: 0.014, poly: 0.006, negative: 0.003 };
|
||||
|
||||
// ── Boss blinds ──────────────────────────────────────────────────────────────
|
||||
// Hooks are pure helpers over run/bossState; BalatroLogic invokes them at the
|
||||
// matching points and provides `api` = { rng, discardRandom, log }.
|
||||
// cardDebuffed is evaluated lazily (every render/score) so late Wild cards etc.
|
||||
// stay correct. Transient card flags (faceDown/debuffed) reset at blind end.
|
||||
function suitDebuff(suit) {
|
||||
return (run, card, effSuits) => !card.debuffed && card.enhancement !== 'stone' && effSuits(card).includes(suit);
|
||||
}
|
||||
|
||||
export const BOSSES = [
|
||||
{ id: 'the_hook', name: 'The Hook', minAnte: 1, desc: 'Discards 2 random cards per hand played',
|
||||
afterHandPlayed: (run, api) => api.discardRandom(run, 2) },
|
||||
{ id: 'the_ox', name: 'The Ox', minAnte: 6, desc: 'Playing your most played hand sets money to $0',
|
||||
afterHandPlayed: (run, api, info) => { if (info.handType === api.mostPlayedHand(run)) run.gold = 0; } },
|
||||
{ id: 'the_house', name: 'The House', minAnte: 2, desc: 'First hand is drawn face down',
|
||||
drawTransform: (run, card, api) => { if (api.initial) card.faceDown = true; } },
|
||||
{ id: 'the_wall', name: 'The Wall', minAnte: 2, reqMult: 4, desc: 'Extra large blind' },
|
||||
{ id: 'the_wheel', name: 'The Wheel', minAnte: 2, desc: '1 in 7 cards get drawn face down',
|
||||
drawTransform: (run, card, api) => { if (api.rng.int(7) === 0) card.faceDown = true; } },
|
||||
{ id: 'the_arm', name: 'The Arm', minAnte: 2, desc: 'Decreases level of played poker hand',
|
||||
preScore: true }, // handled in scoring: level-1 (min 1) before base lookup
|
||||
{ id: 'the_club', name: 'The Club', minAnte: 1, desc: 'All Club cards are debuffed', cardDebuffed: suitDebuff('C') },
|
||||
{ id: 'the_goad', name: 'The Goad', minAnte: 1, desc: 'All Spade cards are debuffed', cardDebuffed: suitDebuff('S') },
|
||||
{ id: 'the_window', name: 'The Window', minAnte: 1, desc: 'All Diamond cards are debuffed', cardDebuffed: suitDebuff('D') },
|
||||
{ id: 'the_head', name: 'The Head', minAnte: 1, desc: 'All Heart cards are debuffed', cardDebuffed: suitDebuff('H') },
|
||||
{ id: 'the_psychic', name: 'The Psychic', minAnte: 1, desc: 'Must play 5 cards',
|
||||
validPlay: (run, uids) => (uids.length === 5 ? null : 'Must play exactly 5 cards') },
|
||||
{ id: 'the_fish', name: 'The Fish', minAnte: 2, desc: 'Cards drawn face down after each hand played',
|
||||
drawTransform: (run, card) => { if (run.bossState.handsPlayed > 0) card.faceDown = true; } },
|
||||
{ id: 'the_water', name: 'The Water', minAnte: 2, desc: 'Start with 0 discards',
|
||||
onBlindStart: (run) => { run.discardsLeft = 0; } },
|
||||
{ id: 'the_manacle', name: 'The Manacle', minAnte: 1, desc: '-1 hand size',
|
||||
handSizeDelta: -1 },
|
||||
{ id: 'the_eye', name: 'The Eye', minAnte: 3, desc: 'No repeat hand types this round',
|
||||
validPlay: (run, uids, handType) => (run.handTypesPlayedThisRound.includes(handType) ? 'No repeat hand types this round' : null) },
|
||||
{ id: 'the_mouth', name: 'The Mouth', minAnte: 2, desc: 'Only one hand type can be played this round',
|
||||
validPlay: (run, uids, handType) => {
|
||||
const played = run.handTypesPlayedThisRound;
|
||||
return (played.length && handType !== played[0]) ? `Only ${played[0]} can be played this round` : null;
|
||||
} },
|
||||
{ id: 'the_plant', name: 'The Plant', minAnte: 4, desc: 'All face cards are debuffed',
|
||||
cardDebuffed: (run, card, effSuits, isFace) => isFace(card) },
|
||||
{ id: 'the_needle', name: 'The Needle', minAnte: 2, reqMult: 1, desc: 'Play only 1 hand',
|
||||
onBlindStart: (run) => { run.handsLeft = 1; } },
|
||||
{ id: 'the_flint', name: 'The Flint', minAnte: 2, desc: 'Base Chips and Mult are halved',
|
||||
preScore: true }, // handled in scoring: halve base chips/mult (round up)
|
||||
{ id: 'the_mark', name: 'The Mark', minAnte: 2, desc: 'All face cards are drawn face down',
|
||||
drawTransform: (run, card, api) => { if (api.isFace(card)) card.faceDown = true; } },
|
||||
{ id: 'the_pillar', name: 'The Pillar', minAnte: 1, desc: 'Cards played previously this ante are debuffed',
|
||||
cardDebuffed: (run, card) => run.playedUidsThisAnte.includes(card.uid) },
|
||||
];
|
||||
|
||||
export const FINISHERS = [
|
||||
{ id: 'violet_vessel', name: 'Violet Vessel', reqMult: 6, desc: 'Very large blind' },
|
||||
{ id: 'crimson_heart', name: 'Crimson Heart', reqMult: 2, desc: 'One random Joker disabled every hand',
|
||||
onBlindStart: (run, api) => api.disableRandomJoker(run),
|
||||
afterHandPlayed: (run, api) => api.disableRandomJoker(run) },
|
||||
{ id: 'cerulean_bell', name: 'Cerulean Bell', reqMult: 2, desc: 'Forces 1 card to always be selected',
|
||||
onBlindStart: (run, api) => api.forceRandomCard(run),
|
||||
afterHandPlayed: (run, api) => api.forceRandomCard(run) },
|
||||
];
|
||||
|
||||
export const BOSS_BY_ID = Object.fromEntries([...BOSSES, ...FINISHERS].map((b) => [b.id, b]));
|
||||
|
|
@ -0,0 +1,930 @@
|
|||
// BalatroGame.js
|
||||
// Phaser scene for Balatro: view dispatch, the play table (hand, jokers,
|
||||
// consumables, score panel), and the trace-replay scoring animation. All
|
||||
// rules live in BalatroLogic/BalatroScoring — this file only renders state,
|
||||
// forwards input, and replays the event traces the engine returns.
|
||||
// Non-play views (title, deck select, blind select, shop, packs, game over)
|
||||
// live in BalatroViews.js.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import * as store from '../../services/localStore.js';
|
||||
import {
|
||||
SUIT_NAMES, RANK_NAMES, HAND_BY_ID, handBase, fmtChips, BLINDS, DECK_BY_ID,
|
||||
ENHANCEMENTS, EDITIONS, SEALS, TAROT_BY_ID, PLANET_BY_ID, SPECTRAL_BY_ID,
|
||||
VOUCHER_BY_ID, BOOSTER_BY_ID, BOSS_BY_ID,
|
||||
} from './BalatroData.js';
|
||||
import { JOKER_BY_ID } from './BalatroJokers.js';
|
||||
import { evaluate } from './BalatroScoring.js';
|
||||
import {
|
||||
newRun, serializeRun, deserializeRun, passives, cardsOf, handCards, sortHand,
|
||||
selectBlind, skipBlind, blindChipsFor, bossFor, playHand, discard, anyLegalPlay,
|
||||
forfeitRound, collectCashOut, continueEndless, leaveShop, rerollShop,
|
||||
buyShopCard, buyVoucher, sellJoker, sellConsumable, buyPack, pickFromPack,
|
||||
skipPack, useConsumable, consumableDef, isDebuffed,
|
||||
} from './BalatroLogic.js';
|
||||
import { makeSwirlBackground } from './BalatroSwirlPipeline.js';
|
||||
import {
|
||||
renderTitle, renderDeckSelect, renderBlindSelect, renderShop, renderPackOpen,
|
||||
renderGameOver, renderCashout,
|
||||
} from './BalatroViews.js';
|
||||
|
||||
const SAVE_KEY = 'balatro:run';
|
||||
const STATS_KEY = 'balatro:stats';
|
||||
|
||||
// palette
|
||||
export const C = {
|
||||
ink: '#f4efe6', muted: '#b9b2c8', dark: '#141019',
|
||||
panel: 0x1b1524, panelEdge: 0x3d3355,
|
||||
chips: 0x2f7fd4, chipsHex: '#5aa9f4', mult: 0xd4372f, multHex: '#ff6b5e',
|
||||
gold: 0xe7c14b, goldHex: '#e7c14b', money: '#f0d060',
|
||||
cardFace: 0xf6f1e6, red: '#c8342c', black: '#2b2733',
|
||||
rarity: { common: 0x4f9ad4, uncommon: 0x4fae6a, rare: 0xd44f4f },
|
||||
tarot: 0x8a5fd0, planet: 0x3f9ad0, spectral: 0x4fc8c0, voucher: 0x3fae6a,
|
||||
};
|
||||
export const SUIT_GLYPH = { S: '♠', H: '♥', D: '♦', C: '♣' };
|
||||
const SUIT_COLOR = { S: '#2b2733', C: '#2b3a63', H: '#c8342c', D: '#d07a2c' };
|
||||
|
||||
export default class BalatroGame extends Phaser.Scene {
|
||||
constructor() { super('BalatroGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data?.game ?? { slug: 'balatro', name: 'Balatro' };
|
||||
this.run = null;
|
||||
this.view = 'title';
|
||||
this.selected = [];
|
||||
this.animating = false;
|
||||
this.pendingConsumable = null;
|
||||
this.pendingJoker = null;
|
||||
this._handSprites = {};
|
||||
this._playedSprites = {};
|
||||
this._jokerSprites = {};
|
||||
this._toastText = null;
|
||||
this._recorded = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) {}
|
||||
this.art = this.cache.json.get('balatro-artwork') || {};
|
||||
|
||||
if (!this.textures.exists('balatro-spark')) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xffffff, 1); g.fillCircle(4, 4, 4);
|
||||
g.generateTexture('balatro-spark', 8, 8);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
this.bgLayer = this.add.container(0, 0);
|
||||
this.viewLayer = this.add.container(0, 0).setDepth(10);
|
||||
this.handLayer = this.add.container(0, 0).setDepth(40);
|
||||
this.fxLayer = this.add.container(0, 0).setDepth(80);
|
||||
this.swirl = makeSwirlBackground(this, this.bgLayer);
|
||||
|
||||
// Fast-forward: tapping during a scoring animation snaps to the end.
|
||||
this.input.on('pointerdown', () => { if (this.animating) this._skipAnim = true; });
|
||||
|
||||
const saved = store.read(SAVE_KEY);
|
||||
if (saved) {
|
||||
const run = deserializeRun(saved);
|
||||
if (run && run.phase !== 'over') this._savedRun = run;
|
||||
else store.write(SAVE_KEY, null);
|
||||
}
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
// ── persistence / results ─────────────────────────────────────────────────
|
||||
save() {
|
||||
if (!this.run) return;
|
||||
if (this.run.phase === 'over') { this.onRunOver(); return; }
|
||||
store.write(SAVE_KEY, serializeRun(this.run));
|
||||
}
|
||||
|
||||
onRunOver() {
|
||||
store.write(SAVE_KEY, null);
|
||||
if (this._recorded || !this.run) return;
|
||||
this._recorded = true;
|
||||
const run = this.run;
|
||||
const stats = store.read(STATS_KEY) || { wins: 0, losses: 0, runs: 0, bestAnte: 0, bestHandScore: 0, bestRoundScore: 0, winsByDeck: {} };
|
||||
stats.runs += 1;
|
||||
if (run.won) { stats.wins += 1; stats.winsByDeck[run.deckId] = (stats.winsByDeck[run.deckId] || 0) + 1; }
|
||||
else stats.losses += 1;
|
||||
stats.bestAnte = Math.max(stats.bestAnte, run.stats.bestAnte);
|
||||
stats.bestHandScore = Math.max(stats.bestHandScore, run.stats.bestHandScore);
|
||||
stats.bestRoundScore = Math.max(stats.bestRoundScore, run.stats.bestRoundScore);
|
||||
store.write(STATS_KEY, stats);
|
||||
const antesCleared = run.won ? Math.max(8, run.ante - 1) : run.ante - 1;
|
||||
api.post('/history/single-player', {
|
||||
slug: 'balatro',
|
||||
score: Math.min(100000, Math.max(0, antesCleared * 1000 + run.round)),
|
||||
opponentScores: [],
|
||||
result: run.won ? 'win' : 'loss',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
startRun(deckId) {
|
||||
this.run = newRun(deckId);
|
||||
this._recorded = false;
|
||||
this._savedRun = null;
|
||||
this.save();
|
||||
this.setView('blindselect');
|
||||
}
|
||||
|
||||
continueRun() {
|
||||
if (!this._savedRun) return;
|
||||
this.run = this._savedRun;
|
||||
this._savedRun = null;
|
||||
this._recorded = false;
|
||||
const phase = this.run.phase;
|
||||
this.setView(phase === 'playing' ? 'play' : phase === 'cashout' ? 'cashout' : phase === 'shop' ? (this.run.pack ? 'packopen' : 'shop') : 'blindselect');
|
||||
}
|
||||
|
||||
// ── view plumbing ─────────────────────────────────────────────────────────
|
||||
clearView() {
|
||||
this.viewLayer.removeAll(true);
|
||||
this.handLayer.removeAll(true);
|
||||
this.fxLayer.removeAll(true);
|
||||
this._handSprites = {};
|
||||
this._playedSprites = {};
|
||||
this._jokerSprites = {};
|
||||
this._toastText = null;
|
||||
}
|
||||
|
||||
setView(v) {
|
||||
this.view = v;
|
||||
this.selected = [];
|
||||
this.pendingConsumable = null;
|
||||
this.pendingJoker = null;
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
paletteFor() {
|
||||
if (!this.run) return this.view === 'deckselect' ? 'title' : 'title';
|
||||
if (this.view === 'gameover') return this.run.won ? 'win' : 'lose';
|
||||
if (this.view === 'shop') return 'shop';
|
||||
if (this.view === 'packopen') return 'pack';
|
||||
if (this.view === 'play' || this.view === 'cashout' || this.view === 'blindselect') {
|
||||
return this.run.blind === 'boss' ? 'boss' : this.run.blind;
|
||||
}
|
||||
return 'title';
|
||||
}
|
||||
|
||||
renderView() {
|
||||
this.clearView();
|
||||
if (this.swirl) this.swirl.setPalette(this.paletteFor());
|
||||
switch (this.view) {
|
||||
case 'title': return renderTitle(this);
|
||||
case 'deckselect': return renderDeckSelect(this);
|
||||
case 'blindselect': return renderBlindSelect(this);
|
||||
case 'play': return this.renderPlay();
|
||||
case 'cashout': { this.renderPlay(true); return renderCashout(this); }
|
||||
case 'shop': return renderShop(this);
|
||||
case 'packopen': return renderPackOpen(this);
|
||||
case 'gameover': return renderGameOver(this);
|
||||
default: return renderTitle(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ── tiny UI helpers ───────────────────────────────────────────────────────
|
||||
add2(obj, layer = this.viewLayer) { layer.add(obj); return obj; }
|
||||
|
||||
text(x, y, str, size, color = C.ink, opts = {}) {
|
||||
const t = this.add.text(x, y, str, {
|
||||
fontFamily: opts.font || '"Julius Sans One"', fontSize: `${size}px`, color,
|
||||
align: opts.align || 'left', wordWrap: opts.wrap ? { width: opts.wrap } : undefined,
|
||||
fontStyle: opts.bold ? 'bold' : 'normal',
|
||||
}).setOrigin(opts.ox ?? 0, opts.oy ?? 0);
|
||||
return this.add2(t, opts.layer);
|
||||
}
|
||||
|
||||
panel(x, y, w, h, opts = {}) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(opts.fill ?? C.panel, opts.alpha ?? 0.92);
|
||||
g.fillRoundedRect(x, y, w, h, opts.radius ?? 14);
|
||||
if (opts.stroke !== false) {
|
||||
g.lineStyle(opts.strokeW ?? 2, opts.strokeColor ?? C.panelEdge, 1);
|
||||
g.strokeRoundedRect(x, y, w, h, opts.radius ?? 14);
|
||||
}
|
||||
return this.add2(g, opts.layer);
|
||||
}
|
||||
|
||||
button(x, y, label, onClick, opts = {}) {
|
||||
const b = new Button(this, x, y, label, () => { if (!this.animating) onClick(); }, opts);
|
||||
this.add.existing(b);
|
||||
return this.add2(b, opts.layer);
|
||||
}
|
||||
|
||||
toast(msg) {
|
||||
if (this._toastText) this._toastText.destroy();
|
||||
const t = this.add.text(GAME_WIDTH / 2, 640, msg, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '30px', color: '#ffd2c8',
|
||||
backgroundColor: '#301418', padding: { x: 18, y: 10 },
|
||||
}).setOrigin(0.5).setDepth(90);
|
||||
this.fxLayer.add(t);
|
||||
this._toastText = t;
|
||||
this.tweens.add({ targets: t, alpha: 0, delay: 1600, duration: 400, onComplete: () => { if (t.active) t.destroy(); } });
|
||||
}
|
||||
|
||||
artFrame(sheetKey, mapKey, id) {
|
||||
const sheet = this.art[sheetKey];
|
||||
const map = this.art[mapKey];
|
||||
if (!sheet || !map || map[id] === undefined) return null;
|
||||
if (!this.textures.exists(sheet.key)) return null;
|
||||
return { key: sheet.key, frame: map[id] };
|
||||
}
|
||||
|
||||
// ── card rendering ────────────────────────────────────────────────────────
|
||||
// Playing card: procedural Balatro-style face + enhancement / edition / seal
|
||||
// dressing. Returns a container sized w×h centered on (x, y).
|
||||
drawPlayingCard(x, y, card, opts = {}) {
|
||||
const w = opts.w || 150, h = opts.h || 204;
|
||||
const cont = this.add.container(x, y);
|
||||
const g = this.add.graphics();
|
||||
const debuffed = opts.debuffed;
|
||||
|
||||
if (card.faceDown && !opts.reveal) {
|
||||
if (this.textures.exists('cardbacks')) {
|
||||
const img = this.add.image(0, 0, 'cardbacks', 0).setDisplaySize(w, h);
|
||||
cont.add(img);
|
||||
} else {
|
||||
g.fillStyle(0x28204a, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
g.lineStyle(3, 0x4a3f7a, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
cont.add(g);
|
||||
}
|
||||
cont.setSize(w, h);
|
||||
return cont;
|
||||
}
|
||||
|
||||
// Base face by enhancement
|
||||
const enh = card.enhancement;
|
||||
let face = C.cardFace;
|
||||
if (enh === 'stone') face = 0x7d7a74;
|
||||
if (enh === 'gold') face = 0xd8b74a;
|
||||
if (enh === 'steel') face = 0xb9c2cc;
|
||||
if (enh === 'glass') face = 0xdfeef4;
|
||||
g.fillStyle(face, enh === 'glass' ? 0.72 : 1);
|
||||
g.fillRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
if (enh === 'bonus') { g.fillStyle(0x3f7fd0, 0.25); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); }
|
||||
if (enh === 'mult') { g.fillStyle(0xd4372f, 0.22); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); }
|
||||
if (enh === 'lucky') { g.fillStyle(0x4fae6a, 0.18); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); }
|
||||
if (enh === 'wild') { g.fillGradientStyle(0xd07a2c, 0x8a5fd0, 0x3f9ad0, 0x4fae6a, 0.35); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); }
|
||||
// Edition border
|
||||
const edgeColor = card.edition === 'poly' ? 0xd05fd0 : card.edition === 'holo' ? 0x5fd0d0 : card.edition === 'foil' ? 0x5f8ad0 : 0x9a917d;
|
||||
g.lineStyle(card.edition ? 4 : 2, edgeColor, 1);
|
||||
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
cont.add(g);
|
||||
|
||||
if (enh !== 'stone') {
|
||||
const color = SUIT_COLOR[card.suit];
|
||||
const rank = this.add.text(-w / 2 + 10, -h / 2 + 6, RANK_NAMES[card.rank], {
|
||||
fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.21)}px`, color, fontStyle: 'bold',
|
||||
});
|
||||
const suitSm = this.add.text(-w / 2 + 12, -h / 2 + 8 + h * 0.21, SUIT_GLYPH[card.suit], {
|
||||
fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.16)}px`, color,
|
||||
});
|
||||
const suitBig = this.add.text(w * 0.16, h * 0.16, SUIT_GLYPH[card.suit], {
|
||||
fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.42)}px`, color,
|
||||
}).setOrigin(0.5).setAlpha(0.9);
|
||||
cont.add([rank, suitSm, suitBig]);
|
||||
if (enh === 'wild') {
|
||||
const wl = this.add.text(0, h * 0.38, 'WILD', { fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.1)}px`, color: '#5a3a10', fontStyle: 'bold' }).setOrigin(0.5);
|
||||
cont.add(wl);
|
||||
}
|
||||
} else {
|
||||
const st = this.add.text(0, 0, '◼', { fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.32)}px`, color: '#4c4a45' }).setOrigin(0.5);
|
||||
const lbl = this.add.text(0, h * 0.3, 'STONE', { fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.1)}px`, color: '#3d3b37' }).setOrigin(0.5);
|
||||
cont.add([st, lbl]);
|
||||
}
|
||||
if (card.seal) {
|
||||
const sealColor = { red: 0xd4372f, blue: 0x3f7fd0, gold: 0xd8b23a, purple: 0x8a5fd0 }[card.seal];
|
||||
const dot = this.add.circle(w / 2 - 18, -h / 2 + 18, 11, sealColor, 1).setStrokeStyle(2, 0xffffff, 0.7);
|
||||
cont.add(dot);
|
||||
}
|
||||
if (card.edition) {
|
||||
const tag = this.add.text(0, -h / 2 + 4, EDITIONS[card.edition].name.toUpperCase(), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.07)}px`, color: '#ffffff',
|
||||
}).setOrigin(0.5, 0).setAlpha(0.85);
|
||||
cont.add(tag);
|
||||
}
|
||||
if (card.permaChips) {
|
||||
const pc = this.add.text(-w / 2 + 8, h / 2 - 26, `+${card.permaChips}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.09)}px`, color: C.chipsHex,
|
||||
});
|
||||
cont.add(pc);
|
||||
}
|
||||
if (debuffed) {
|
||||
const shade = this.add.rectangle(0, 0, w, h, 0x1a1420, 0.62);
|
||||
const xg = this.add.text(0, 0, '✕', { fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.3)}px`, color: '#8a4550' }).setOrigin(0.5);
|
||||
cont.add([shade, xg]);
|
||||
}
|
||||
cont.setSize(w, h);
|
||||
return cont;
|
||||
}
|
||||
|
||||
// Joker card: art frame when supplied, else a procedural card with rarity
|
||||
// band + ability text. `inst` optional (owned joker), else def-only (shop).
|
||||
drawJokerCard(x, y, id, opts = {}) {
|
||||
const def = JOKER_BY_ID[id];
|
||||
const w = opts.w || 150, h = opts.h || 200;
|
||||
const cont = this.add.container(x, y);
|
||||
const af = this.artFrame('jokerSheet', 'jokers', id);
|
||||
if (af) {
|
||||
const img = this.add.image(0, 0, af.key, af.frame).setDisplaySize(w, h);
|
||||
cont.add(img);
|
||||
} else {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x241d33, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
g.fillStyle(C.rarity[def.rarity], 0.9); g.fillRoundedRect(-w / 2, -h / 2, w, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
|
||||
g.lineStyle(2, C.rarity[def.rarity], 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
cont.add(g);
|
||||
const name = this.add.text(0, -h / 2 + 15, def.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${Math.min(17, Math.round(280 / def.name.length))}px`, color: '#141019', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
const desc = typeof def.desc === 'function' ? def.desc(opts.inst || { state: def.initState ? def.initState() : {} }) : def.desc;
|
||||
const body = this.add.text(0, 8, desc, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.ink, align: 'center',
|
||||
wordWrap: { width: w - 16 },
|
||||
}).setOrigin(0.5);
|
||||
cont.add([name, body]);
|
||||
}
|
||||
const edition = opts.edition ?? (opts.inst && opts.inst.edition);
|
||||
if (edition) {
|
||||
const edColor = { foil: 0x5f8ad0, holo: 0x5fd0d0, poly: 0xd05fd0, negative: 0x282038 }[edition];
|
||||
const eg = this.add.graphics();
|
||||
eg.lineStyle(4, edColor, 0.95); eg.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
cont.add(eg);
|
||||
const tag = this.add.text(0, h / 2 - 16, EDITIONS[edition].name.toUpperCase(), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
|
||||
}).setOrigin(0.5).setAlpha(0.9);
|
||||
cont.add(tag);
|
||||
if (edition === 'negative') cont.setAlpha(0.8);
|
||||
}
|
||||
if (opts.inst && opts.inst.debuffedByBoss) {
|
||||
cont.add(this.add.rectangle(0, 0, w, h, 0x1a1420, 0.6));
|
||||
cont.add(this.add.text(0, 0, 'DISABLED', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#c86a6a' }).setOrigin(0.5));
|
||||
}
|
||||
cont.setSize(w, h);
|
||||
return cont;
|
||||
}
|
||||
|
||||
// Tarot / planet / spectral card (also used for shop + packs).
|
||||
drawConsumableCard(x, y, kind, id, opts = {}) {
|
||||
const w = opts.w || 150, h = opts.h || 200;
|
||||
const def = kind === 'tarot' ? TAROT_BY_ID[id] : kind === 'planet' ? PLANET_BY_ID[id] : SPECTRAL_BY_ID[id];
|
||||
const cont = this.add.container(x, y);
|
||||
const sheet = { tarot: ['tarotSheet', 'tarots'], planet: ['planetSheet', 'planets'], spectral: ['spectralSheet', 'spectrals'] }[kind];
|
||||
const af = this.artFrame(sheet[0], sheet[1], id);
|
||||
if (af) {
|
||||
cont.add(this.add.image(0, 0, af.key, af.frame).setDisplaySize(w, h));
|
||||
} else {
|
||||
const tint = { tarot: C.tarot, planet: C.planet, spectral: C.spectral }[kind];
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x18131f, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
g.lineStyle(3, tint, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
g.lineStyle(1, tint, 0.6); g.strokeRoundedRect(-w / 2 + 6, -h / 2 + 6, w - 12, h - 12, 8);
|
||||
cont.add(g);
|
||||
const name = this.add.text(0, -h / 2 + 20, def.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${Math.min(16, Math.round(300 / def.name.length))}px`,
|
||||
color: `#${tint.toString(16).padStart(6, '0')}`, fontStyle: 'bold', align: 'center', wordWrap: { width: w - 14 },
|
||||
}).setOrigin(0.5, 0);
|
||||
const descText = kind === 'planet' ? `+1 level: ${HAND_BY_ID[def.hand].name}` : def.desc;
|
||||
const body = this.add.text(0, 16, descText, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: C.ink, align: 'center', wordWrap: { width: w - 18 },
|
||||
}).setOrigin(0.5);
|
||||
cont.add([name, body]);
|
||||
const glyph = { tarot: '☽', planet: '✶', spectral: '✧' }[kind];
|
||||
cont.add(this.add.text(0, h / 2 - 22, glyph, { fontFamily: 'Georgia, serif', fontSize: '20px', color: `#${tint.toString(16).padStart(6, '0')}` }).setOrigin(0.5));
|
||||
}
|
||||
cont.setSize(w, h);
|
||||
return cont;
|
||||
}
|
||||
|
||||
// Hover tilt for any card container.
|
||||
addHoverTilt(cont, base = {}) {
|
||||
const bs = base.scale ?? 1;
|
||||
cont.setInteractive({ useHandCursor: true });
|
||||
cont.on('pointerover', () => {
|
||||
if (this.animating) return;
|
||||
this.tweens.add({ targets: cont, scale: bs * 1.07, angle: (base.angle ?? 0) + Phaser.Math.Between(-3, 3), duration: 120 });
|
||||
});
|
||||
cont.on('pointerout', () => {
|
||||
this.tweens.add({ targets: cont, scale: bs, angle: base.angle ?? 0, duration: 140 });
|
||||
});
|
||||
return cont;
|
||||
}
|
||||
|
||||
// ── PLAY VIEW ─────────────────────────────────────────────────────────────
|
||||
renderPlay(frozen = false) {
|
||||
const run = this.run;
|
||||
const p = passives(run);
|
||||
this.renderLeftPanel();
|
||||
this.renderJokerRow(frozen);
|
||||
this.renderConsumableRow(frozen);
|
||||
this.renderDeckThumb();
|
||||
this.renderHand(frozen);
|
||||
if (!frozen) this.renderPlayButtons();
|
||||
}
|
||||
|
||||
renderLeftPanel() {
|
||||
const run = this.run;
|
||||
const X = 24, W = 380;
|
||||
this.panel(X, 24, W, 1032, { alpha: 0.85 });
|
||||
|
||||
// Blind badge
|
||||
const blind = BLINDS[run.blind];
|
||||
const boss = run.blind === 'boss' ? bossFor(run) : null;
|
||||
const badgeColor = run.blind === 'boss' ? 0xc22f3a : run.blind === 'big' ? 0x8556d6 : 0x2f6fce;
|
||||
this.panel(X + 16, 40, W - 32, boss ? 168 : 128, { fill: badgeColor, alpha: 0.35, strokeColor: badgeColor });
|
||||
this.text(X + W / 2, 58, boss ? boss.name : blind.name, 30, C.ink, { ox: 0.5, bold: true });
|
||||
this.text(X + W / 2, 100, `Score at least`, 17, C.muted, { ox: 0.5 });
|
||||
this.text(X + W / 2, 124, fmtChips(run.blindChips), 32, '#ff8a6a', { ox: 0.5, bold: true });
|
||||
if (boss) this.text(X + W / 2, 166, boss.desc, 16, '#ffb9a8', { ox: 0.5, align: 'center', wrap: W - 60 });
|
||||
|
||||
// Round score
|
||||
const sy = boss ? 226 : 190;
|
||||
this.text(X + 24, sy, 'Round score', 18, C.muted);
|
||||
this._roundScoreText = this.text(X + W - 24, sy - 6, fmtChips(run.roundScore), 34, C.ink, { ox: 1, bold: true });
|
||||
|
||||
// chips × mult live boxes
|
||||
const cy = sy + 66;
|
||||
const half = (W - 64) / 2;
|
||||
this.panel(X + 24, cy, half, 64, { fill: C.chips, alpha: 0.85, stroke: false, radius: 10 });
|
||||
this.panel(X + 40 + half, cy, half, 64, { fill: C.mult, alpha: 0.85, stroke: false, radius: 10 });
|
||||
this._chipsText = this.text(X + 24 + half / 2, cy + 32, '0', 30, C.ink, { ox: 0.5, oy: 0.5, bold: true });
|
||||
this._multText = this.text(X + 40 + half * 1.5, cy + 32, '0', 30, C.ink, { ox: 0.5, oy: 0.5, bold: true });
|
||||
this.text(X + 16 + W / 2 - 8, cy + 32, '×', 30, C.muted, { ox: 0.5, oy: 0.5 });
|
||||
this._handNameText = this.text(X + W / 2, cy - 26, '', 20, C.gold ? '#e7c14b' : C.ink, { ox: 0.5 });
|
||||
|
||||
// hands / discards / gold / ante
|
||||
const iy = cy + 96;
|
||||
const stat = (label, value, x, color = C.ink) => {
|
||||
this.text(x, iy, label, 16, C.muted, { ox: 0.5 });
|
||||
return this.text(x, iy + 24, value, 30, color, { ox: 0.5, bold: true });
|
||||
};
|
||||
this._handsText = stat('Hands', String(run.handsLeft), X + 70, C.chipsHex);
|
||||
this._discardsText = stat('Discards', String(run.discardsLeft), X + 180, C.multHex);
|
||||
this._goldText = stat('Money', `$${run.gold}`, X + 300, C.money);
|
||||
const ay = iy + 78;
|
||||
this.text(X + 70, ay, 'Ante', 16, C.muted, { ox: 0.5 });
|
||||
this.text(X + 70, ay + 24, run.endless ? String(run.ante) : `${run.ante}/8`, 28, C.ink, { ox: 0.5, bold: true });
|
||||
this.text(X + 180, ay, 'Round', 16, C.muted, { ox: 0.5 });
|
||||
this.text(X + 180, ay + 24, String(run.round + 1), 28, C.ink, { ox: 0.5, bold: true });
|
||||
this.text(X + 300, ay, 'Deck', 16, C.muted, { ox: 0.5 });
|
||||
this.text(X + 300, ay + 24, DECK_BY_ID[run.deckId].name.replace(' Deck', ''), 20, C.ink, { ox: 0.5 });
|
||||
|
||||
// Hand-levels quick reference (compact)
|
||||
const hy = ay + 72;
|
||||
this.text(X + 24, hy, 'Hand levels', 16, C.muted);
|
||||
let row = 0;
|
||||
for (const [hid, lv] of Object.entries(run.handLevels)) {
|
||||
if (lv <= 1) continue;
|
||||
const hd = HAND_BY_ID[hid];
|
||||
this.text(X + 24, hy + 24 + row * 22, `${hd.name} lvl.${lv}`, 15, '#a8d8ff');
|
||||
row++;
|
||||
if (row > 7) break;
|
||||
}
|
||||
|
||||
// Options
|
||||
this.button(X + W / 2, 1006, 'Options', () => this.showOptions(), { width: 180, height: 44, fontSize: 20, variant: 'ghost' });
|
||||
}
|
||||
|
||||
showOptions() {
|
||||
const ov = this.add.container(0, 0).setDepth(95);
|
||||
this.fxLayer.add(ov);
|
||||
const shade = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive();
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(GAME_WIDTH / 2 - 240, 360, 480, 360, 16);
|
||||
g.lineStyle(2, C.panelEdge, 1); g.strokeRoundedRect(GAME_WIDTH / 2 - 240, 360, 480, 360, 16);
|
||||
ov.add([shade, g]);
|
||||
const t = this.add.text(GAME_WIDTH / 2, 400, 'Options', { fontFamily: '"Julius Sans One"', fontSize: '32px', color: C.ink }).setOrigin(0.5);
|
||||
ov.add(t);
|
||||
const mk = (y, label, cb) => {
|
||||
const b = new Button(this, GAME_WIDTH / 2, y, label, cb, { width: 320, height: 56, fontSize: 22 });
|
||||
this.add.existing(b); ov.add(b);
|
||||
};
|
||||
mk(480, 'Resume', () => ov.destroy());
|
||||
mk(560, 'Abandon Run', () => {
|
||||
ov.destroy();
|
||||
if (this.run && this.run.phase !== 'over') {
|
||||
this.run.phase = 'over';
|
||||
this.run.lostAt = { ante: this.run.ante, blind: this.run.blind };
|
||||
this.onRunOver();
|
||||
}
|
||||
this.setView('gameover');
|
||||
});
|
||||
mk(640, 'Main Menu', () => { this.scene.start('GameMenu'); });
|
||||
shade.on('pointerdown', () => ov.destroy());
|
||||
}
|
||||
|
||||
renderJokerRow(frozen) {
|
||||
const run = this.run;
|
||||
const p = passives(run);
|
||||
const w = 150, h = 200, y = 140;
|
||||
const x0 = 470;
|
||||
const maxSpan = 900;
|
||||
const n = Math.max(run.jokers.length, 1);
|
||||
const step = Math.min(w + 12, maxSpan / n);
|
||||
this.text(x0 - 10, 22, `Jokers ${run.jokers.length}/${p.jokerSlots}`, 18, C.muted);
|
||||
run.jokers.forEach((inst, i) => {
|
||||
const cx = x0 + w / 2 + i * step;
|
||||
const cont = this.drawJokerCard(cx, y, inst.id, { inst, w, h });
|
||||
this.add2(cont);
|
||||
this._jokerSprites[inst.uid] = cont;
|
||||
if (!frozen) {
|
||||
this.addHoverTilt(cont);
|
||||
cont.on('pointerdown', () => this.showJokerPanel(inst));
|
||||
this.input.setDraggable(cont);
|
||||
cont.on('drag', (ptr, dx) => { cont.x = dx; });
|
||||
cont.on('dragend', () => {
|
||||
const idx = Phaser.Math.Clamp(Math.round((cont.x - x0 - w / 2) / step), 0, run.jokers.length - 1);
|
||||
const cur = run.jokers.indexOf(inst);
|
||||
if (idx !== cur) {
|
||||
run.jokers.splice(cur, 1);
|
||||
run.jokers.splice(idx, 0, inst);
|
||||
this.save();
|
||||
}
|
||||
this.renderView();
|
||||
});
|
||||
}
|
||||
});
|
||||
for (let i = run.jokers.length; i < p.jokerSlots; i++) {
|
||||
const cx = x0 + w / 2 + i * step;
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(2, 0xffffff, 0.12); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 10);
|
||||
this.add2(g);
|
||||
}
|
||||
}
|
||||
|
||||
showJokerPanel(inst) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
this.dismissPanel();
|
||||
const cont = this.add.container(0, 0).setDepth(92);
|
||||
this.fxLayer.add(cont);
|
||||
const px = 700, py = 280, pw = 460, ph = 190;
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(px, py, pw, ph, 12);
|
||||
g.lineStyle(2, C.rarity[def.rarity], 1); g.strokeRoundedRect(px, py, pw, ph, 12);
|
||||
cont.add(g);
|
||||
const desc = typeof def.desc === 'function' ? def.desc(inst) : def.desc;
|
||||
cont.add(this.add.text(px + 20, py + 14, `${def.name}${inst.edition ? ` (${EDITIONS[inst.edition].name})` : ''}`, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.ink }));
|
||||
cont.add(this.add.text(px + 20, py + 52, desc, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, wordWrap: { width: pw - 40 } }));
|
||||
const sell = new Button(this, px + pw - 110, py + ph - 40, `Sell $${inst.sellValue}`, () => {
|
||||
const r = sellJoker(this.run, inst.uid);
|
||||
if (r.ok) { playSound(this, SFX.COINS); this.save(); this.renderView(); }
|
||||
}, { width: 170, height: 48, fontSize: 20 });
|
||||
this.add.existing(sell); cont.add(sell);
|
||||
const close = new Button(this, px + 90, py + ph - 40, 'Close', () => this.dismissPanel(), { width: 130, height: 48, fontSize: 20, variant: 'ghost' });
|
||||
this.add.existing(close); cont.add(close);
|
||||
this._panel = cont;
|
||||
}
|
||||
|
||||
dismissPanel() { if (this._panel) { this._panel.destroy(); this._panel = null; } }
|
||||
|
||||
renderConsumableRow(frozen) {
|
||||
const run = this.run;
|
||||
const p = passives(run);
|
||||
const w = 130, h = 176, y = 140;
|
||||
const x0 = 1470;
|
||||
this.text(x0 - 4, 22, `Consumables ${run.consumables.length}/${p.consumableSlots}`, 18, C.muted);
|
||||
run.consumables.forEach((inst, i) => {
|
||||
const cx = x0 + w / 2 + i * (w + 10);
|
||||
const cont = this.drawConsumableCard(cx, y, inst.kind, inst.id, { w, h });
|
||||
this.add2(cont);
|
||||
if (!frozen) {
|
||||
this.addHoverTilt(cont);
|
||||
cont.on('pointerdown', () => this.showConsumablePanel(inst));
|
||||
}
|
||||
});
|
||||
for (let i = run.consumables.length; i < p.consumableSlots; i++) {
|
||||
const cx = x0 + w / 2 + i * (w + 10);
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(2, 0xffffff, 0.12); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 10);
|
||||
this.add2(g);
|
||||
}
|
||||
}
|
||||
|
||||
showConsumablePanel(inst) {
|
||||
const def = consumableDef(inst);
|
||||
this.dismissPanel();
|
||||
const cont = this.add.container(0, 0).setDepth(92);
|
||||
this.fxLayer.add(cont);
|
||||
const px = 1230, py = 260, pw = 470, ph = 210;
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(px, py, pw, ph, 12);
|
||||
const tint = { tarot: C.tarot, planet: C.planet, spectral: C.spectral }[inst.kind];
|
||||
g.lineStyle(2, tint, 1); g.strokeRoundedRect(px, py, pw, ph, 12);
|
||||
cont.add(g);
|
||||
cont.add(this.add.text(px + 20, py + 14, def.name, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.ink }));
|
||||
const descText = inst.kind === 'planet' ? `+1 level: ${HAND_BY_ID[def.hand].name}` : def.desc;
|
||||
cont.add(this.add.text(px + 20, py + 52, descText, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, wordWrap: { width: pw - 40 } }));
|
||||
const needs = def.targets || 0;
|
||||
if (needs) cont.add(this.add.text(px + 20, py + ph - 96, `Select up to ${needs} card${needs > 1 ? 's' : ''} in hand first`, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#a8d8ff' }));
|
||||
const use = new Button(this, px + pw - 110, py + ph - 40, 'Use', () => {
|
||||
const r = useConsumable(this.run, inst.uid, this.selected.slice(0, Math.max(needs, 2)));
|
||||
if (!r.ok) { this.toast(r.error); return; }
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
if (r.note) this.toast(r.note);
|
||||
this.dismissPanel();
|
||||
this.selected = [];
|
||||
this.save();
|
||||
this.renderView();
|
||||
}, { width: 140, height: 48, fontSize: 20 });
|
||||
this.add.existing(use); cont.add(use);
|
||||
const sellV = Math.max(1, Math.floor(({ tarot: 3, planet: 3, spectral: 4 })[inst.kind] / 2));
|
||||
const sell = new Button(this, px + pw - 270, py + ph - 40, `Sell $${sellV}`, () => {
|
||||
const r = sellConsumable(this.run, inst.uid);
|
||||
if (r.ok) { playSound(this, SFX.COINS); this.dismissPanel(); this.save(); this.renderView(); }
|
||||
}, { width: 140, height: 48, fontSize: 20, variant: 'ghost' });
|
||||
this.add.existing(sell); cont.add(sell);
|
||||
const close = new Button(this, px + 85, py + ph - 40, 'Close', () => this.dismissPanel(), { width: 120, height: 48, fontSize: 20, variant: 'ghost' });
|
||||
this.add.existing(close); cont.add(close);
|
||||
this._panel = cont;
|
||||
}
|
||||
|
||||
renderDeckThumb() {
|
||||
const run = this.run;
|
||||
const x = 1840, y = 150, w = 110, h = 148;
|
||||
const deck = DECK_BY_ID[run.deckId];
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(deck.color, 0.9); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
||||
g.lineStyle(2, 0xffffff, 0.4); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
|
||||
this.add2(g);
|
||||
this.text(x, y + h / 2 + 14, `${run.drawPile.length}/${run.deck.length}`, 18, C.ink, { ox: 0.5 });
|
||||
}
|
||||
|
||||
renderHand(frozen) {
|
||||
const run = this.run;
|
||||
const hand = handCards(run);
|
||||
const w = 150, h = 204;
|
||||
const centerX = 1160, y = 880;
|
||||
const maxSpan = 1300;
|
||||
const step = hand.length > 1 ? Math.min(w * 0.72, (maxSpan - w) / (hand.length - 1)) : 0;
|
||||
const x0 = centerX - ((hand.length - 1) * step) / 2;
|
||||
hand.forEach((card, i) => {
|
||||
const selected = this.selected.includes(card.uid);
|
||||
const cx = x0 + i * step;
|
||||
const cy = y - (selected ? 44 : 0);
|
||||
const cont = this.drawPlayingCard(cx, cy, card, { w, h, debuffed: isDebuffed(run, card) });
|
||||
const tilt = (i - (hand.length - 1) / 2) * 1.6;
|
||||
cont.setAngle(tilt);
|
||||
this.add2(cont, this.handLayer);
|
||||
this._handSprites[card.uid] = cont;
|
||||
if (selected) {
|
||||
const glow = this.add.graphics();
|
||||
glow.lineStyle(4, 0xe7c14b, 0.95); glow.strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
cont.add(glow);
|
||||
}
|
||||
if (run.bossState && run.bossState.forcedUid === card.uid) {
|
||||
const lock = this.add.text(0, -h / 2 - 20, '⛓ forced', { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#ff8a6a' }).setOrigin(0.5);
|
||||
cont.add(lock);
|
||||
}
|
||||
if (!frozen) {
|
||||
cont.setInteractive({ useHandCursor: true });
|
||||
cont.on('pointerover', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy - 16, duration: 100 }); });
|
||||
cont.on('pointerout', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy, duration: 120 }); });
|
||||
cont.on('pointerdown', () => this.toggleSelect(card.uid));
|
||||
}
|
||||
});
|
||||
this.updateHandPreview();
|
||||
}
|
||||
|
||||
toggleSelect(uid) {
|
||||
if (this.animating) return;
|
||||
const run = this.run;
|
||||
if (run.bossState && run.bossState.forcedUid === uid && this.selected.includes(uid)) {
|
||||
this.toast('That card is forced by the Boss Blind');
|
||||
return;
|
||||
}
|
||||
const i = this.selected.indexOf(uid);
|
||||
if (i >= 0) this.selected.splice(i, 1);
|
||||
else {
|
||||
if (this.selected.length >= 5) { this.toast('Up to 5 cards'); return; }
|
||||
this.selected.push(uid);
|
||||
}
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
// Re-render just the hand + preview (cheap enough to redo the view).
|
||||
this.handLayer.removeAll(true);
|
||||
this._handSprites = {};
|
||||
this.renderHand(false);
|
||||
}
|
||||
|
||||
updateHandPreview() {
|
||||
if (!this._handNameText || !this._handNameText.active) return;
|
||||
const run = this.run;
|
||||
if (!this.selected.length) {
|
||||
this._handNameText.setText('');
|
||||
if (this._chipsText.active) this._chipsText.setText('0');
|
||||
if (this._multText.active) this._multText.setText('0');
|
||||
return;
|
||||
}
|
||||
const p = passives(run);
|
||||
const { handType } = evaluate(cardsOf(run, this.selected), p);
|
||||
const lv = run.handLevels[handType] || 1;
|
||||
const base = handBase(handType, lv);
|
||||
this._handNameText.setText(`${HAND_BY_ID[handType].name} lvl.${lv}`);
|
||||
this._chipsText.setText(fmtChips(base.chips));
|
||||
this._multText.setText(String(base.mult));
|
||||
}
|
||||
|
||||
renderPlayButtons() {
|
||||
const run = this.run;
|
||||
const y = 1030;
|
||||
this.button(980, y, 'Play Hand', () => this.doPlay(), { width: 240, height: 58, fontSize: 24, bg: 0x2f6fce });
|
||||
this.button(1250, y, `Discard (${run.discardsLeft})`, () => this.doDiscard(), { width: 240, height: 58, fontSize: 24, bg: 0x8c2f2f });
|
||||
this.button(1540, y, 'Rank', () => { sortHand(run, 'rank'); this.save(); this.renderView(); }, { width: 110, height: 46, fontSize: 18, variant: 'ghost' });
|
||||
this.button(1665, y, 'Suit', () => { sortHand(run, 'suit'); this.save(); this.renderView(); }, { width: 110, height: 46, fontSize: 18, variant: 'ghost' });
|
||||
if (!anyLegalPlay(run) && run.discardsLeft === 0) {
|
||||
this.button(1830, y, 'Forfeit', () => {
|
||||
forfeitRound(run);
|
||||
this.onRunOver();
|
||||
this.setView('gameover');
|
||||
}, { width: 140, height: 46, fontSize: 18, bg: 0x5a2f3a });
|
||||
}
|
||||
}
|
||||
|
||||
doDiscard() {
|
||||
if (!this.selected.length) { this.toast('Select cards to discard'); return; }
|
||||
const r = discard(this.run, this.selected.slice());
|
||||
if (!r.ok) { this.toast(r.error); return; }
|
||||
playSound(this, SFX.CARD_DEAL);
|
||||
this.selected = [];
|
||||
this.save();
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
doPlay() {
|
||||
if (!this.selected.length) { this.toast('Select cards to play'); return; }
|
||||
const run = this.run;
|
||||
const uids = run.hand.filter((u) => this.selected.includes(u));
|
||||
const playedCards = cardsOf(run, uids).map((c) => ({ ...c })); // snapshot for display
|
||||
const res = playHand(run, uids);
|
||||
if (!res.ok) { this.toast(res.error); return; }
|
||||
this.selected = [];
|
||||
this.save();
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.animateScoring(playedCards, res);
|
||||
}
|
||||
|
||||
// ── scoring animation: pure playback of the trace ─────────────────────────
|
||||
animateScoring(playedCards, res) {
|
||||
this.animating = true;
|
||||
this._skipAnim = false;
|
||||
this.dismissPanel();
|
||||
|
||||
// Freeze the table (re-render without input), lift played cards to center.
|
||||
this.clearView();
|
||||
this.renderLeftPanel();
|
||||
this.renderJokerRow(true);
|
||||
this.renderConsumableRow(true);
|
||||
this.renderDeckThumb();
|
||||
|
||||
// Hand without the played cards
|
||||
this.renderHand(true);
|
||||
|
||||
// Played row
|
||||
const w = 150, h = 204;
|
||||
const centerX = 1160, py = 480;
|
||||
const step = Math.min(w + 22, 900 / Math.max(playedCards.length, 1));
|
||||
const x0 = centerX - ((playedCards.length - 1) * step) / 2;
|
||||
this._playedSprites = {};
|
||||
const scoringSet = new Set(res.trace.scoringUids);
|
||||
playedCards.forEach((card, i) => {
|
||||
const cont = this.drawPlayingCard(x0 + i * step, py, card, { w, h, reveal: true });
|
||||
if (!scoringSet.has(card.uid)) cont.setAlpha(0.45);
|
||||
this.add2(cont);
|
||||
this._playedSprites[card.uid] = cont;
|
||||
});
|
||||
|
||||
const events = res.trace.events;
|
||||
const stepMs = Phaser.Math.Clamp(Math.round(2600 / Math.max(events.length, 1)), 70, 240);
|
||||
let idx = 0;
|
||||
|
||||
const findSprite = (source) => {
|
||||
if (!source) return null;
|
||||
if (source.kind === 'card' || source.kind === 'seal') return this._playedSprites[source.uid] || this._handSprites[source.uid] || null;
|
||||
if (source.kind === 'joker') {
|
||||
if (source.jokerUid && this._jokerSprites[source.jokerUid]) return this._jokerSprites[source.jokerUid];
|
||||
if (source.cardUid) return this._playedSprites[source.cardUid] || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const floater = (sprite, textStr, color) => {
|
||||
const x = sprite ? this.fxX(sprite) : 980;
|
||||
const y = sprite ? this.fxY(sprite) - 80 : 400;
|
||||
const t = this.add.text(x, y, textStr, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '34px', color, fontStyle: 'bold',
|
||||
stroke: '#141019', strokeThickness: 5,
|
||||
}).setOrigin(0.5).setDepth(85);
|
||||
this.fxLayer.add(t);
|
||||
this.tweens.add({ targets: t, y: y - 56, alpha: 0, duration: 700, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
|
||||
};
|
||||
|
||||
const pop = (sprite, big = false) => {
|
||||
if (!sprite || !sprite.active) return;
|
||||
this.tweens.add({ targets: sprite, scale: big ? 1.2 : 1.1, duration: 90, yoyo: true });
|
||||
};
|
||||
|
||||
const applyEvent = (ev, silent = false) => {
|
||||
if (this._chipsText && this._chipsText.active) this._chipsText.setText(fmtChips(ev.chipsAfter));
|
||||
if (this._multText && this._multText.active) this._multText.setText(String(Math.round(ev.multAfter * 100) / 100));
|
||||
if (silent) return;
|
||||
const sprite = findSprite(ev.source);
|
||||
switch (ev.t) {
|
||||
case 'base':
|
||||
if (this._handNameText && this._handNameText.active) this._handNameText.setText(`${HAND_BY_ID[ev.handType].name} lvl.${ev.level}`);
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
break;
|
||||
case 'chips': floater(sprite, `+${fmtChips(ev.v)}`, C.chipsHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break;
|
||||
case 'mult': floater(sprite, `+${ev.v} Mult`, C.multHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break;
|
||||
case 'xmult':
|
||||
floater(sprite, `×${ev.v}`, '#ffa24f'); pop(sprite, true);
|
||||
playSound(this, SFX.SCIFI_PLINK);
|
||||
if (ev.v >= 2) this.cameras.main.shake(120, 0.004);
|
||||
break;
|
||||
case 'money': floater(sprite, `+$${ev.v}`, C.money); playSound(this, SFX.COINS); if (this._goldText && this._goldText.active) this._goldText.setText(`$${this.run.gold}`); break;
|
||||
case 'retrigger': if (sprite) this.ringPulse(sprite); break;
|
||||
case 'destroy': if (sprite) this.shatter(sprite); break;
|
||||
case 'jokerState': if (sprite) { pop(sprite); if (ev.text) floater(sprite, ev.text, '#d0c8ff'); } break;
|
||||
case 'info': if (ev.text) floater(sprite, ev.text, '#d0c8ff'); break;
|
||||
case 'total': break;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
const totalEv = events[events.length - 1];
|
||||
applyEvent(totalEv, true);
|
||||
const score = res.trace.score;
|
||||
// Slam: big score text at the played row.
|
||||
const slam = this.add.text(centerX, py - 170, fmtChips(score), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '72px', color: '#ffffff', fontStyle: 'bold',
|
||||
stroke: '#c8342c', strokeThickness: 8,
|
||||
}).setOrigin(0.5).setScale(1.6).setAlpha(0).setDepth(86);
|
||||
this.fxLayer.add(slam);
|
||||
this.tweens.add({ targets: slam, alpha: 1, scale: 1, duration: 180, ease: 'Back.easeOut' });
|
||||
this.cameras.main.shake(160, score >= this.run.blindChips ? 0.008 : 0.004);
|
||||
playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK);
|
||||
if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore));
|
||||
|
||||
this.time.delayedCall(this._skipAnim ? 250 : 900, () => {
|
||||
this.animating = false;
|
||||
if (res.destroyedJokers && res.destroyedJokers.length) {
|
||||
this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`);
|
||||
}
|
||||
if (res.outcome === 'blindWon') {
|
||||
playSound(this, SFX.CASINO_WIN);
|
||||
this.setView('cashout');
|
||||
} else if (res.outcome === 'runLost') {
|
||||
playSound(this, SFX.CASINO_LOSE);
|
||||
this.onRunOver();
|
||||
this.setView('gameover');
|
||||
} else {
|
||||
this.setView('play');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (this._skipAnim) {
|
||||
// Snap: apply remaining counter states instantly.
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (idx >= events.length) { finish(); return; }
|
||||
const ev = events[idx++];
|
||||
applyEvent(ev);
|
||||
this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
fxX(sprite) { let x = sprite.x, p = sprite.parentContainer; while (p) { x += p.x; p = p.parentContainer; } return x; }
|
||||
fxY(sprite) { let y = sprite.y, p = sprite.parentContainer; while (p) { y += p.y; p = p.parentContainer; } return y; }
|
||||
|
||||
ringPulse(sprite) {
|
||||
const x = this.fxX(sprite), y = this.fxY(sprite);
|
||||
const ring = this.add.circle(x, y, 30, 0xffffff, 0).setStrokeStyle(4, 0xffffff, 0.9).setDepth(84);
|
||||
this.fxLayer.add(ring);
|
||||
this.tweens.add({ targets: ring, radius: 110, alpha: 0, duration: 380, onComplete: () => ring.destroy() });
|
||||
}
|
||||
|
||||
shatter(sprite) {
|
||||
const x = this.fxX(sprite), y = this.fxY(sprite);
|
||||
if (sprite.active) sprite.setAlpha(0.25);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const shard = this.add.rectangle(x, y, 14, 20, 0xdfeef4, 0.9).setDepth(84).setAngle(Phaser.Math.Between(0, 360));
|
||||
this.fxLayer.add(shard);
|
||||
this.tweens.add({
|
||||
targets: shard,
|
||||
x: x + Phaser.Math.Between(-140, 140), y: y + Phaser.Math.Between(-60, 180),
|
||||
angle: shard.angle + Phaser.Math.Between(-240, 240), alpha: 0,
|
||||
duration: 520, ease: 'Cubic.easeOut', onComplete: () => shard.destroy(),
|
||||
});
|
||||
}
|
||||
playSound(this, SFX.SCIFI_PLONK);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
// BalatroJokers.js
|
||||
// Joker definitions: data rows + effect hooks, consumed by BalatroScoring
|
||||
// (scoring-time hooks) and BalatroLogic (economy/round hooks).
|
||||
//
|
||||
// Hook taxonomy — all optional per joker:
|
||||
// onScored(ctx, j, card, index) per scored card, per trigger
|
||||
// onHeld(ctx, j, card) per held card, per trigger
|
||||
// independent(ctx, j) joker-loop step (chips/mult/xmult)
|
||||
// cardRetriggers(ctx, j, card, i)→n extra triggers for a scored card
|
||||
// heldRetriggers(ctx, j, card)→n extra triggers for a held card
|
||||
// onHandPlayed(ctx, j) state update BEFORE this joker's independent
|
||||
// afterHand(ctx, j) state update after the whole hand (decay)
|
||||
// onDiscard(run, j, cards, info) info = { isFirst, api }
|
||||
// onBlindStart(run, j, api) after boss setup, before first draw
|
||||
// onBlindSelected(run, j, blind, api) small/big blind chosen (Madness)
|
||||
// onCashOut(run, j)→$ extra money line at round end
|
||||
// onRoundEnd(run, j, api)→{destroyed?} decay / self-destruct rolls
|
||||
// onPackSkipped / onBossDefeated / onCardAdded / onConsumableUsed(run, j, ...)
|
||||
// passive(p, j, run) folded into BalatroLogic.passives()
|
||||
// isDead(j)→bool removed by Logic when true
|
||||
// copyable:false Blueprint/Brainstorm resolve to no-op
|
||||
//
|
||||
// Copies (Blueprint/Brainstorm) read the TARGET's state and run only
|
||||
// scoring-time hooks + onCashOut; state-updating hooks fire once, on the
|
||||
// original, so counters never double-tick.
|
||||
|
||||
import { rankChips, isFaceRank } from './BalatroData.js';
|
||||
|
||||
const C = 'common', U = 'uncommon', R = 'rare';
|
||||
|
||||
// ── Roster ───────────────────────────────────────────────────────────────────
|
||||
export const JOKERS = [
|
||||
// Flat +Mult
|
||||
{ id: 'joker', name: 'Joker', rarity: C, cost: 2, desc: '+4 Mult',
|
||||
independent: (ctx) => ctx.mult(4, sj('joker')) },
|
||||
...[['greedy', 'Greedy Joker', 'D', 'Diamond'], ['lusty', 'Lusty Joker', 'H', 'Heart'],
|
||||
['wrathful', 'Wrathful Joker', 'S', 'Spade'], ['gluttonous', 'Gluttonous Joker', 'C', 'Club']]
|
||||
.map(([id, name, suit, sn]) => ({
|
||||
id, name, rarity: C, cost: 5, desc: `Played cards with ${sn} suit give +3 Mult when scored`,
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, suit)) ctx.mult(3, sj(id, card)); } })),
|
||||
...[['jolly', 'Jolly Joker', 'pair', 8, 3], ['zany', 'Zany Joker', 'threeofakind', 12, 4],
|
||||
['mad', 'Mad Joker', 'twopair', 10, 4], ['crazy', 'Crazy Joker', 'straight', 12, 4],
|
||||
['droll', 'Droll Joker', 'flush', 10, 4]]
|
||||
.map(([id, name, want, amt, cost]) => ({
|
||||
id, name, rarity: C, cost, desc: `+${amt} Mult if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'twopair' ? 'Two Pair' : want[0].toUpperCase() + want.slice(1)}`,
|
||||
independent: (ctx) => { if (ctx.contains(want)) ctx.mult(amt, sj(id)); } })),
|
||||
{ id: 'halfjoker', name: 'Half Joker', rarity: C, cost: 5, desc: '+20 Mult if played hand contains 3 or fewer cards',
|
||||
independent: (ctx) => { if (ctx.playedCards.length <= 3) ctx.mult(20, sj('halfjoker')); } },
|
||||
{ id: 'misprint', name: 'Misprint', rarity: C, cost: 4, desc: '+0 to +23 Mult',
|
||||
independent: (ctx) => ctx.mult(ctx.rng.int(24), sj('misprint')) },
|
||||
|
||||
// Flat +Chips
|
||||
...[['sly', 'Sly Joker', 'pair', 50, 3], ['wily', 'Wily Joker', 'threeofakind', 100, 4],
|
||||
['clever', 'Clever Joker', 'twopair', 80, 4], ['devious', 'Devious Joker', 'straight', 100, 4],
|
||||
['crafty', 'Crafty Joker', 'flush', 80, 4]]
|
||||
.map(([id, name, want, amt, cost]) => ({
|
||||
id, name, rarity: C, cost, desc: `+${amt} Chips if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'twopair' ? 'Two Pair' : want[0].toUpperCase() + want.slice(1)}`,
|
||||
independent: (ctx) => { if (ctx.contains(want)) ctx.chips(amt, sj(id)); } })),
|
||||
{ id: 'banner', name: 'Banner', rarity: C, cost: 5, desc: '+30 Chips per remaining discard',
|
||||
independent: (ctx) => ctx.chips(30 * ctx.run.discardsLeft, sj('banner')) },
|
||||
{ id: 'stuntman', name: 'Stuntman', rarity: R, cost: 7, desc: '+250 Chips, -2 hand size',
|
||||
independent: (ctx) => ctx.chips(250, sj('stuntman')),
|
||||
passive: (p) => { p.handSize -= 2; } },
|
||||
|
||||
// Per-card onScored
|
||||
{ id: 'fibonacci', name: 'Fibonacci', rarity: U, cost: 8, desc: 'Each played Ace, 2, 3, 5 or 8 gives +8 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if ([14, 2, 3, 5, 8].includes(card.rank) && card.enhancement !== 'stone') ctx.mult(8, sj('fibonacci', card)); } },
|
||||
{ id: 'scaryface', name: 'Scary Face', rarity: C, cost: 4, desc: 'Played face cards give +30 Chips when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.isFace(card)) ctx.chips(30, sj('scaryface', card)); } },
|
||||
{ id: 'evensteven', name: 'Even Steven', rarity: C, cost: 4, desc: 'Played cards with even rank give +4 Mult when scored (10, 8, 6, 4, 2)',
|
||||
onScored: (ctx, j, card) => { if (card.rank <= 10 && card.rank % 2 === 0 && card.enhancement !== 'stone') ctx.mult(4, sj('evensteven', card)); } },
|
||||
{ id: 'oddtodd', name: 'Odd Todd', rarity: C, cost: 4, desc: 'Played cards with odd rank give +31 Chips when scored (A, 9, 7, 5, 3)',
|
||||
onScored: (ctx, j, card) => { if ((card.rank === 14 || (card.rank <= 9 && card.rank % 2 === 1)) && card.enhancement !== 'stone') ctx.chips(31, sj('oddtodd', card)); } },
|
||||
{ id: 'scholar', name: 'Scholar', rarity: C, cost: 4, desc: 'Played Aces give +20 Chips and +4 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (card.rank === 14 && card.enhancement !== 'stone') { ctx.chips(20, sj('scholar', card)); ctx.mult(4, sj('scholar', card)); } } },
|
||||
{ id: 'smileyface', name: 'Smiley Face', rarity: C, cost: 4, desc: 'Played face cards give +5 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.isFace(card)) ctx.mult(5, sj('smileyface', card)); } },
|
||||
{ id: 'walkietalkie', name: 'Walkie Talkie', rarity: C, cost: 4, desc: 'Each played 10 or 4 gives +10 Chips and +4 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (card.rank === 10 || card.rank === 4) { ctx.chips(10, sj('walkietalkie', card)); ctx.mult(4, sj('walkietalkie', card)); } } },
|
||||
{ id: 'roughgem', name: 'Rough Gem', rarity: U, cost: 7, desc: 'Played Diamonds earn $1 when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'D')) ctx.money(1, sj('roughgem', card)); } },
|
||||
{ id: 'bloodstone', name: 'Bloodstone', rarity: U, cost: 7, desc: '1 in 2 chance for played Hearts to give x1.5 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'H') && ctx.prob(2)) ctx.xmult(1.5, sj('bloodstone', card)); } },
|
||||
{ id: 'arrowhead', name: 'Arrowhead', rarity: U, cost: 7, desc: 'Played Spades give +50 Chips when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'S')) ctx.chips(50, sj('arrowhead', card)); } },
|
||||
{ id: 'onyxagate', name: 'Onyx Agate', rarity: U, cost: 7, desc: 'Played Clubs give +7 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'C')) ctx.mult(7, sj('onyxagate', card)); } },
|
||||
|
||||
// xMult
|
||||
{ id: 'blackboard', name: 'Blackboard', rarity: U, cost: 6, desc: 'x3 Mult if all cards held in hand are Spades or Clubs',
|
||||
independent: (ctx) => {
|
||||
const ok = ctx.heldCards.every((c) => ctx.hasSuit(c, 'S') || ctx.hasSuit(c, 'C'));
|
||||
if (ok) ctx.xmult(3, sj('blackboard'));
|
||||
} },
|
||||
{ id: 'baron', name: 'Baron', rarity: R, cost: 8, desc: 'Each King held in hand gives x1.5 Mult',
|
||||
onHeld: (ctx, j, card) => { if (card.rank === 13 && card.enhancement !== 'stone') ctx.xmult(1.5, sj('baron', card)); } },
|
||||
{ id: 'photograph', name: 'Photograph', rarity: C, cost: 5, desc: 'First played face card gives x2 Mult when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.isFace(card) && !ctx.flags.photograph) { ctx.flags.photograph = true; ctx.xmult(2, sj('photograph', card)); } } },
|
||||
{ id: 'ancient', name: 'Ancient Joker', rarity: R, cost: 8, initState: (rng) => ({ suit: 'SHDC'[rng ? rng.int(4) : 0] }),
|
||||
desc: (j) => `Each played card with ${suitName(j.state.suit)} suit gives x1.5 Mult when scored (suit changes every round)`,
|
||||
onScored: (ctx, j, card) => { if (ctx.hasSuit(card, j.state.suit)) ctx.xmult(1.5, sj('ancient', card)); },
|
||||
onRoundEnd: (run, j, api) => { j.state.suit = 'SHDC'[api.rng.int(4)]; } },
|
||||
{ id: 'cavendish', name: 'Cavendish', rarity: C, cost: 4, desc: 'x3 Mult; 1 in 1000 chance it is destroyed at end of round',
|
||||
independent: (ctx) => ctx.xmult(3, sj('cavendish')),
|
||||
onRoundEnd: (run, j, api) => (api.prob(1000) ? { destroyed: true } : null) },
|
||||
{ id: 'cardsharp', name: 'Card Sharp', rarity: U, cost: 6, desc: 'x3 Mult if played hand type was already played this round',
|
||||
independent: (ctx) => { if (ctx.run.handTypesPlayedThisRound.includes(ctx.handType)) ctx.xmult(3, sj('cardsharp')); } },
|
||||
{ id: 'acrobat', name: 'Acrobat', rarity: U, cost: 6, desc: 'x3 Mult on the final hand of the round',
|
||||
independent: (ctx) => { if (ctx.run.handsLeft === 1) ctx.xmult(3, sj('acrobat')); } },
|
||||
...[['duo', 'The Duo', 'pair', 2], ['trio', 'The Trio', 'threeofakind', 3],
|
||||
['family', 'The Family', 'fourofakind', 4], ['order', 'The Order', 'straight', 3],
|
||||
['tribe', 'The Tribe', 'flush', 2]]
|
||||
.map(([id, name, want, x]) => ({
|
||||
id, name, rarity: R, cost: 8, desc: `x${x} Mult if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'fourofakind' ? 'Four of a Kind' : want[0].toUpperCase() + want.slice(1)}`,
|
||||
independent: (ctx) => { if (ctx.contains(want)) ctx.xmult(x, sj(id)); } })),
|
||||
{ id: 'flowerpot', name: 'Flower Pot', rarity: U, cost: 6, desc: 'x3 Mult if the scoring hand contains a Diamond, Club, Heart and Spade',
|
||||
independent: (ctx) => {
|
||||
const need = ['D', 'C', 'H', 'S'];
|
||||
const cards = ctx.scoredCards.filter((c) => c.enhancement !== 'stone');
|
||||
const wilds = cards.filter((c) => c.enhancement === 'wild').length;
|
||||
const suits = new Set(cards.filter((c) => c.enhancement !== 'wild').map((c) => c.suit));
|
||||
if (suits.size + Math.min(wilds, need.length - suits.size) >= 4) ctx.xmult(3, sj('flowerpot'));
|
||||
} },
|
||||
|
||||
// Scaling
|
||||
{ id: 'runner', name: 'Runner', rarity: C, cost: 5, initState: () => ({ chips: 0 }),
|
||||
desc: (j) => `Gains +15 Chips if played hand contains a Straight (now +${j.state.chips} Chips)`,
|
||||
onHandPlayed: (ctx, j) => { if (ctx.contains('straight')) { j.state.chips += 15; ctx.emit({ t: 'jokerState', source: sj('runner'), text: '+15' }); } },
|
||||
independent: (ctx, j) => ctx.chips(j.state.chips, sj('runner')) },
|
||||
{ id: 'icecream', name: 'Ice Cream', rarity: C, cost: 5, initState: () => ({ chips: 100 }),
|
||||
desc: (j) => `+${j.state.chips} Chips; -5 Chips per hand played`,
|
||||
independent: (ctx, j) => ctx.chips(j.state.chips, sj('icecream')),
|
||||
afterHand: (ctx, j) => { j.state.chips -= 5; ctx.emit({ t: 'jokerState', source: sj('icecream'), text: '-5' }); },
|
||||
isDead: (j) => j.state.chips <= 0 },
|
||||
{ id: 'bluejoker', name: 'Blue Joker', rarity: C, cost: 5, desc: '+2 Chips per remaining card in the draw pile',
|
||||
independent: (ctx) => ctx.chips(2 * ctx.run.drawPile.length, sj('bluejoker')) },
|
||||
{ id: 'greenjoker', name: 'Green Joker', rarity: C, cost: 4, initState: () => ({ n: 0 }),
|
||||
desc: (j) => `+1 Mult per hand played, -1 Mult per discard (now +${j.state.n})`,
|
||||
onHandPlayed: (ctx, j) => { j.state.n += 1; ctx.emit({ t: 'jokerState', source: sj('greenjoker'), text: '+1' }); },
|
||||
independent: (ctx, j) => ctx.mult(j.state.n, sj('greenjoker')),
|
||||
onDiscard: (run, j) => { j.state.n = Math.max(0, j.state.n - 1); } },
|
||||
{ id: 'ridethebus', name: 'Ride the Bus', rarity: C, cost: 6, initState: () => ({ n: 0 }),
|
||||
desc: (j) => `Gains +1 Mult per consecutive hand played without a scoring face card (now +${j.state.n})`,
|
||||
onHandPlayed: (ctx, j) => {
|
||||
const face = ctx.scoredCards.some((c) => ctx.isFace(c));
|
||||
j.state.n = face ? 0 : j.state.n + 1;
|
||||
ctx.emit({ t: 'jokerState', source: sj('ridethebus'), text: face ? 'Reset' : '+1' });
|
||||
},
|
||||
independent: (ctx, j) => ctx.mult(j.state.n, sj('ridethebus')) },
|
||||
{ id: 'squarejoker', name: 'Square Joker', rarity: C, cost: 4, initState: () => ({ chips: 0 }),
|
||||
desc: (j) => `Gains +4 Chips if played hand has exactly 4 cards (now +${j.state.chips})`,
|
||||
onHandPlayed: (ctx, j) => { if (ctx.playedCards.length === 4) { j.state.chips += 4; ctx.emit({ t: 'jokerState', source: sj('squarejoker'), text: '+4' }); } },
|
||||
independent: (ctx, j) => ctx.chips(j.state.chips, sj('squarejoker')) },
|
||||
{ id: 'supernova', name: 'Supernova', rarity: C, cost: 5, desc: 'Adds the number of times the played hand type has been played this run to Mult',
|
||||
independent: (ctx) => ctx.mult((ctx.run.handPlayCounts[ctx.handType] || 0) + 1, sj('supernova')) },
|
||||
{ id: 'obelisk', name: 'Obelisk', rarity: R, cost: 8, initState: () => ({ x: 1 }),
|
||||
desc: (j) => `x${j.state.x.toFixed(1)} Mult per consecutive hand played that is not your most played hand`,
|
||||
onHandPlayed: (ctx, j) => {
|
||||
const counts = ctx.run.handPlayCounts;
|
||||
const max = Math.max(0, ...Object.values(counts));
|
||||
const isMost = (counts[ctx.handType] || 0) >= max && max > 0;
|
||||
j.state.x = isMost ? 1 : Math.round((j.state.x + 0.2) * 10) / 10;
|
||||
ctx.emit({ t: 'jokerState', source: sj('obelisk'), text: isMost ? 'Reset' : '+0.2' });
|
||||
},
|
||||
independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('obelisk')); } },
|
||||
{ id: 'constellation', name: 'Constellation', rarity: U, cost: 6, initState: () => ({ x: 1 }),
|
||||
desc: (j) => `Gains x0.1 Mult per Planet card used (now x${j.state.x.toFixed(1)})`,
|
||||
onConsumableUsed: (run, j, kind) => { if (kind === 'planet') j.state.x = Math.round((j.state.x + 0.1) * 10) / 10; },
|
||||
independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('constellation')); } },
|
||||
{ id: 'hologram', name: 'Hologram', rarity: U, cost: 7, initState: () => ({ x: 1 }),
|
||||
desc: (j) => `Gains x0.25 Mult per playing card added to your deck (now x${j.state.x.toFixed(2)})`,
|
||||
onCardAdded: (run, j) => { j.state.x = Math.round((j.state.x + 0.25) * 100) / 100; },
|
||||
independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('hologram')); } },
|
||||
{ id: 'vampire', name: 'Vampire', rarity: U, cost: 7, initState: () => ({ x: 1 }),
|
||||
desc: (j) => `Gains x0.1 Mult per scoring enhanced card; removes the enhancement (now x${j.state.x.toFixed(1)})`,
|
||||
onScored: (ctx, j, card) => {
|
||||
if (card.enhancement && card.enhancement !== 'stone') {
|
||||
card.enhancement = null;
|
||||
j.state.x = Math.round((j.state.x + 0.1) * 10) / 10;
|
||||
ctx.emit({ t: 'jokerState', source: sj('vampire', card), text: '+0.1' });
|
||||
}
|
||||
},
|
||||
independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('vampire')); } },
|
||||
{ id: 'madness', name: 'Madness', rarity: U, cost: 7, initState: () => ({ x: 1 }),
|
||||
desc: (j) => `When a Small or Big Blind is selected, gains x0.5 Mult and destroys a random other Joker (now x${j.state.x.toFixed(1)})`,
|
||||
onBlindSelected: (run, j, blind, api) => {
|
||||
if (blind === 'boss') return;
|
||||
j.state.x = Math.round((j.state.x + 0.5) * 10) / 10;
|
||||
api.destroyRandomOtherJoker(run, j);
|
||||
},
|
||||
independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('madness')); } },
|
||||
{ id: 'castle', name: 'Castle', rarity: U, cost: 6, initState: (rng) => ({ suit: 'SHDC'[rng ? rng.int(4) : 0], chips: 0 }),
|
||||
desc: (j) => `Gains +3 Chips per discarded ${suitName(j.state.suit)} card (now +${j.state.chips}; suit changes every round)`,
|
||||
onDiscard: (run, j, cards, info) => { j.state.chips += 3 * cards.filter((c) => info.api.hasSuit(c, j.state.suit)).length; },
|
||||
independent: (ctx, j) => ctx.chips(j.state.chips, sj('castle')),
|
||||
onRoundEnd: (run, j, api) => { j.state.suit = 'SHDC'[api.rng.int(4)]; } },
|
||||
{ id: 'weejoker', name: 'Wee Joker', rarity: R, cost: 8, initState: () => ({ chips: 0 }),
|
||||
desc: (j) => `Gains +8 Chips when each played 2 is scored (now +${j.state.chips})`,
|
||||
onScored: (ctx, j, card) => { if (card.rank === 2 && card.enhancement !== 'stone') { j.state.chips += 8; ctx.emit({ t: 'jokerState', source: sj('weejoker', card), text: '+8' }); } },
|
||||
independent: (ctx, j) => ctx.chips(j.state.chips, sj('weejoker')) },
|
||||
{ id: 'erosion', name: 'Erosion', rarity: U, cost: 6, desc: '+4 Mult for each card below your starting deck size',
|
||||
independent: (ctx) => ctx.mult(4 * Math.max(0, ctx.run.startDeckSize - ctx.run.deck.length), sj('erosion')) },
|
||||
{ id: 'hiker', name: 'Hiker', rarity: U, cost: 5, desc: 'Every played card permanently gains +5 Chips when scored',
|
||||
onScored: (ctx, j, card) => { card.permaChips = (card.permaChips || 0) + 5; } },
|
||||
{ id: 'ramen', name: 'Ramen', rarity: U, cost: 6, initState: () => ({ x: 2 }),
|
||||
desc: (j) => `x${j.state.x.toFixed(2)} Mult; loses x0.01 per card discarded`,
|
||||
independent: (ctx, j) => ctx.xmult(j.state.x, sj('ramen')),
|
||||
onDiscard: (run, j, cards) => { j.state.x = Math.round((j.state.x - 0.01 * cards.length) * 100) / 100; },
|
||||
isDead: (j) => j.state.x <= 1 },
|
||||
{ id: 'popcorn', name: 'Popcorn', rarity: C, cost: 5, initState: () => ({ mult: 20 }),
|
||||
desc: (j) => `+${j.state.mult} Mult; -4 Mult per round played`,
|
||||
independent: (ctx, j) => ctx.mult(j.state.mult, sj('popcorn')),
|
||||
onRoundEnd: (run, j) => { j.state.mult -= 4; },
|
||||
isDead: (j) => j.state.mult <= 0 },
|
||||
{ id: 'throwback', name: 'Throwback', rarity: U, cost: 6, desc: 'x0.25 Mult for each blind skipped this run',
|
||||
independent: (ctx) => { const n = ctx.run.stats.blindsSkipped; if (n > 0) ctx.xmult(1 + 0.25 * n, sj('throwback')); } },
|
||||
|
||||
// Economy
|
||||
{ id: 'goldenjoker', name: 'Golden Joker', rarity: C, cost: 6, desc: 'Earn $4 at end of round',
|
||||
onCashOut: () => 4 },
|
||||
{ id: 'delayedgrat', name: 'Delayed Gratification', rarity: C, cost: 4, desc: 'Earn $2 per discard if no discards are used by end of round',
|
||||
onCashOut: (run) => (run.discardsUsedThisRound === 0 ? 2 * run.discardsLeft : 0) },
|
||||
{ id: 'businesscard', name: 'Business Card', rarity: C, cost: 4, desc: 'Played face cards have a 1 in 2 chance to give $2 when scored',
|
||||
onScored: (ctx, j, card) => { if (ctx.isFace(card) && ctx.prob(2)) ctx.money(2, sj('businesscard', card)); } },
|
||||
{ id: 'faceless', name: 'Faceless Joker', rarity: C, cost: 4, desc: 'Earn $5 if 3 or more face cards are discarded at the same time',
|
||||
onDiscard: (run, j, cards, info) => { if (cards.filter((c) => info.api.isFace(c)).length >= 3) run.gold += 5; } },
|
||||
{ id: 'cloud9', name: 'Cloud 9', rarity: U, cost: 7, desc: 'Earn $1 for each 9 in your full deck at end of round',
|
||||
onCashOut: (run) => run.deck.filter((c) => c.rank === 9 && c.enhancement !== 'stone').length },
|
||||
{ id: 'rocket', name: 'Rocket', rarity: U, cost: 6, initState: () => ({ payout: 1 }),
|
||||
desc: (j) => `Earn $${j.state.payout} at end of round; payout increases by $2 when a Boss Blind is defeated`,
|
||||
onCashOut: (run, j) => j.state.payout,
|
||||
onBossDefeated: (run, j) => { j.state.payout += 2; } },
|
||||
{ id: 'egg', name: 'Egg', rarity: C, cost: 4, copyable: false, desc: 'Gains $3 of sell value at end of round',
|
||||
onRoundEnd: (run, j) => { j.sellValue += 3; } },
|
||||
{ id: 'tothemoon', name: 'To the Moon', rarity: U, cost: 5, desc: 'Earn an extra $1 of interest for every $5 you have at end of round',
|
||||
passive: (p) => { p.interestPerFive += 1; } },
|
||||
{ id: 'satellite', name: 'Satellite', rarity: U, cost: 6, desc: 'Earn $1 at end of round per unique Planet card used this run',
|
||||
onCashOut: (run) => run.stats.planetsUsedSet.length },
|
||||
{ id: 'bull', name: 'Bull', rarity: U, cost: 6, desc: '+2 Chips for each $1 you have',
|
||||
independent: (ctx) => ctx.chips(2 * Math.max(0, ctx.run.gold), sj('bull')) },
|
||||
{ id: 'bootstraps', name: 'Bootstraps', rarity: U, cost: 7, desc: '+2 Mult for every $5 you have',
|
||||
independent: (ctx) => ctx.mult(2 * Math.floor(Math.max(0, ctx.run.gold) / 5), sj('bootstraps')) },
|
||||
{ id: 'goldenticket', name: 'Golden Ticket', rarity: C, cost: 5, desc: 'Played Gold cards earn $4 when scored',
|
||||
onScored: (ctx, j, card) => { if (card.enhancement === 'gold') ctx.money(4, sj('goldenticket', card)); } },
|
||||
{ id: 'redcard', name: 'Red Card', rarity: C, cost: 5, desc: '+3 Mult for each Booster Pack skipped this run',
|
||||
independent: (ctx) => ctx.mult(3 * ctx.run.stats.packsSkipped, sj('redcard')) },
|
||||
{ id: 'swashbuckler', name: 'Swashbuckler', rarity: C, cost: 4, desc: 'Adds the sell value of all other owned Jokers to Mult',
|
||||
independent: (ctx, j) => ctx.mult(ctx.run.jokers.filter((o) => o.uid !== j.uid).reduce((s, o) => s + o.sellValue, 0), sj('swashbuckler')) },
|
||||
{ id: 'fortuneteller', name: 'Fortune Teller', rarity: C, cost: 6, desc: '+1 Mult per Tarot card used this run',
|
||||
independent: (ctx) => ctx.mult(ctx.run.stats.tarotsUsed, sj('fortuneteller')) },
|
||||
{ id: 'tradingcard', name: 'Trading Card', rarity: U, cost: 6, desc: 'If the first discard of a round has only 1 card, destroy it and earn $3',
|
||||
onDiscard: (run, j, cards, info) => {
|
||||
if (info.isFirst && cards.length === 1) { info.api.destroyCards(run, [cards[0].uid]); run.gold += 3; }
|
||||
} },
|
||||
|
||||
// Retriggers
|
||||
{ id: 'hack', name: 'Hack', rarity: U, cost: 6, desc: 'Retrigger each played 2, 3, 4 or 5',
|
||||
cardRetriggers: (ctx, j, card) => ([2, 3, 4, 5].includes(card.rank) && card.enhancement !== 'stone' ? 1 : 0) },
|
||||
{ id: 'dusk', name: 'Dusk', rarity: U, cost: 5, desc: 'Retrigger all played cards on the final hand of the round',
|
||||
cardRetriggers: (ctx) => (ctx.run.handsLeft === 1 ? 1 : 0) },
|
||||
{ id: 'sockandbuskin', name: 'Sock and Buskin', rarity: U, cost: 6, desc: 'Retrigger all played face cards',
|
||||
cardRetriggers: (ctx, j, card) => (ctx.isFace(card) ? 1 : 0) },
|
||||
{ id: 'seltzer', name: 'Seltzer', rarity: U, cost: 6, initState: () => ({ n: 10 }),
|
||||
desc: (j) => `Retrigger all played cards for the next ${j.state.n} hands`,
|
||||
cardRetriggers: () => 1,
|
||||
afterHand: (ctx, j) => { j.state.n -= 1; },
|
||||
isDead: (j) => j.state.n <= 0 },
|
||||
{ id: 'hangingchad', name: 'Hanging Chad', rarity: C, cost: 4, desc: 'Retrigger the first played card 2 additional times',
|
||||
cardRetriggers: (ctx, j, card, i) => (i === 0 ? 2 : 0) },
|
||||
{ id: 'mime', name: 'Mime', rarity: U, cost: 5, desc: 'Retrigger all card abilities held in hand',
|
||||
heldRetriggers: () => 1 },
|
||||
|
||||
// Utility / negative-space
|
||||
{ id: 'juggler', name: 'Juggler', rarity: C, cost: 4, desc: '+1 hand size',
|
||||
passive: (p) => { p.handSize += 1; } },
|
||||
{ id: 'drunkard', name: 'Drunkard', rarity: C, cost: 4, desc: '+1 discard every round',
|
||||
passive: (p) => { p.discards += 1; } },
|
||||
{ id: 'troubadour', name: 'Troubadour', rarity: U, cost: 6, desc: '+2 hand size, -1 hand every round',
|
||||
passive: (p) => { p.handSize += 2; p.hands -= 1; } },
|
||||
{ id: 'merryandy', name: 'Merry Andy', rarity: U, cost: 7, desc: '+3 discards every round, -1 hand size',
|
||||
passive: (p) => { p.discards += 3; p.handSize -= 1; } },
|
||||
{ id: 'turtlebean', name: 'Turtle Bean', rarity: U, cost: 6, initState: () => ({ n: 5 }),
|
||||
desc: (j) => `+${j.state.n} hand size; reduces by 1 every round`,
|
||||
passive: (p, j) => { p.handSize += j.state.n; },
|
||||
onRoundEnd: (run, j) => { j.state.n -= 1; },
|
||||
isDead: (j) => j.state.n <= 0 },
|
||||
{ id: 'burglar', name: 'Burglar', rarity: U, cost: 6, desc: 'When a Blind starts, gain 3 extra hands but lose all discards',
|
||||
onBlindStart: (run) => { run.handsLeft += 3; run.discardsLeft = 0; } },
|
||||
{ id: 'creditcard', name: 'Credit Card', rarity: C, cost: 1, desc: 'Go up to -$20 in debt',
|
||||
passive: (p) => { p.creditFloor = -20; } },
|
||||
{ id: 'chaostheclown', name: 'Chaos the Clown', rarity: C, cost: 4, desc: '1 free reroll per shop',
|
||||
passive: (p) => { p.freeRerolls += 1; } },
|
||||
{ id: 'oops', name: 'Oops! All 6s', rarity: U, cost: 4, desc: 'Doubles all listed probabilities (e.g. 1 in 4 becomes 2 in 4)',
|
||||
passive: (p) => { p.oops = true; } },
|
||||
{ id: 'pareidolia', name: 'Pareidolia', rarity: U, cost: 5, desc: 'All cards are considered face cards',
|
||||
passive: (p) => { p.pareidolia = true; } },
|
||||
{ id: 'splash', name: 'Splash', rarity: C, cost: 3, desc: 'Every played card counts in scoring',
|
||||
passive: (p) => { p.splash = true; } },
|
||||
{ id: 'smeared', name: 'Smeared Joker', rarity: U, cost: 7, desc: 'Hearts and Diamonds count as the same suit; Spades and Clubs count as the same suit',
|
||||
passive: (p) => { p.smeared = true; } },
|
||||
{ id: 'shortcut', name: 'Shortcut', rarity: U, cost: 7, desc: 'Straights can be made with gaps of 1 rank',
|
||||
passive: (p) => { p.shortcut = true; } },
|
||||
{ id: 'fourfingers', name: 'Four Fingers', rarity: U, cost: 7, desc: 'All Flushes and Straights can be made with 4 cards',
|
||||
passive: (p) => { p.fourFingers = true; } },
|
||||
{ id: 'stencil', name: 'Joker Stencil', rarity: U, cost: 8, desc: 'x1 Mult for each empty Joker slot (Joker Stencil included)',
|
||||
independent: (ctx) => {
|
||||
const empty = Math.max(0, ctx.passives.jokerSlots - ctx.run.jokers.length) + 1;
|
||||
if (empty > 1) ctx.xmult(empty, sj('stencil'));
|
||||
} },
|
||||
{ id: 'eightball', name: '8 Ball', rarity: C, cost: 5, desc: 'Each played 8 has a 1 in 4 chance to create a Tarot card when scored',
|
||||
onScored: (ctx, j, card) => { if (card.rank === 8 && card.enhancement !== 'stone' && ctx.prob(4)) ctx.createConsumable('tarot', sj('eightball', card)); } },
|
||||
{ id: 'superposition', name: 'Superposition', rarity: C, cost: 4, desc: 'Create a Tarot card if played hand contains an Ace and a Straight',
|
||||
afterHand: (ctx) => {
|
||||
const straight = ctx.handType === 'straight' || ctx.handType === 'straightflush';
|
||||
if (straight && ctx.playedCards.some((c) => c.rank === 14 && c.enhancement !== 'stone')) ctx.createConsumable('tarot', sj('superposition'));
|
||||
} },
|
||||
{ id: 'vagabond', name: 'Vagabond', rarity: R, cost: 8, desc: 'Create a Tarot card if the hand is played with $4 or less',
|
||||
afterHand: (ctx) => { if (ctx.run.gold <= 4) ctx.createConsumable('tarot', sj('vagabond')); } },
|
||||
{ id: 'cartomancer', name: 'Cartomancer', rarity: U, cost: 8, desc: 'Create a Tarot card when a Blind is selected',
|
||||
onBlindStart: (run, j, api) => api.createConsumable(run, 'tarot') },
|
||||
{ id: 'mysticsummit', name: 'Mystic Summit', rarity: C, cost: 5, desc: '+15 Mult when 0 discards remain',
|
||||
independent: (ctx) => { if (ctx.run.discardsLeft === 0) ctx.mult(15, sj('mysticsummit')); } },
|
||||
{ id: 'loyaltycard', name: 'Loyalty Card', rarity: U, cost: 5, initState: () => ({ n: 5 }),
|
||||
desc: (j) => `x4 Mult every 6 hands played (${j.state.n} remaining)`,
|
||||
independent: (ctx, j) => { if (j.state.n === 0) ctx.xmult(4, sj('loyaltycard')); },
|
||||
afterHand: (ctx, j) => { j.state.n = j.state.n === 0 ? 5 : j.state.n - 1; } },
|
||||
{ id: 'raisedfist', name: 'Raised Fist', rarity: C, cost: 5, desc: 'Adds double the rank value of the lowest ranked card held in hand to Mult',
|
||||
independent: (ctx) => {
|
||||
const held = ctx.heldCards.filter((c) => c.enhancement !== 'stone');
|
||||
if (!held.length) return;
|
||||
const low = held.reduce((m, c) => (c.rank < m.rank ? c : m));
|
||||
ctx.mult(2 * rankChips(low.rank), sj('raisedfist', low));
|
||||
} },
|
||||
{ id: 'shootthemoon', name: 'Shoot the Moon', rarity: C, cost: 5, desc: 'Each Queen held in hand gives +13 Mult',
|
||||
onHeld: (ctx, j, card) => { if (card.rank === 12 && card.enhancement !== 'stone') ctx.mult(13, sj('shootthemoon', card)); } },
|
||||
{ id: 'abstract', name: 'Abstract Joker', rarity: C, cost: 4, desc: '+3 Mult for each Joker you own',
|
||||
independent: (ctx) => ctx.mult(3 * ctx.run.jokers.length, sj('abstract')) },
|
||||
{ id: 'grosmichel', name: 'Gros Michel', rarity: C, cost: 5, desc: '+15 Mult; 1 in 6 chance it is destroyed at end of round',
|
||||
independent: (ctx) => ctx.mult(15, sj('grosmichel')),
|
||||
onRoundEnd: (run, j, api) => (api.prob(6) ? { destroyed: true } : null) },
|
||||
{ id: 'stonejoker', name: 'Stone Joker', rarity: U, cost: 6, desc: '+25 Chips for each Stone Card in your full deck',
|
||||
independent: (ctx) => ctx.chips(25 * ctx.run.deck.filter((c) => c.enhancement === 'stone').length, sj('stonejoker')) },
|
||||
{ id: 'baseball', name: 'Baseball Card', rarity: R, cost: 8, desc: 'Each Uncommon Joker you own gives x1.5 Mult',
|
||||
independent: (ctx, j) => {
|
||||
for (const o of ctx.run.jokers) {
|
||||
if (o.uid === j.uid) continue;
|
||||
const d = JOKER_BY_ID[o.id];
|
||||
if (d && d.rarity === U) ctx.xmult(1.5, { kind: 'joker', id: 'baseball', jokerUid: j.uid, via: o.id });
|
||||
}
|
||||
} },
|
||||
|
||||
// Copiers
|
||||
{ id: 'dna', name: 'DNA', rarity: R, cost: 8, desc: 'If the first hand of the round has only 1 card, add a permanent copy of it to your deck and draw it to hand',
|
||||
afterHand: (ctx) => {
|
||||
if (ctx.run.handsPlayedThisRound === 0 && ctx.playedCards.length === 1) {
|
||||
const src = ctx.playedCards[0];
|
||||
const copy = { ...src, uid: ctx.run.uidCounter++, faceDown: false, debuffed: false };
|
||||
ctx.run.deck.push(copy);
|
||||
ctx.run.hand.push(copy.uid);
|
||||
ctx.run.stats.cardsAddedToDeck += 1;
|
||||
ctx.emit({ t: 'info', source: sj('dna'), text: 'Copied card' });
|
||||
}
|
||||
} },
|
||||
{ id: 'blueprint', name: 'Blueprint', rarity: R, cost: 10, copyable: false, desc: 'Copies the ability of the Joker to the right' },
|
||||
{ id: 'brainstorm', name: 'Brainstorm', rarity: R, cost: 10, copyable: false, desc: 'Copies the ability of the leftmost Joker' },
|
||||
];
|
||||
|
||||
function sj(id, card) {
|
||||
const s = { kind: 'joker', id };
|
||||
if (card) s.cardUid = card.uid;
|
||||
return s;
|
||||
}
|
||||
function suitName(s) { return { S: 'Spade', H: 'Heart', D: 'Diamond', C: 'Club' }[s]; }
|
||||
|
||||
export const JOKER_BY_ID = Object.fromEntries(JOKERS.map((j) => [j.id, j]));
|
||||
|
||||
export function jokerPool(rarity) { return JOKERS.filter((j) => j.rarity === rarity); }
|
||||
|
||||
export function makeJokerInstance(id, uid, rng, edition = null) {
|
||||
const def = JOKER_BY_ID[id];
|
||||
return {
|
||||
id, uid, edition,
|
||||
sellValue: Math.max(1, Math.floor((def.cost + (edition ? ({ foil: 2, holo: 3, poly: 5, negative: 5 })[edition] : 0)) / 2)),
|
||||
state: def.initState ? def.initState(rng) : {},
|
||||
debuffedByBoss: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Blueprint/Brainstorm resolution: each owned joker maps to the definition it
|
||||
// acts as. Copies read the target's state (hookInst) but keep their own
|
||||
// edition/debuff (inst). Cycle-guarded; non-copyable targets resolve to no-op.
|
||||
export function resolveEffectiveJokers(run) {
|
||||
return run.jokers.map((inst, idx) => {
|
||||
let def = JOKER_BY_ID[inst.id];
|
||||
let hookInst = inst;
|
||||
let i = idx;
|
||||
const visited = new Set([idx]);
|
||||
while (def && (def.id === 'blueprint' || def.id === 'brainstorm')) {
|
||||
const ni = def.id === 'blueprint' ? i + 1 : 0;
|
||||
if (ni >= run.jokers.length || visited.has(ni)) { def = null; break; }
|
||||
visited.add(ni);
|
||||
i = ni;
|
||||
hookInst = run.jokers[ni];
|
||||
def = JOKER_BY_ID[hookInst.id];
|
||||
}
|
||||
const isCopy = hookInst !== inst;
|
||||
if (isCopy && def && def.copyable === false) def = null;
|
||||
return { inst, hookInst, def: def || {}, isCopy };
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,968 @@
|
|||
// BalatroLogic.js
|
||||
// Pure (no Phaser) run engine for Balatro: seeded serializable RNG, the ante /
|
||||
// blind state machine, hand play + discard, cash out, shop (cards, vouchers,
|
||||
// booster packs, rerolls), consumable resolution, boss-blind hooks, endless
|
||||
// mode and save/load. BalatroGame renders state and forwards input here;
|
||||
// tools/verifyBalatro.js drives it headless.
|
||||
//
|
||||
// Every public mutator restores the RNG from run.rngState on entry and writes
|
||||
// it back on exit, so a serialized run resumes deterministically.
|
||||
|
||||
import {
|
||||
SUITS, DEFAULTS, HAND_TYPES, DECK_BY_ID, DECKS, anteBase, BLINDS, isFaceRank,
|
||||
TAROTS, PLANETS, SPECTRALS, TAROT_BY_ID, PLANET_BY_ID, SPECTRAL_BY_ID, PLANET_BY_HAND,
|
||||
VOUCHERS, VOUCHER_BY_ID, VOUCHER_COST, BOOSTERS, BOOSTER_BY_ID, BOOSTER_WEIGHTS,
|
||||
SHOP_CARD_WEIGHTS, SHOP_SPECTRAL_WEIGHT, RARITY_WEIGHTS, CONSUMABLE_PRICES,
|
||||
SHOP_EDITION_ODDS, BOSSES, FINISHERS, BOSS_BY_ID, EDITIONS,
|
||||
} from './BalatroData.js';
|
||||
import { JOKERS, JOKER_BY_ID, jokerPool, makeJokerInstance, resolveEffectiveJokers } from './BalatroJokers.js';
|
||||
import { scorePlayedHand, evaluate, cardHasSuit } from './BalatroScoring.js';
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
// ── RNG (mulberry32, seedable & serializable) ────────────────────────────────
|
||||
export function makeRng(seed) {
|
||||
let s = (seed >>> 0) || 1;
|
||||
const fn = () => {
|
||||
s |= 0; s = (s + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
fn.int = (n) => Math.floor(fn() * n);
|
||||
fn.pick = (arr) => arr[fn.int(arr.length)];
|
||||
fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||||
fn.getState = () => s >>> 0;
|
||||
fn.setState = (v) => { s = (v >>> 0) || 1; };
|
||||
return fn;
|
||||
}
|
||||
|
||||
function rngOf(run) { const r = makeRng(1); r.setState(run.rngState); return r; }
|
||||
function saveRng(run, rng) { run.rngState = rng.getState(); }
|
||||
function weighted(rng, pairs) {
|
||||
const total = pairs.reduce((s, [, w]) => s + w, 0);
|
||||
let roll = rng() * total;
|
||||
for (const [v, w] of pairs) { roll -= w; if (roll < 0) return v; }
|
||||
return pairs[pairs.length - 1][0];
|
||||
}
|
||||
|
||||
// ── Run construction ─────────────────────────────────────────────────────────
|
||||
export function newRun(deckId, seed = (Math.random() * 1e9) | 0) {
|
||||
const deckDef = DECK_BY_ID[deckId] || DECK_BY_ID.red;
|
||||
const mods = deckDef.mods || {};
|
||||
const run = {
|
||||
v: SAVE_VERSION,
|
||||
seed, rngState: 0,
|
||||
deckId: deckDef.id,
|
||||
uidCounter: 1,
|
||||
ante: 1, blind: 'small', phase: 'blindselect', round: 0,
|
||||
gold: DEFAULTS.startGold + (mods.gold || 0),
|
||||
won: false, endless: false, pendingWin: false, lostAt: null,
|
||||
deck: [], drawPile: [], hand: [],
|
||||
handsLeft: 0, discardsLeft: 0, handSizePenalty: 0,
|
||||
discardsUsedThisRound: 0, firstDiscardDone: false,
|
||||
handsPlayedThisRound: 0, handTypesPlayedThisRound: [],
|
||||
handLevels: Object.fromEntries(HAND_TYPES.map((h) => [h.id, 1])),
|
||||
handPlayCounts: {},
|
||||
jokers: [], consumables: [], vouchers: [],
|
||||
bossId: null, lastBossId: null, bossState: {}, anteVoucherId: null,
|
||||
playedUidsThisAnte: [],
|
||||
shop: null, pack: null,
|
||||
lastConsumable: null,
|
||||
sortMode: 'rank',
|
||||
roundScore: 0, blindChips: 0, cashout: null,
|
||||
startDeckSize: 52,
|
||||
stats: {
|
||||
bestHandScore: 0, bestRoundScore: 0, bestAnte: 1,
|
||||
tarotsUsed: 0, planetsUsed: 0, planetsUsedSet: [], spectralsUsed: 0,
|
||||
blindsSkipped: 0, packsSkipped: 0, cardsAddedToDeck: 0, handsPlayed: 0,
|
||||
},
|
||||
};
|
||||
const rng = makeRng(seed);
|
||||
for (const suit of SUITS) {
|
||||
for (let rank = 2; rank <= 14; rank++) {
|
||||
run.deck.push({ uid: run.uidCounter++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false });
|
||||
}
|
||||
}
|
||||
for (const v of mods.startVouchers || []) run.vouchers.push(v);
|
||||
for (const c of mods.startConsumables || []) run.consumables.push({ kind: c.kind, id: c.id, uid: run.uidCounter++ });
|
||||
saveRng(run, rng);
|
||||
beginAnte(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
export function serializeRun(run) { return JSON.stringify(run); }
|
||||
export function deserializeRun(json) {
|
||||
try {
|
||||
const run = JSON.parse(json);
|
||||
if (!run || run.v !== SAVE_VERSION || !Array.isArray(run.deck)) return null;
|
||||
return run;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ── Derived passives ─────────────────────────────────────────────────────────
|
||||
// Always recomputed, never stored: buying/selling jokers or vouchers is
|
||||
// trivially consistent.
|
||||
export function passives(run) {
|
||||
const mods = (DECK_BY_ID[run.deckId] || {}).mods || {};
|
||||
const p = {
|
||||
handSize: DEFAULTS.handSize - run.handSizePenalty,
|
||||
hands: DEFAULTS.hands + (mods.hands || 0),
|
||||
discards: DEFAULTS.discards + (mods.discards || 0),
|
||||
jokerSlots: DEFAULTS.jokerSlots + (mods.jokerSlots || 0),
|
||||
consumableSlots: DEFAULTS.consumableSlots + (mods.consumableSlots || 0),
|
||||
freeRerolls: 0, creditFloor: 0,
|
||||
oops: false, pareidolia: false, splash: false, smeared: false, shortcut: false, fourFingers: false,
|
||||
interestPerFive: 1, interestCap: DEFAULTS.interestCap,
|
||||
rerollOff: 0, discount: 0, editionOdds: 1, shopCards: 2, telescope: false,
|
||||
noInterest: !!mods.noInterest, unusedHand: mods.unusedHand || 0, unusedDiscard: mods.unusedDiscard || 0,
|
||||
shopSpectral: !!mods.shopSpectral,
|
||||
};
|
||||
for (const vid of run.vouchers) {
|
||||
const v = VOUCHER_BY_ID[vid];
|
||||
if (!v) continue;
|
||||
const m = v.mods;
|
||||
if (m.shopCards) p.shopCards += m.shopCards;
|
||||
if (m.discount) p.discount = Math.max(p.discount, m.discount);
|
||||
if (m.editionOdds) p.editionOdds *= m.editionOdds;
|
||||
if (m.rerollOff) p.rerollOff += m.rerollOff;
|
||||
if (m.hands) p.hands += m.hands;
|
||||
if (m.discards) p.discards += m.discards;
|
||||
if (m.interestCap) p.interestCap = Math.max(p.interestCap, m.interestCap);
|
||||
if (m.consumableSlots) p.consumableSlots += m.consumableSlots;
|
||||
if (m.telescope) p.telescope = true;
|
||||
}
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.passive) def.passive(p, inst, run);
|
||||
if (inst.edition === 'negative') p.jokerSlots += 1;
|
||||
}
|
||||
if (run.phase === 'playing' && run.blind === 'boss') {
|
||||
const boss = BOSS_BY_ID[run.bossId];
|
||||
if (boss && boss.handSizeDelta) p.handSize += boss.handSizeDelta;
|
||||
}
|
||||
p.handSize = Math.max(1, p.handSize);
|
||||
p.hands = Math.max(1, p.hands);
|
||||
p.discards = Math.max(0, p.discards);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── Small shared helpers ─────────────────────────────────────────────────────
|
||||
export function cardsOf(run, uids) {
|
||||
const byUid = new Map(run.deck.map((c) => [c.uid, c]));
|
||||
return uids.map((u) => byUid.get(u)).filter(Boolean);
|
||||
}
|
||||
export function handCards(run) { return cardsOf(run, run.hand); }
|
||||
|
||||
function effSuitsOf(run, p) {
|
||||
return (card) => {
|
||||
if (card.enhancement === 'stone') return [];
|
||||
if (card.enhancement === 'wild') return ['S', 'H', 'D', 'C'];
|
||||
if (p.smeared) return (card.suit === 'H' || card.suit === 'D') ? ['H', 'D'] : ['S', 'C'];
|
||||
return [card.suit];
|
||||
};
|
||||
}
|
||||
|
||||
export function isDebuffed(run, card) {
|
||||
if (run.phase !== 'playing' || run.blind !== 'boss') return false;
|
||||
const boss = BOSS_BY_ID[run.bossId];
|
||||
if (!boss || !boss.cardDebuffed) return false;
|
||||
const p = passives(run);
|
||||
return !!boss.cardDebuffed(run, card, effSuitsOf(run, p), (c) => c.enhancement !== 'stone' && (p.pareidolia || isFaceRank(c.rank)));
|
||||
}
|
||||
|
||||
function makeApi(run, rng, p) {
|
||||
const api = {
|
||||
rng,
|
||||
initial: false,
|
||||
prob: (n) => rng() < Math.min(1, (p.oops ? 2 : 1) / n),
|
||||
isFace: (c) => c.enhancement !== 'stone' && (p.pareidolia || isFaceRank(c.rank)),
|
||||
hasSuit: (c, suit) => cardHasSuit(c, suit, p.smeared),
|
||||
mostPlayedHand: (r) => {
|
||||
let best = null, bestN = -1;
|
||||
for (const [h, n] of Object.entries(r.handPlayCounts)) if (n > bestN) { best = h; bestN = n; }
|
||||
return best;
|
||||
},
|
||||
discardRandom: (r, n) => {
|
||||
for (let i = 0; i < n && r.hand.length; i++) r.hand.splice(rng.int(r.hand.length), 1);
|
||||
},
|
||||
destroyCards: (r, uids) => {
|
||||
r.deck = r.deck.filter((c) => !uids.includes(c.uid));
|
||||
r.hand = r.hand.filter((u) => !uids.includes(u));
|
||||
r.drawPile = r.drawPile.filter((u) => !uids.includes(u));
|
||||
},
|
||||
disableRandomJoker: (r) => {
|
||||
for (const j of r.jokers) j.debuffedByBoss = false;
|
||||
if (r.jokers.length) rng.pick(r.jokers).debuffedByBoss = true;
|
||||
},
|
||||
forceRandomCard: (r) => { r.bossState.forcedUid = r.hand.length ? rng.pick(r.hand) : null; },
|
||||
destroyRandomOtherJoker: (r, j) => {
|
||||
const others = r.jokers.filter((o) => o.uid !== j.uid);
|
||||
if (others.length) { const victim = rng.pick(others); r.jokers = r.jokers.filter((o) => o.uid !== victim.uid); }
|
||||
},
|
||||
createConsumable: (r, kind, id = null) => {
|
||||
const pp = passives(r);
|
||||
if (r.consumables.length >= pp.consumableSlots) return false;
|
||||
const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS;
|
||||
const pick = id ? pool.find((x) => x.id === id) : pool[rng.int(pool.length)];
|
||||
if (!pick) return false;
|
||||
r.consumables.push({ kind, id: pick.id, uid: r.uidCounter++ });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function removeDeadJokers(run) {
|
||||
const dead = [];
|
||||
run.jokers = run.jokers.filter((inst) => {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.isDead && def.isDead(inst)) { dead.push(inst.id); return false; }
|
||||
return true;
|
||||
});
|
||||
return dead;
|
||||
}
|
||||
|
||||
// ── Ante / blind flow ────────────────────────────────────────────────────────
|
||||
export function bossFor(run) { return BOSS_BY_ID[run.bossId] || null; }
|
||||
|
||||
export function blindChipsFor(run, blind) {
|
||||
const base = anteBase(run.ante);
|
||||
if (blind !== 'boss') return Math.floor(base * BLINDS[blind].mult);
|
||||
const boss = bossFor(run);
|
||||
return Math.floor(base * ((boss && boss.reqMult) || 2));
|
||||
}
|
||||
|
||||
export function beginAnte(run) {
|
||||
const rng = rngOf(run);
|
||||
run.blind = 'small';
|
||||
run.phase = 'blindselect';
|
||||
run.playedUidsThisAnte = [];
|
||||
run.stats.bestAnte = Math.max(run.stats.bestAnte, run.ante);
|
||||
const finisher = run.ante % 8 === 0;
|
||||
const pool = finisher ? FINISHERS : BOSSES.filter((b) => (b.minAnte || 1) <= run.ante && b.id !== run.lastBossId);
|
||||
const boss = rng.pick(pool.length ? pool : (finisher ? FINISHERS : BOSSES));
|
||||
run.bossId = boss.id;
|
||||
run.lastBossId = boss.id;
|
||||
// One voucher offer per ante; persists in the shop until bought.
|
||||
const owned = run.vouchers;
|
||||
const offerable = VOUCHERS.filter((v) => !owned.includes(v.id) && (!v.requires || owned.includes(v.requires)));
|
||||
run.anteVoucherId = offerable.length ? rng.pick(offerable).id : null;
|
||||
saveRng(run, rng);
|
||||
}
|
||||
|
||||
export function skipBlind(run) {
|
||||
if (run.phase !== 'blindselect') return { ok: false, error: 'Not choosing a blind' };
|
||||
if (!BLINDS[run.blind].skippable) return { ok: false, error: 'The Boss Blind cannot be skipped' };
|
||||
run.stats.blindsSkipped += 1;
|
||||
run.blind = run.blind === 'small' ? 'big' : 'boss';
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function selectBlind(run) {
|
||||
if (run.phase !== 'blindselect') return { ok: false, error: 'Not choosing a blind' };
|
||||
const rng = rngOf(run);
|
||||
const p = passives(run);
|
||||
const api = makeApi(run, rng, p);
|
||||
if (run.blind !== 'boss') {
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onBlindSelected) def.onBlindSelected(run, inst, run.blind, api);
|
||||
}
|
||||
}
|
||||
startBlind(run, rng);
|
||||
saveRng(run, rng);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function startBlind(run, rng) {
|
||||
const p = passives(run);
|
||||
run.phase = 'playing';
|
||||
run.roundScore = 0;
|
||||
run.blindChips = blindChipsFor(run, run.blind);
|
||||
run.handsLeft = p.hands;
|
||||
run.discardsLeft = p.discards;
|
||||
run.discardsUsedThisRound = 0;
|
||||
run.firstDiscardDone = false;
|
||||
run.handsPlayedThisRound = 0;
|
||||
run.handTypesPlayedThisRound = [];
|
||||
run.bossState = { handsPlayed: 0, forcedUid: null };
|
||||
for (const c of run.deck) { c.faceDown = false; c.debuffed = false; }
|
||||
for (const j of run.jokers) j.debuffedByBoss = false;
|
||||
run.drawPile = rng.shuffle(run.deck.map((c) => c.uid));
|
||||
run.hand = [];
|
||||
|
||||
const api = makeApi(run, rng, p);
|
||||
const boss = run.blind === 'boss' ? bossFor(run) : null;
|
||||
if (boss && boss.onBlindStart) boss.onBlindStart(run, api);
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onBlindStart) def.onBlindStart(run, inst, api);
|
||||
}
|
||||
api.initial = true;
|
||||
drawToHandSize(run, rng, api);
|
||||
api.initial = false;
|
||||
// Cerulean Bell's onBlindStart fires before cards exist; force after the deal.
|
||||
if (boss && boss.id === 'cerulean_bell') api.forceRandomCard(run);
|
||||
}
|
||||
|
||||
function drawToHandSize(run, rng, api) {
|
||||
const p = passives(run);
|
||||
const drawn = [];
|
||||
const boss = run.blind === 'boss' ? bossFor(run) : null;
|
||||
const byUid = new Map(run.deck.map((c) => [c.uid, c]));
|
||||
while (run.hand.length < p.handSize && run.drawPile.length) {
|
||||
const uid = run.drawPile.pop();
|
||||
const card = byUid.get(uid);
|
||||
if (boss && boss.drawTransform) boss.drawTransform(run, card, api);
|
||||
run.hand.push(uid);
|
||||
drawn.push(uid);
|
||||
}
|
||||
sortHand(run);
|
||||
return drawn;
|
||||
}
|
||||
|
||||
export function sortHand(run, mode = null) {
|
||||
if (mode) run.sortMode = mode;
|
||||
const byUid = new Map(run.deck.map((c) => [c.uid, c]));
|
||||
const suitOrder = { S: 0, H: 1, D: 2, C: 3 };
|
||||
run.hand.sort((a, b) => {
|
||||
const ca = byUid.get(a), cb = byUid.get(b);
|
||||
if (run.sortMode === 'suit') {
|
||||
if (suitOrder[ca.suit] !== suitOrder[cb.suit]) return suitOrder[ca.suit] - suitOrder[cb.suit];
|
||||
return cb.rank - ca.rank;
|
||||
}
|
||||
if (cb.rank !== ca.rank) return cb.rank - ca.rank;
|
||||
return suitOrder[ca.suit] - suitOrder[cb.suit];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Playing ──────────────────────────────────────────────────────────────────
|
||||
export function validatePlay(run, uids) {
|
||||
if (run.phase !== 'playing') return 'Not in a round';
|
||||
if (run.handsLeft <= 0) return 'No hands left';
|
||||
if (!uids.length || uids.length > 5) return 'Select 1 to 5 cards';
|
||||
if (!uids.every((u) => run.hand.includes(u))) return 'Card not in hand';
|
||||
if (run.bossState.forcedUid && !uids.includes(run.bossState.forcedUid)) return 'A card is forced by the Boss Blind';
|
||||
const boss = run.blind === 'boss' ? bossFor(run) : null;
|
||||
if (boss && boss.validPlay) {
|
||||
const p = passives(run);
|
||||
const { handType } = evaluate(cardsOf(run, uids), p);
|
||||
const err = boss.validPlay(run, uids, handType);
|
||||
if (err) return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Boss constraints (The Psychic / Eye / Mouth, Cerulean Bell) can leave no
|
||||
// legal selection. The scene checks this to offer a forfeit instead of a
|
||||
// soft-lock; discards may still open up a play.
|
||||
export function anyLegalPlay(run) {
|
||||
if (run.phase !== 'playing' || run.handsLeft <= 0) return false;
|
||||
const hand = run.hand;
|
||||
const found = { hit: false };
|
||||
const rec = (start, cur) => {
|
||||
if (found.hit) return;
|
||||
if (cur.length >= 1 && cur.length <= 5 && !validatePlay(run, cur)) { found.hit = true; return; }
|
||||
if (cur.length >= 5) return;
|
||||
for (let i = start; i < hand.length && !found.hit; i++) { cur.push(hand[i]); rec(i + 1, cur); cur.pop(); }
|
||||
};
|
||||
rec(0, []);
|
||||
return found.hit;
|
||||
}
|
||||
|
||||
export function forfeitRound(run) {
|
||||
if (run.phase !== 'playing') return { ok: false, error: 'Not in a round' };
|
||||
run.phase = 'over';
|
||||
run.lostAt = { ante: run.ante, blind: run.blind };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function playHand(run, uids) {
|
||||
const err = validatePlay(run, uids);
|
||||
if (err) return { ok: false, error: err };
|
||||
const rng = rngOf(run);
|
||||
const p = passives(run);
|
||||
const api = makeApi(run, rng, p);
|
||||
|
||||
// Score in hand (left→right) order regardless of click order.
|
||||
const ordered = run.hand.filter((u) => uids.includes(u));
|
||||
const played = cardsOf(run, ordered);
|
||||
for (const c of played) c.faceDown = false;
|
||||
const held = cardsOf(run, run.hand.filter((u) => !uids.includes(u)));
|
||||
|
||||
const effJokers = resolveEffectiveJokers(run);
|
||||
const trace = scorePlayedHand(run, played, held, {
|
||||
effJokers, passives: p, rng,
|
||||
isDebuffed: (c) => isDebuffed(run, c),
|
||||
boss: run.blind === 'boss' ? bossFor(run) : null,
|
||||
});
|
||||
|
||||
run.roundScore += trace.score;
|
||||
run.handsPlayedThisRound += 1;
|
||||
run.stats.handsPlayed += 1;
|
||||
run.handTypesPlayedThisRound.push(trace.handType);
|
||||
run.handPlayCounts[trace.handType] = (run.handPlayCounts[trace.handType] || 0) + 1;
|
||||
run.playedUidsThisAnte.push(...ordered);
|
||||
run.stats.bestHandScore = Math.max(run.stats.bestHandScore, trace.score);
|
||||
run.hand = run.hand.filter((u) => !uids.includes(u));
|
||||
if (trace.destroyedUids.length) api.destroyCards(run, trace.destroyedUids);
|
||||
const destroyedJokers = removeDeadJokers(run);
|
||||
|
||||
const boss = run.blind === 'boss' ? bossFor(run) : null;
|
||||
if (boss && boss.afterHandPlayed) boss.afterHandPlayed(run, api, { handType: trace.handType });
|
||||
run.bossState.handsPlayed += 1;
|
||||
run.handsLeft -= 1;
|
||||
|
||||
let outcome = 'continue';
|
||||
let drawn = [];
|
||||
if (run.roundScore >= run.blindChips || !Number.isFinite(run.roundScore)) {
|
||||
outcome = 'blindWon';
|
||||
winBlind(run, rng, api, trace.handType);
|
||||
} else if (run.handsLeft <= 0) {
|
||||
outcome = 'runLost';
|
||||
run.phase = 'over';
|
||||
run.lostAt = { ante: run.ante, blind: run.blind };
|
||||
} else {
|
||||
drawn = drawToHandSize(run, rng, api);
|
||||
if (run.bossState.forcedUid !== null && !run.hand.includes(run.bossState.forcedUid)) api.forceRandomCard(run);
|
||||
}
|
||||
saveRng(run, rng);
|
||||
return { ok: true, trace, outcome, drawn, destroyedJokers };
|
||||
}
|
||||
|
||||
function winBlind(run, rng, api, lastHandType) {
|
||||
run.round += 1;
|
||||
run.stats.bestRoundScore = Math.max(run.stats.bestRoundScore, Number.isFinite(run.roundScore) ? run.roundScore : run.stats.bestRoundScore);
|
||||
// Blue seals held at end of round create the Planet of the last played hand.
|
||||
for (const card of handCards(run)) {
|
||||
if (card.seal === 'blue') {
|
||||
const planet = PLANET_BY_HAND[lastHandType];
|
||||
if (planet) api.createConsumable(run, 'planet', planet.id);
|
||||
}
|
||||
card.faceDown = false;
|
||||
}
|
||||
if (run.blind === 'boss') {
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onBossDefeated) def.onBossDefeated(run, inst);
|
||||
}
|
||||
}
|
||||
run.cashout = computeCashOut(run);
|
||||
run.phase = 'cashout';
|
||||
if (run.blind === 'boss' && run.ante === 8 && !run.endless) run.pendingWin = true;
|
||||
if (!Number.isFinite(run.roundScore)) { run.pendingWin = true; }
|
||||
}
|
||||
|
||||
export function computeCashOut(run) {
|
||||
const p = passives(run);
|
||||
const lines = [{ label: BLINDS[run.blind].name, amount: BLINDS[run.blind].reward }];
|
||||
if (run.handsLeft > 0) {
|
||||
lines.push({ label: `${run.handsLeft} remaining hand${run.handsLeft > 1 ? 's' : ''}`, amount: run.handsLeft * (p.unusedHand || 1) });
|
||||
}
|
||||
if (p.unusedDiscard && run.discardsLeft > 0) {
|
||||
lines.push({ label: `${run.discardsLeft} remaining discard${run.discardsLeft > 1 ? 's' : ''}`, amount: run.discardsLeft * p.unusedDiscard });
|
||||
}
|
||||
if (!p.noInterest && run.gold > 0) {
|
||||
const interest = Math.min(Math.floor(run.gold / DEFAULTS.interestPer), p.interestCap) * p.interestPerFive;
|
||||
if (interest > 0) lines.push({ label: 'Interest', amount: interest });
|
||||
}
|
||||
const goldCards = handCards(run).filter((c) => c.enhancement === 'gold' && !c.debuffed).length;
|
||||
if (goldCards > 0) lines.push({ label: `${goldCards} Gold card${goldCards > 1 ? 's' : ''} held`, amount: goldCards * 3 });
|
||||
for (const e of resolveEffectiveJokers(run)) {
|
||||
if (!e.def.onCashOut || e.inst.debuffedByBoss) continue;
|
||||
const amount = e.def.onCashOut(run, e.hookInst) || 0;
|
||||
if (amount > 0) lines.push({ label: e.def.name || e.inst.id, amount });
|
||||
}
|
||||
return { lines, total: lines.reduce((s, l) => s + l.amount, 0) };
|
||||
}
|
||||
|
||||
// Collect the cash-out and enter the shop (or the win screen).
|
||||
export function collectCashOut(run) {
|
||||
if (run.phase !== 'cashout') return { ok: false, error: 'Nothing to collect' };
|
||||
const rng = rngOf(run);
|
||||
run.gold += run.cashout ? run.cashout.total : 0;
|
||||
run.cashout = null;
|
||||
// Round-end joker upkeep: decay, self-destruct rolls, suit rerolls, Egg.
|
||||
const p = passives(run);
|
||||
const api = makeApi(run, rng, p);
|
||||
const destroyed = [];
|
||||
run.jokers = run.jokers.filter((inst) => {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onRoundEnd) {
|
||||
const res = def.onRoundEnd(run, inst, api);
|
||||
if (res && res.destroyed) { destroyed.push(inst.id); return false; }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
destroyed.push(...removeDeadJokers(run));
|
||||
if (run.pendingWin) {
|
||||
run.phase = 'over';
|
||||
run.won = true;
|
||||
saveRng(run, rng);
|
||||
return { ok: true, destroyed, won: true };
|
||||
}
|
||||
enterShop(run, rng);
|
||||
saveRng(run, rng);
|
||||
return { ok: true, destroyed };
|
||||
}
|
||||
|
||||
// Endless: from the win screen, keep the run going.
|
||||
export function continueEndless(run) {
|
||||
if (!run.won || run.endless) return { ok: false, error: 'No win to continue' };
|
||||
const rng = rngOf(run);
|
||||
run.endless = true;
|
||||
run.pendingWin = false;
|
||||
run.phase = 'shop';
|
||||
enterShop(run, rng);
|
||||
saveRng(run, rng);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Leave the shop → next blind (or next ante after a boss).
|
||||
export function leaveShop(run) {
|
||||
if (run.phase !== 'shop') return { ok: false, error: 'Not in the shop' };
|
||||
if (run.pack) return { ok: false, error: 'Finish the open pack first' };
|
||||
run.shop = null;
|
||||
if (run.blind === 'small') { run.blind = 'big'; run.phase = 'blindselect'; }
|
||||
else if (run.blind === 'big') { run.blind = 'boss'; run.phase = 'blindselect'; }
|
||||
else { run.ante += 1; beginAnte(run); }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Discards ─────────────────────────────────────────────────────────────────
|
||||
export function discard(run, uids) {
|
||||
if (run.phase !== 'playing') return { ok: false, error: 'Not in a round' };
|
||||
if (run.discardsLeft <= 0) return { ok: false, error: 'No discards left' };
|
||||
if (!uids.length || uids.length > 5) return { ok: false, error: 'Select 1 to 5 cards' };
|
||||
if (!uids.every((u) => run.hand.includes(u))) return { ok: false, error: 'Card not in hand' };
|
||||
const rng = rngOf(run);
|
||||
const p = passives(run);
|
||||
const api = makeApi(run, rng, p);
|
||||
const cards = cardsOf(run, uids);
|
||||
for (const c of cards) c.faceDown = false;
|
||||
|
||||
// Purple seals create a Tarot per discarded card (slots permitting).
|
||||
for (const c of cards) { if (c.seal === 'purple') api.createConsumable(run, 'tarot'); }
|
||||
const info = { isFirst: !run.firstDiscardDone, api };
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onDiscard) def.onDiscard(run, inst, cards, info);
|
||||
}
|
||||
run.hand = run.hand.filter((u) => !uids.includes(u));
|
||||
run.discardsLeft -= 1;
|
||||
run.discardsUsedThisRound += 1;
|
||||
run.firstDiscardDone = true;
|
||||
const destroyedJokers = removeDeadJokers(run);
|
||||
const drawn = drawToHandSize(run, rng, api);
|
||||
if (run.bossState.forcedUid !== null && !run.hand.includes(run.bossState.forcedUid)) api.forceRandomCard(run);
|
||||
saveRng(run, rng);
|
||||
return { ok: true, drawn, destroyedJokers };
|
||||
}
|
||||
|
||||
// ── Shop ─────────────────────────────────────────────────────────────────────
|
||||
function discounted(run, p, cost) { return Math.max(1, Math.round(cost * (1 - p.discount))); }
|
||||
|
||||
function rollEdition(rng, p, jokerOk) {
|
||||
const odds = SHOP_EDITION_ODDS;
|
||||
const roll = rng();
|
||||
let acc = 0;
|
||||
for (const ed of ['poly', 'holo', 'foil', 'negative']) {
|
||||
if (ed === 'negative' && !jokerOk) continue;
|
||||
acc += odds[ed] * p.editionOdds;
|
||||
if (roll < acc) return ed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rollShopCard(run, rng, p, excludeJokers) {
|
||||
const weights = Object.entries(SHOP_CARD_WEIGHTS).map(([k, w]) => [k, w]);
|
||||
if (p.shopSpectral) weights.push(['spectral', SHOP_SPECTRAL_WEIGHT]);
|
||||
const kind = weighted(rng, weights);
|
||||
if (kind === 'joker') {
|
||||
const rarity = weighted(rng, Object.entries(RARITY_WEIGHTS));
|
||||
const owned = new Set([...run.jokers.map((j) => j.id), ...excludeJokers]);
|
||||
let pool = jokerPool(rarity).filter((j) => !owned.has(j.id));
|
||||
if (!pool.length) pool = JOKERS.filter((j) => !owned.has(j.id));
|
||||
if (!pool.length) pool = [JOKER_BY_ID.joker];
|
||||
const def = rng.pick(pool);
|
||||
const edition = rollEdition(rng, p, true);
|
||||
const cost = discounted(run, p, def.cost + (edition ? EDITIONS[edition].cost : 0));
|
||||
return { slot: 'joker', id: def.id, edition, cost };
|
||||
}
|
||||
if (kind === 'tarot' || kind === 'planet' || kind === 'spectral') {
|
||||
const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS;
|
||||
const pick = rng.pick(pool);
|
||||
return { slot: kind, id: pick.id, cost: discounted(run, p, CONSUMABLE_PRICES[kind]) };
|
||||
}
|
||||
// Playing card: random rank/suit, small chance of enhancement / edition / seal.
|
||||
const card = { rank: 2 + rng.int(13), suit: rng.pick(SUITS), enhancement: null, edition: null, seal: null };
|
||||
let cost = 1;
|
||||
if (rng() < 0.4) { card.enhancement = rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'stone', 'gold', 'lucky']); cost += 1; }
|
||||
const ed = rollEdition(rng, p, false);
|
||||
if (ed) { card.edition = ed; cost += EDITIONS[ed].cost; }
|
||||
if (rng() < 0.05) card.seal = rng.pick(['red', 'blue', 'gold', 'purple']);
|
||||
return { slot: 'playing', card, cost: discounted(run, p, cost) };
|
||||
}
|
||||
|
||||
function rollShop(run, rng) {
|
||||
const p = passives(run);
|
||||
const cards = [];
|
||||
for (let i = 0; i < p.shopCards; i++) {
|
||||
cards.push(rollShopCard(run, rng, p, cards.filter((c) => c.slot === 'joker').map((c) => c.id)));
|
||||
}
|
||||
const packs = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const id = weighted(rng, BOOSTER_WEIGHTS);
|
||||
packs.push({ id, cost: discounted(run, p, BOOSTER_BY_ID[id].cost) });
|
||||
}
|
||||
return {
|
||||
cards, packs,
|
||||
voucher: run.anteVoucherId ? { id: run.anteVoucherId, cost: discounted(run, p, VOUCHER_COST), sold: false } : null,
|
||||
rerollCost: Math.max(0, DEFAULTS.rerollBase - p.rerollOff),
|
||||
rerollsUsed: 0,
|
||||
freeRerolls: p.freeRerolls,
|
||||
};
|
||||
}
|
||||
|
||||
function enterShop(run, rng) {
|
||||
run.phase = 'shop';
|
||||
run.shop = rollShop(run, rng);
|
||||
}
|
||||
|
||||
export function rerollShop(run) {
|
||||
if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' };
|
||||
const rng = rngOf(run);
|
||||
const p = passives(run);
|
||||
const shop = run.shop;
|
||||
let cost = 0;
|
||||
if (shop.freeRerolls > 0) shop.freeRerolls -= 1;
|
||||
else {
|
||||
cost = shop.rerollCost;
|
||||
if (run.gold - cost < p.creditFloor) return { ok: false, error: 'Not enough money' };
|
||||
run.gold -= cost;
|
||||
shop.rerollCost += 1;
|
||||
}
|
||||
shop.rerollsUsed += 1;
|
||||
shop.cards = [];
|
||||
for (let i = 0; i < p.shopCards; i++) {
|
||||
shop.cards.push(rollShopCard(run, rng, p, shop.cards.filter((c) => c.slot === 'joker').map((c) => c.id)));
|
||||
}
|
||||
saveRng(run, rng);
|
||||
return { ok: true, cost };
|
||||
}
|
||||
|
||||
export function buyShopCard(run, idx) {
|
||||
if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' };
|
||||
const item = run.shop.cards[idx];
|
||||
if (!item) return { ok: false, error: 'Nothing there' };
|
||||
const p = passives(run);
|
||||
if (run.gold - item.cost < p.creditFloor) return { ok: false, error: 'Not enough money' };
|
||||
const rng = rngOf(run);
|
||||
if (item.slot === 'joker') {
|
||||
const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length;
|
||||
if (item.edition !== 'negative' && slotsUsed >= p.jokerSlots) return { ok: false, error: 'No Joker slots left' };
|
||||
run.jokers.push(makeJokerInstance(item.id, run.uidCounter++, rng, item.edition));
|
||||
} else if (item.slot === 'playing') {
|
||||
addCardToDeck(run, item.card);
|
||||
} else {
|
||||
if (run.consumables.length >= p.consumableSlots) return { ok: false, error: 'No consumable slots left' };
|
||||
run.consumables.push({ kind: item.slot, id: item.id, uid: run.uidCounter++ });
|
||||
}
|
||||
run.gold -= item.cost;
|
||||
run.shop.cards.splice(idx, 1);
|
||||
saveRng(run, rng);
|
||||
return { ok: true, item };
|
||||
}
|
||||
|
||||
function addCardToDeck(run, proto) {
|
||||
const card = { uid: run.uidCounter++, faceDown: false, debuffed: false, ...proto };
|
||||
run.deck.push(card);
|
||||
run.stats.cardsAddedToDeck += 1;
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onCardAdded) def.onCardAdded(run, inst);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
export function buyVoucher(run) {
|
||||
if (run.phase !== 'shop' || !run.shop || !run.shop.voucher || run.shop.voucher.sold) return { ok: false, error: 'No voucher available' };
|
||||
const p = passives(run);
|
||||
const v = run.shop.voucher;
|
||||
if (run.gold - v.cost < p.creditFloor) return { ok: false, error: 'Not enough money' };
|
||||
run.gold -= v.cost;
|
||||
run.vouchers.push(v.id);
|
||||
run.shop.voucher.sold = true;
|
||||
run.anteVoucherId = null;
|
||||
return { ok: true, id: v.id };
|
||||
}
|
||||
|
||||
export function sellJoker(run, uid) {
|
||||
const inst = run.jokers.find((j) => j.uid === uid);
|
||||
if (!inst) return { ok: false, error: 'No such Joker' };
|
||||
run.jokers = run.jokers.filter((j) => j.uid !== uid);
|
||||
run.gold += inst.sellValue;
|
||||
return { ok: true, amount: inst.sellValue };
|
||||
}
|
||||
|
||||
export function sellConsumable(run, uid) {
|
||||
const inst = run.consumables.find((c) => c.uid === uid);
|
||||
if (!inst) return { ok: false, error: 'No such card' };
|
||||
run.consumables = run.consumables.filter((c) => c.uid !== uid);
|
||||
const amount = Math.max(1, Math.floor((CONSUMABLE_PRICES[inst.kind] || 3) / 2));
|
||||
run.gold += amount;
|
||||
return { ok: true, amount };
|
||||
}
|
||||
|
||||
// ── Booster packs ────────────────────────────────────────────────────────────
|
||||
export function buyPack(run, idx) {
|
||||
if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' };
|
||||
if (run.pack) return { ok: false, error: 'A pack is already open' };
|
||||
const slot = run.shop.packs[idx];
|
||||
if (!slot) return { ok: false, error: 'Nothing there' };
|
||||
const p = passives(run);
|
||||
if (run.gold - slot.cost < p.creditFloor) return { ok: false, error: 'Not enough money' };
|
||||
const rng = rngOf(run);
|
||||
const def = BOOSTER_BY_ID[slot.id];
|
||||
run.gold -= slot.cost;
|
||||
run.shop.packs.splice(idx, 1);
|
||||
const options = [];
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < def.options; i++) {
|
||||
if (def.what === 'playing') {
|
||||
const card = { rank: 2 + rng.int(13), suit: rng.pick(SUITS), enhancement: null, edition: null, seal: null };
|
||||
if (rng() < 0.35) card.enhancement = rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'gold', 'lucky']);
|
||||
const ed = rollEdition(rng, p, false);
|
||||
if (ed) card.edition = ed;
|
||||
if (rng() < 0.08) card.seal = rng.pick(['red', 'blue', 'gold', 'purple']);
|
||||
options.push({ what: 'playing', card });
|
||||
} else if (def.what === 'joker') {
|
||||
const rarity = weighted(rng, Object.entries(RARITY_WEIGHTS));
|
||||
const owned = new Set([...run.jokers.map((j) => j.id), ...seen]);
|
||||
let pool = jokerPool(rarity).filter((j) => !owned.has(j.id));
|
||||
if (!pool.length) pool = JOKERS.filter((j) => !owned.has(j.id));
|
||||
const jk = pool.length ? rng.pick(pool) : JOKER_BY_ID.joker;
|
||||
seen.add(jk.id);
|
||||
options.push({ what: 'joker', id: jk.id, edition: rollEdition(rng, p, true) });
|
||||
} else {
|
||||
const pool = def.what === 'tarot' ? TAROTS : def.what === 'planet' ? PLANETS : SPECTRALS;
|
||||
let pick;
|
||||
if (def.what === 'planet' && i === 0 && p.telescope) {
|
||||
const most = Object.entries(run.handPlayCounts).sort((a, b) => b[1] - a[1])[0];
|
||||
pick = most ? PLANET_BY_HAND[most[0]] : rng.pick(pool);
|
||||
} else {
|
||||
pick = rng.pick(pool.filter((x) => !seen.has(x.id)).length ? pool.filter((x) => !seen.has(x.id)) : pool);
|
||||
}
|
||||
seen.add(pick.id);
|
||||
options.push({ what: def.what, id: pick.id });
|
||||
}
|
||||
}
|
||||
run.pack = { boosterId: def.id, options, picksLeft: def.picks };
|
||||
saveRng(run, rng);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function pickFromPack(run, idx) {
|
||||
if (!run.pack) return { ok: false, error: 'No open pack' };
|
||||
const opt = run.pack.options[idx];
|
||||
if (!opt) return { ok: false, error: 'Nothing there' };
|
||||
const p = passives(run);
|
||||
const rng = rngOf(run);
|
||||
if (opt.what === 'playing') {
|
||||
addCardToDeck(run, opt.card);
|
||||
} else if (opt.what === 'joker') {
|
||||
const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length;
|
||||
if (opt.edition !== 'negative' && slotsUsed >= p.jokerSlots) return { ok: false, error: 'No Joker slots left' };
|
||||
run.jokers.push(makeJokerInstance(opt.id, run.uidCounter++, rng, opt.edition || null));
|
||||
} else if (opt.what === 'planet') {
|
||||
applyPlanet(run, opt.id); // Celestial picks are used immediately
|
||||
} else {
|
||||
if (run.consumables.length >= p.consumableSlots) return { ok: false, error: 'No consumable slots left' };
|
||||
run.consumables.push({ kind: opt.what, id: opt.id, uid: run.uidCounter++ });
|
||||
}
|
||||
run.pack.options.splice(idx, 1);
|
||||
run.pack.picksLeft -= 1;
|
||||
if (run.pack.picksLeft <= 0 || !run.pack.options.length) run.pack = null;
|
||||
saveRng(run, rng);
|
||||
return { ok: true, picked: opt };
|
||||
}
|
||||
|
||||
export function skipPack(run) {
|
||||
if (!run.pack) return { ok: false, error: 'No open pack' };
|
||||
run.pack = null;
|
||||
run.stats.packsSkipped += 1;
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onPackSkipped) def.onPackSkipped(run, inst);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Consumables ──────────────────────────────────────────────────────────────
|
||||
function applyPlanet(run, planetId) {
|
||||
const planet = PLANET_BY_ID[planetId];
|
||||
run.handLevels[planet.hand] = (run.handLevels[planet.hand] || 1) + 1;
|
||||
run.stats.planetsUsed += 1;
|
||||
if (!run.stats.planetsUsedSet.includes(planetId)) run.stats.planetsUsedSet.push(planetId);
|
||||
run.lastConsumable = { kind: 'planet', id: planetId };
|
||||
for (const inst of run.jokers) {
|
||||
const def = JOKER_BY_ID[inst.id];
|
||||
if (def && def.onConsumableUsed) def.onConsumableUsed(run, inst, 'planet');
|
||||
}
|
||||
return { note: `${planet.name}: ${planet.hand} is now level ${run.handLevels[planet.hand]}` };
|
||||
}
|
||||
|
||||
export function consumableDef(inst) {
|
||||
return inst.kind === 'tarot' ? TAROT_BY_ID[inst.id] : inst.kind === 'planet' ? PLANET_BY_ID[inst.id] : SPECTRAL_BY_ID[inst.id];
|
||||
}
|
||||
|
||||
export function useConsumable(run, uid, targetUids = []) {
|
||||
const inst = run.consumables.find((c) => c.uid === uid);
|
||||
if (!inst) return { ok: false, error: 'No such card' };
|
||||
if (run.phase === 'over') return { ok: false, error: 'The run is over' };
|
||||
const def = consumableDef(inst);
|
||||
const spec = def.effect || {};
|
||||
const needsTargets = def.targets || 0;
|
||||
if (needsTargets > 0) {
|
||||
if (run.phase !== 'playing') return { ok: false, error: 'Can only be used during a round' };
|
||||
if (!targetUids.length || targetUids.length > needsTargets) return { ok: false, error: `Select up to ${needsTargets} card${needsTargets > 1 ? 's' : ''}` };
|
||||
if (spec.kind === 'death' && targetUids.length !== 2) return { ok: false, error: 'Select exactly 2 cards' };
|
||||
if (!targetUids.every((u) => run.hand.includes(u))) return { ok: false, error: 'Card not in hand' };
|
||||
}
|
||||
const rng = rngOf(run);
|
||||
const p = passives(run);
|
||||
const api = makeApi(run, rng, p);
|
||||
const targets = cardsOf(run, run.hand.filter((u) => targetUids.includes(u)));
|
||||
let note = def.name;
|
||||
|
||||
// Remove before resolving so created consumables have room where possible.
|
||||
run.consumables = run.consumables.filter((c) => c.uid !== uid);
|
||||
|
||||
if (inst.kind === 'planet') {
|
||||
note = applyPlanet(run, inst.id).note;
|
||||
saveRng(run, rng);
|
||||
return { ok: true, note };
|
||||
}
|
||||
|
||||
switch (spec.kind) {
|
||||
case 'fool': {
|
||||
if (!run.lastConsumable) { run.consumables.push(inst); return { ok: false, error: 'No card to copy yet' }; }
|
||||
if (!api.createConsumable(run, run.lastConsumable.kind, run.lastConsumable.id)) {
|
||||
run.consumables.push(inst);
|
||||
return { ok: false, error: 'No consumable slots left' };
|
||||
}
|
||||
note = 'Copied the last card used';
|
||||
break;
|
||||
}
|
||||
case 'enhance': for (const c of targets) c.enhancement = spec.enh; break;
|
||||
case 'create': {
|
||||
if (spec.what === 'joker') {
|
||||
const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length;
|
||||
if (slotsUsed >= p.jokerSlots) { run.consumables.push(inst); return { ok: false, error: 'No Joker slots left' }; }
|
||||
const owned = new Set(run.jokers.map((j) => j.id));
|
||||
const pool = JOKERS.filter((j) => !owned.has(j.id) && j.id !== 'blueprint' && j.id !== 'brainstorm');
|
||||
run.jokers.push(makeJokerInstance(rng.pick(pool.length ? pool : JOKERS).id, run.uidCounter++, rng));
|
||||
note = 'Created a Joker';
|
||||
} else {
|
||||
let made = 0;
|
||||
for (let i = 0; i < (spec.count || 1); i++) { if (api.createConsumable(run, spec.what)) made++; }
|
||||
if (!made) { run.consumables.push(inst); return { ok: false, error: 'No consumable slots left' }; }
|
||||
note = `Created ${made} ${spec.what}${made > 1 ? 's' : ''}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doubleMoney': { const gain = Math.max(0, Math.min(spec.max, run.gold)); run.gold += gain; note = `+$${gain}`; break; }
|
||||
case 'wheel': {
|
||||
const cands = run.jokers.filter((j) => !j.edition);
|
||||
if (cands.length && api.prob(4)) {
|
||||
const j = rng.pick(cands);
|
||||
j.edition = weighted(rng, [['foil', 50], ['holo', 35], ['poly', 15]]);
|
||||
note = `${JOKER_BY_ID[j.id].name} became ${EDITIONS[j.edition].name}!`;
|
||||
} else note = 'Nope!';
|
||||
break;
|
||||
}
|
||||
case 'rankUp': for (const c of targets) { if (c.enhancement !== 'stone') c.rank = c.rank === 14 ? 2 : c.rank + 1; } break;
|
||||
case 'destroy': api.destroyCards(run, targets.map((c) => c.uid)); note = `Destroyed ${targets.length}`; break;
|
||||
case 'death': {
|
||||
const [left, right] = targets;
|
||||
left.rank = right.rank; left.suit = right.suit;
|
||||
left.enhancement = right.enhancement; left.edition = right.edition; left.seal = right.seal;
|
||||
note = 'Copied the card';
|
||||
break;
|
||||
}
|
||||
case 'temperance': { const gain = Math.min(spec.max, run.jokers.reduce((s, j) => s + j.sellValue, 0)); run.gold += gain; note = `+$${gain}`; break; }
|
||||
case 'suit': for (const c of targets) { if (c.enhancement !== 'stone') c.suit = spec.suit; } break;
|
||||
case 'seal': for (const c of targets) c.seal = spec.seal; break;
|
||||
case 'aura': { if (targets[0]) { targets[0].edition = weighted(rng, [['foil', 50], ['holo', 35], ['poly', 15]]); } break; }
|
||||
case 'wraith': {
|
||||
const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length;
|
||||
if (slotsUsed >= p.jokerSlots) { run.consumables.push(inst); return { ok: false, error: 'No Joker slots left' }; }
|
||||
const owned = new Set(run.jokers.map((j) => j.id));
|
||||
const pool = jokerPool('rare').filter((j) => !owned.has(j.id));
|
||||
run.jokers.push(makeJokerInstance(rng.pick(pool.length ? pool : jokerPool('rare')).id, run.uidCounter++, rng));
|
||||
run.gold = 0;
|
||||
note = 'A Rare Joker appears... your money is gone';
|
||||
break;
|
||||
}
|
||||
case 'sigil': { const suit = rng.pick(SUITS); for (const c of handCards(run)) { if (c.enhancement !== 'stone') c.suit = suit; } note = 'All one suit'; break; }
|
||||
case 'ouija': { const rank = 2 + rng.int(13); for (const c of handCards(run)) { if (c.enhancement !== 'stone') c.rank = rank; } run.handSizePenalty += 1; note = 'All one rank; -1 hand size'; break; }
|
||||
case 'ectoplasm': {
|
||||
if (!run.jokers.length) { run.consumables.push(inst); return { ok: false, error: 'No Jokers to enhance' }; }
|
||||
rng.pick(run.jokers).edition = 'negative';
|
||||
run.handSizePenalty += 1;
|
||||
note = 'Negative Joker; -1 hand size';
|
||||
break;
|
||||
}
|
||||
case 'immolate': {
|
||||
const pool = run.hand.slice();
|
||||
const burn = [];
|
||||
for (let i = 0; i < 5 && pool.length; i++) burn.push(pool.splice(rng.int(pool.length), 1)[0]);
|
||||
api.destroyCards(run, burn);
|
||||
run.gold += 20;
|
||||
note = `Destroyed ${burn.length}; +$20`;
|
||||
break;
|
||||
}
|
||||
case 'blackhole': { for (const h of HAND_TYPES) run.handLevels[h.id] += 1; note = 'Every hand leveled up'; break; }
|
||||
case 'hex': {
|
||||
if (!run.jokers.length) { run.consumables.push(inst); return { ok: false, error: 'No Jokers to enhance' }; }
|
||||
const keep = rng.pick(run.jokers);
|
||||
keep.edition = 'poly';
|
||||
run.jokers = [keep];
|
||||
note = `${JOKER_BY_ID[keep.id].name} is Polychrome; the rest are gone`;
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
|
||||
if (inst.kind === 'tarot') {
|
||||
run.stats.tarotsUsed += 1;
|
||||
if (spec.kind !== 'fool') run.lastConsumable = { kind: 'tarot', id: inst.id };
|
||||
} else if (inst.kind === 'spectral') {
|
||||
run.stats.spectralsUsed += 1;
|
||||
}
|
||||
if (spec.kind === 'spectralAdd') {
|
||||
// Handled here so the created cards land in hand + deck.
|
||||
if (run.hand.length) {
|
||||
const victim = run.hand[rng.int(run.hand.length)];
|
||||
api.destroyCards(run, [victim]);
|
||||
}
|
||||
for (let i = 0; i < spec.add; i++) {
|
||||
const rank = spec.addKind === 'ace' ? 14 : spec.addKind === 'face' ? 11 + rng.int(3) : 2 + rng.int(9);
|
||||
const card = addCardToDeck(run, {
|
||||
rank, suit: rng.pick(SUITS),
|
||||
enhancement: rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'gold', 'lucky']),
|
||||
edition: null, seal: null,
|
||||
});
|
||||
run.hand.push(card.uid);
|
||||
}
|
||||
sortHand(run);
|
||||
note = `${def.name}: new cards in hand`;
|
||||
}
|
||||
for (const jinst of run.jokers) {
|
||||
const jdef = JOKER_BY_ID[jinst.id];
|
||||
if (jdef && jdef.onConsumableUsed && inst.kind !== 'planet') jdef.onConsumableUsed(run, jinst, inst.kind);
|
||||
}
|
||||
saveRng(run, rng);
|
||||
return { ok: true, note };
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
// BalatroScoring.js
|
||||
// Pure poker-hand evaluator + the chips×mult scoring pipeline. The pipeline
|
||||
// both mutates run state (gold, joker counters, hand levels under The Arm,
|
||||
// Vampire strips, DNA copies) and emits an ordered trace of events;
|
||||
// BalatroGame replays the trace as animation and never recomputes scoring.
|
||||
// Every event carries chipsAfter/multAfter so counters are pure playback.
|
||||
|
||||
import { handBase, rankChips, isFaceRank, TAROTS, PLANETS, SPECTRALS } from './BalatroData.js';
|
||||
|
||||
// ── Suit helpers ─────────────────────────────────────────────────────────────
|
||||
function suitGroup(suit, smeared) {
|
||||
if (!smeared) return suit;
|
||||
return (suit === 'H' || suit === 'D') ? 'R' : 'B';
|
||||
}
|
||||
|
||||
// Does this card count as `suit`? Stone: never. Wild: always. Smeared merges
|
||||
// the red pair and the black pair.
|
||||
export function cardHasSuit(card, suit, smeared) {
|
||||
if (card.enhancement === 'stone') return false;
|
||||
if (card.enhancement === 'wild') return true;
|
||||
return suitGroup(card.suit, smeared) === suitGroup(suit, smeared);
|
||||
}
|
||||
|
||||
// ── Evaluator ────────────────────────────────────────────────────────────────
|
||||
// evaluate(cards, {fourFingers, shortcut, smeared}) → { handType, scoringUids }
|
||||
// Stone cards have no rank/suit (excluded from detection, always score).
|
||||
// Wild cards count as every suit. Debuffed cards still count for detection.
|
||||
|
||||
// Cards forming a flush of >= n, or null. Wilds join any group.
|
||||
function findFlush(active, n, smeared) {
|
||||
const wilds = active.filter((c) => c.enhancement === 'wild');
|
||||
const rest = active.filter((c) => c.enhancement !== 'wild');
|
||||
const groups = {};
|
||||
for (const c of rest) {
|
||||
const g = suitGroup(c.suit, smeared);
|
||||
(groups[g] = groups[g] || []).push(c);
|
||||
}
|
||||
let best = null;
|
||||
for (const g of Object.keys(groups)) {
|
||||
if (groups[g].length + wilds.length >= n && (!best || groups[g].length > best.length)) best = groups[g];
|
||||
}
|
||||
if (!best && wilds.length >= n) best = [];
|
||||
return best ? best.concat(wilds) : null;
|
||||
}
|
||||
|
||||
// Cards forming a straight of n distinct ranks (Ace high or low; Shortcut
|
||||
// allows a single-rank gap between consecutive ranks), or null.
|
||||
function findStraight(active, n, shortcut) {
|
||||
const byRank = new Map();
|
||||
for (const c of active) {
|
||||
const ranks = c.rank === 14 ? [14, 1] : [c.rank];
|
||||
for (const r of ranks) {
|
||||
if (!byRank.has(r)) byRank.set(r, []);
|
||||
byRank.get(r).push(c);
|
||||
}
|
||||
}
|
||||
const ranks = [...byRank.keys()].sort((a, b) => a - b);
|
||||
const maxGap = shortcut ? 2 : 1;
|
||||
for (let i = 0; i <= ranks.length - n; i++) {
|
||||
const chain = [ranks[i]];
|
||||
for (let j = i + 1; j < ranks.length && chain.length < n; j++) {
|
||||
const gap = ranks[j] - chain[chain.length - 1];
|
||||
if (gap === 0) continue;
|
||||
if (gap > maxGap) break;
|
||||
chain.push(ranks[j]);
|
||||
}
|
||||
if (chain.length >= n) {
|
||||
const used = new Set();
|
||||
const cards = [];
|
||||
for (const r of chain) {
|
||||
const card = byRank.get(r).find((c) => !used.has(c.uid));
|
||||
if (card) { used.add(card.uid); cards.push(card); }
|
||||
}
|
||||
if (cards.length >= n) return cards;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function evaluate(cards, opts = {}) {
|
||||
const { fourFingers = false, shortcut = false, smeared = false } = opts;
|
||||
const n = fourFingers ? 4 : 5;
|
||||
const stones = cards.filter((c) => c.enhancement === 'stone');
|
||||
const active = cards.filter((c) => c.enhancement !== 'stone');
|
||||
const withStones = (arr) => [...new Set([...arr.map((c) => c.uid), ...stones.map((c) => c.uid)])];
|
||||
|
||||
const byRank = {};
|
||||
for (const c of active) (byRank[c.rank] = byRank[c.rank] || []).push(c);
|
||||
const rankGroups = Object.values(byRank).sort((a, b) => b.length - a.length);
|
||||
const flush = active.length >= n ? findFlush(active, n, smeared) : null;
|
||||
const straight = active.length >= n ? findStraight(active, n, shortcut) : null;
|
||||
const straightFlush = flush && straight ? findStraight(flush, n, shortcut) : null;
|
||||
|
||||
if (rankGroups[0] && rankGroups[0].length >= 5) {
|
||||
if (flush && flush.length >= 5) return { handType: 'flushfive', scoringUids: withStones(active) };
|
||||
return { handType: 'fiveofakind', scoringUids: withStones(rankGroups[0].slice(0, 5)) };
|
||||
}
|
||||
const fullHouse = rankGroups[0] && rankGroups[1] && rankGroups[0].length >= 3 && rankGroups[1].length >= 2;
|
||||
if (fullHouse && flush && flush.length >= 5) return { handType: 'flushhouse', scoringUids: withStones(active) };
|
||||
if (straightFlush) return { handType: 'straightflush', scoringUids: withStones([...new Set([...straightFlush, ...flush])]) };
|
||||
if (rankGroups[0] && rankGroups[0].length >= 4) return { handType: 'fourofakind', scoringUids: withStones(rankGroups[0].slice(0, 4)) };
|
||||
if (fullHouse) return { handType: 'fullhouse', scoringUids: withStones(active) };
|
||||
if (flush) return { handType: 'flush', scoringUids: withStones(flush) };
|
||||
if (straight) return { handType: 'straight', scoringUids: withStones(straight) };
|
||||
if (rankGroups[0] && rankGroups[0].length >= 3) return { handType: 'threeofakind', scoringUids: withStones(rankGroups[0].slice(0, 3)) };
|
||||
if (rankGroups[0] && rankGroups[1] && rankGroups[0].length >= 2 && rankGroups[1].length >= 2) {
|
||||
return { handType: 'twopair', scoringUids: withStones([...rankGroups[0].slice(0, 2), ...rankGroups[1].slice(0, 2)]) };
|
||||
}
|
||||
if (rankGroups[0] && rankGroups[0].length >= 2) return { handType: 'pair', scoringUids: withStones(rankGroups[0].slice(0, 2)) };
|
||||
const high = active.slice().sort((a, b) => b.rank - a.rank)[0];
|
||||
return { handType: 'highcard', scoringUids: withStones(high ? [high] : []) };
|
||||
}
|
||||
|
||||
// "Does the played hand contain a X" — used by Jolly/Sly/Duo joker families.
|
||||
export function handContains(cards, want, opts = {}) {
|
||||
const active = cards.filter((c) => c.enhancement !== 'stone');
|
||||
const n = opts.fourFingers ? 4 : 5;
|
||||
if (want === 'straight') return !!findStraight(active, n, opts.shortcut);
|
||||
if (want === 'flush') return !!findFlush(active, n, opts.smeared);
|
||||
const byRank = {};
|
||||
for (const c of active) byRank[c.rank] = (byRank[c.rank] || 0) + 1;
|
||||
const counts = Object.values(byRank).sort((a, b) => b - a);
|
||||
if (want === 'pair') return (counts[0] || 0) >= 2;
|
||||
if (want === 'threeofakind') return (counts[0] || 0) >= 3;
|
||||
if (want === 'fourofakind') return (counts[0] || 0) >= 4;
|
||||
if (want === 'twopair') return (counts[0] || 0) >= 2 && (counts[1] || 0) >= 2;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Scoring pipeline ─────────────────────────────────────────────────────────
|
||||
// scorePlayedHand(run, played, held, opts) → trace
|
||||
// played/held: arrays of card objects (played left→right in table order)
|
||||
// opts: { effJokers: resolveEffectiveJokers(run), passives, rng,
|
||||
// isDebuffed(card), boss }
|
||||
// Copies (isCopy) run only scoring-time hooks; onHandPlayed/afterHand state
|
||||
// updates fire once, on originals, so counters never double-tick.
|
||||
|
||||
export function scorePlayedHand(run, played, held, opts) {
|
||||
const { effJokers, passives, rng, isDebuffed, boss } = opts;
|
||||
const events = [];
|
||||
let chips = 0;
|
||||
let mult = 0;
|
||||
|
||||
const { handType, scoringUids } = evaluate(played, passives);
|
||||
const scoringSet = passives.splash ? played.slice() : played.filter((c) => scoringUids.includes(c.uid));
|
||||
const isFace = (c) => c.enhancement !== 'stone' && (passives.pareidolia || isFaceRank(c.rank));
|
||||
const dead = (c) => c.debuffed || isDebuffed(c);
|
||||
const destroyedUids = [];
|
||||
|
||||
// The Arm permanently levels the played hand down before base lookup.
|
||||
if (boss && boss.id === 'the_arm' && (run.handLevels[handType] || 1) > 1) {
|
||||
run.handLevels[handType] -= 1;
|
||||
events.push({ t: 'info', text: 'The Arm: hand level down', chipsAfter: 0, multAfter: 0 });
|
||||
}
|
||||
const level = run.handLevels[handType] || 1;
|
||||
let base = handBase(handType, level);
|
||||
if (boss && boss.id === 'the_flint') {
|
||||
base = { chips: Math.ceil(base.chips / 2), mult: Math.ceil(base.mult / 2) };
|
||||
}
|
||||
chips = base.chips;
|
||||
mult = base.mult;
|
||||
|
||||
const ctx = {
|
||||
run, rng, handType, level, events, passives,
|
||||
playedCards: played, scoredCards: scoringSet, heldCards: held,
|
||||
flags: {}, isFace,
|
||||
hasSuit: (card, suit) => cardHasSuit(card, suit, passives.smeared),
|
||||
contains: (want) => handContains(played, want, passives),
|
||||
prob(nOdds) { const odds = passives.oops ? 2 / nOdds : 1 / nOdds; return rng() < Math.min(1, odds); },
|
||||
emit(ev) { events.push({ chipsAfter: chips, multAfter: mult, ...ev }); },
|
||||
chips(v, source) { if (!v) return; chips += v; events.push({ t: 'chips', v, source, chipsAfter: chips, multAfter: mult }); },
|
||||
mult(v, source) { if (!v) return; mult += v; events.push({ t: 'mult', v, source, chipsAfter: chips, multAfter: mult }); },
|
||||
xmult(v, source) { if (!v || v === 1) return; mult = Math.round(mult * v * 100) / 100; events.push({ t: 'xmult', v, source, chipsAfter: chips, multAfter: mult }); },
|
||||
money(v, source) { if (!v) return; run.gold += v; events.push({ t: 'money', v, source, chipsAfter: chips, multAfter: mult }); },
|
||||
createConsumable(kind, source) {
|
||||
if (run.consumables.length >= passives.consumableSlots) return;
|
||||
const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS;
|
||||
const pick = pool[rng.int(pool.length)];
|
||||
run.consumables.push({ kind, id: pick.id, uid: run.uidCounter++ });
|
||||
events.push({ t: 'info', source, text: `Created ${pick.name}`, chipsAfter: chips, multAfter: mult });
|
||||
},
|
||||
};
|
||||
|
||||
events.push({ t: 'base', chips, mult, handType, level, chipsAfter: chips, multAfter: mult });
|
||||
|
||||
// 1. Scored cards, left to right. Debuffed cards count toward the hand type
|
||||
// above but contribute nothing and trigger nothing.
|
||||
for (let i = 0; i < scoringSet.length; i++) {
|
||||
const card = scoringSet[i];
|
||||
if (dead(card)) { ctx.emit({ t: 'info', source: { kind: 'card', uid: card.uid, index: i }, text: 'Debuffed' }); continue; }
|
||||
let triggers = 1 + (card.seal === 'red' ? 1 : 0);
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss || !e.def.cardRetriggers) continue;
|
||||
triggers += e.def.cardRetriggers(ctx, e.hookInst, card, i) || 0;
|
||||
}
|
||||
for (let t = 0; t < triggers; t++) {
|
||||
if (t > 0) ctx.emit({ t: 'retrigger', source: { kind: card.seal === 'red' && t === 1 ? 'seal' : 'joker', uid: card.uid } });
|
||||
const src = { kind: 'card', uid: card.uid, index: i };
|
||||
const baseChips = (card.enhancement === 'stone' ? 50 : rankChips(card.rank)) + (card.permaChips || 0);
|
||||
ctx.chips(baseChips, src);
|
||||
switch (card.enhancement) {
|
||||
case 'bonus': ctx.chips(30, { ...src, enh: 'bonus' }); break;
|
||||
case 'mult': ctx.mult(4, { ...src, enh: 'mult' }); break;
|
||||
case 'glass': ctx.xmult(2, { ...src, enh: 'glass' }); break;
|
||||
case 'lucky':
|
||||
if (ctx.prob(5)) ctx.mult(20, { ...src, enh: 'lucky' });
|
||||
if (ctx.prob(15)) ctx.money(20, { ...src, enh: 'lucky' });
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
if (card.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' });
|
||||
if (card.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' });
|
||||
if (card.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' });
|
||||
if (card.seal === 'gold') ctx.money(3, { kind: 'seal', seal: 'gold', uid: card.uid });
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss || !e.def.onScored) continue;
|
||||
e.def.onScored(ctx, e.hookInst, card, i);
|
||||
}
|
||||
}
|
||||
// Glass shatter: one roll per scored glass card, after its triggers.
|
||||
if (card.enhancement === 'glass' && ctx.prob(4)) {
|
||||
destroyedUids.push(card.uid);
|
||||
ctx.emit({ t: 'destroy', source: { kind: 'card', uid: card.uid, reason: 'glass' } });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Held-in-hand cards, left to right.
|
||||
for (const card of held) {
|
||||
if (dead(card)) continue;
|
||||
let triggers = 1 + (card.seal === 'red' ? 1 : 0);
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss || !e.def.heldRetriggers) continue;
|
||||
triggers += e.def.heldRetriggers(ctx, e.hookInst, card) || 0;
|
||||
}
|
||||
for (let t = 0; t < triggers; t++) {
|
||||
if (card.enhancement === 'steel') ctx.xmult(1.5, { kind: 'card', uid: card.uid, enh: 'steel', held: true });
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss || !e.def.onHeld) continue;
|
||||
e.def.onHeld(ctx, e.hookInst, card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Jokers left to right: Foil/Holo before the effect, Polychrome after.
|
||||
// State updates (onHandPlayed) land just before the joker's own
|
||||
// contribution so scalers apply to the hand that grew them.
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss) continue;
|
||||
const src = { kind: 'joker', id: e.def.id || e.inst.id, jokerUid: e.inst.uid };
|
||||
if (e.inst.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' });
|
||||
if (e.inst.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' });
|
||||
if (e.def.onHandPlayed && !e.isCopy) e.def.onHandPlayed(ctx, e.hookInst);
|
||||
if (e.def.independent) e.def.independent(ctx, e.hookInst);
|
||||
if (e.inst.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' });
|
||||
}
|
||||
|
||||
// 4. Post-hand decays / triggers (Ice Cream, Seltzer, Loyalty, DNA...).
|
||||
for (const e of effJokers) {
|
||||
if (e.inst.debuffedByBoss || e.isCopy || !e.def.afterHand) continue;
|
||||
e.def.afterHand(ctx, e.hookInst);
|
||||
}
|
||||
|
||||
const score = Math.floor(chips) * mult;
|
||||
events.push({ t: 'total', chips: Math.floor(chips), mult, score, chipsAfter: Math.floor(chips), multAfter: mult });
|
||||
|
||||
return { handType, level, scoringUids: scoringSet.map((c) => c.uid), events, score, destroyedUids };
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// BalatroSwirlPipeline.js
|
||||
// The signature melty psychedelic background: a fullscreen GLSL quad running a
|
||||
// domain-warped polar swirl (Balatro-style paint bands), with a two-color
|
||||
// palette the scene retunes per context (blinds, boss, shop, packs...).
|
||||
// Canvas renderer (no WebGL) falls back to a gradient with slow drifting
|
||||
// blobs so the game stays fully playable.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
|
||||
uniform float time;
|
||||
uniform vec2 resolution;
|
||||
uniform vec3 uColorA;
|
||||
uniform vec3 uColorB;
|
||||
uniform float uSpin;
|
||||
uniform float uContrast;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
v += a * vnoise(p);
|
||||
p = p * 2.03 + vec2(17.3, 9.1);
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 uv = (gl_FragCoord.xy - 0.5 * resolution) / min(resolution.x, resolution.y);
|
||||
float r = length(uv);
|
||||
float ang = atan(uv.y, uv.x);
|
||||
|
||||
// Spiral twist grows toward the center and slowly drifts.
|
||||
float twist = uSpin * (1.35 - r) * 2.6 + time * 0.09;
|
||||
ang += twist;
|
||||
vec2 p = vec2(cos(ang), sin(ang)) * (r * 2.2 + 0.15);
|
||||
|
||||
// Domain-warped fbm drives the paint bands.
|
||||
vec2 warp = vec2(fbm(p * 1.7 + vec2(time * 0.045, -time * 0.03)),
|
||||
fbm(p * 1.7 - vec2(time * 0.035, time * 0.05)));
|
||||
float n = fbm(p * 2.1 + warp * 1.9);
|
||||
float bands = sin(6.28318 * (n * 0.9 + r * 1.4 - time * 0.035));
|
||||
float m = smoothstep(-0.55, 0.75, bands);
|
||||
|
||||
// Posterize toward flat paint blobs, keep a little smoothness.
|
||||
float post = floor(m * 4.0) / 4.0;
|
||||
m = mix(m, post, 0.55) * uContrast;
|
||||
|
||||
vec3 col = mix(uColorA, uColorB, clamp(m, 0.0, 1.0));
|
||||
|
||||
// Soft center glow + corner vignette.
|
||||
col += 0.05 * (1.0 - smoothstep(0.0, 0.7, r));
|
||||
col *= 1.0 - 0.38 * smoothstep(0.55, 1.15, r);
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToVec(hex) {
|
||||
return [((hex >> 16) & 0xff) / 255, ((hex >> 8) & 0xff) / 255, (hex & 0xff) / 255];
|
||||
}
|
||||
|
||||
// Named palettes the scene switches between per view / blind.
|
||||
export const SWIRL_PALETTES = {
|
||||
title: [0x2a1a4a, 0xb03060],
|
||||
small: [0x122a52, 0x2f6fce],
|
||||
big: [0x2a2456, 0x8556d6],
|
||||
boss: [0x38080e, 0xc22f3a],
|
||||
shop: [0x0e2e22, 0x2e9e6a],
|
||||
pack: [0x241040, 0x7a3fd0],
|
||||
win: [0x2e2408, 0xd8b23a],
|
||||
lose: [0x1a1418, 0x5a2f3a],
|
||||
};
|
||||
|
||||
export function makeSwirlBackground(scene, layer) {
|
||||
const usingWebGL = scene.renderer && scene.renderer.type === Phaser.WEBGL;
|
||||
|
||||
if (usingWebGL) {
|
||||
const base = new Phaser.Display.BaseShader('balatro-swirl', FRAG, undefined, {
|
||||
uColorA: { type: '3f', value: { x: 0.16, y: 0.10, z: 0.29 } },
|
||||
uColorB: { type: '3f', value: { x: 0.69, y: 0.19, z: 0.38 } },
|
||||
uSpin: { type: '1f', value: 1.0 },
|
||||
uContrast: { type: '1f', value: 1.0 },
|
||||
});
|
||||
const go = scene.add.shader(base, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
|
||||
layer.add(go);
|
||||
const state = { a: hexToVec(SWIRL_PALETTES.title[0]), b: hexToVec(SWIRL_PALETTES.title[1]) };
|
||||
const apply = () => {
|
||||
go.setUniform('uColorA.value.x', state.a[0]);
|
||||
go.setUniform('uColorA.value.y', state.a[1]);
|
||||
go.setUniform('uColorA.value.z', state.a[2]);
|
||||
go.setUniform('uColorB.value.x', state.b[0]);
|
||||
go.setUniform('uColorB.value.y', state.b[1]);
|
||||
go.setUniform('uColorB.value.z', state.b[2]);
|
||||
};
|
||||
apply();
|
||||
let tween = null;
|
||||
return {
|
||||
setPalette(name, ms = 900) {
|
||||
const [ha, hb] = SWIRL_PALETTES[name] || SWIRL_PALETTES.title;
|
||||
const ta = hexToVec(ha), tb = hexToVec(hb);
|
||||
if (tween) tween.stop();
|
||||
const from = { a: state.a.slice(), b: state.b.slice() };
|
||||
const obj = { t: 0 };
|
||||
tween = scene.tweens.add({
|
||||
targets: obj, t: 1, duration: ms, ease: 'Sine.easeInOut',
|
||||
onUpdate: () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
state.a[i] = from.a[i] + (ta[i] - from.a[i]) * obj.t;
|
||||
state.b[i] = from.b[i] + (tb[i] - from.b[i]) * obj.t;
|
||||
}
|
||||
apply();
|
||||
},
|
||||
});
|
||||
},
|
||||
setSpin(v) { go.setUniform('uSpin.value', v); },
|
||||
destroy() { if (tween) tween.stop(); go.destroy(); },
|
||||
};
|
||||
}
|
||||
|
||||
// Canvas fallback: static two-tone gradient + two huge slow-drifting blobs.
|
||||
const g = scene.add.graphics();
|
||||
layer.add(g);
|
||||
const blobs = [
|
||||
scene.add.circle(GAME_WIDTH * 0.3, GAME_HEIGHT * 0.4, 420, 0xffffff, 0.05),
|
||||
scene.add.circle(GAME_WIDTH * 0.7, GAME_HEIGHT * 0.6, 520, 0xffffff, 0.04),
|
||||
];
|
||||
for (const b of blobs) layer.add(b);
|
||||
scene.tweens.add({ targets: blobs[0], x: GAME_WIDTH * 0.7, y: GAME_HEIGHT * 0.55, duration: 16000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
scene.tweens.add({ targets: blobs[1], x: GAME_WIDTH * 0.35, y: GAME_HEIGHT * 0.45, duration: 21000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
const paint = (name) => {
|
||||
const [a, b] = SWIRL_PALETTES[name] || SWIRL_PALETTES.title;
|
||||
g.clear();
|
||||
g.fillGradientStyle(a, a, b, b, 1);
|
||||
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
};
|
||||
paint('title');
|
||||
return { setPalette(name) { paint(name); }, setSpin() {}, destroy() { g.destroy(); blobs.forEach((b) => b.destroy()); } };
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
// BalatroViews.js
|
||||
// Non-play view renderers for BalatroGame (title, deck select, blind select,
|
||||
// shop, pack opening, cash-out overlay, game over). Each receives the scene
|
||||
// and uses its helpers (text/panel/button/draw*Card) so all styling stays in
|
||||
// one place. Pure rendering + Logic calls; no game rules here.
|
||||
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
DECKS, DECK_BY_ID, BLINDS, fmtChips, anteBase, VOUCHER_BY_ID, BOOSTER_BY_ID,
|
||||
HAND_BY_ID, EDITIONS,
|
||||
} from './BalatroData.js';
|
||||
import { JOKER_BY_ID } from './BalatroJokers.js';
|
||||
import {
|
||||
passives, selectBlind, skipBlind, blindChipsFor, bossFor, collectCashOut,
|
||||
continueEndless, leaveShop, rerollShop, buyShopCard, buyVoucher, buyPack,
|
||||
pickFromPack, skipPack,
|
||||
} from './BalatroLogic.js';
|
||||
import * as store from '../../services/localStore.js';
|
||||
import { C } from './BalatroGame.js';
|
||||
|
||||
const CX = GAME_WIDTH / 2;
|
||||
|
||||
// ── Title ────────────────────────────────────────────────────────────────────
|
||||
export function renderTitle(scene) {
|
||||
const letters = 'BALATRO';
|
||||
const colors = ['#ff5e5e', '#5aa9f4', '#ffd25e', '#7ce07c', '#c88aff', '#ff9a5e', '#7ce0e0'];
|
||||
const size = 128;
|
||||
const total = letters.length * (size * 0.72);
|
||||
letters.split('').forEach((ch, i) => {
|
||||
const t = scene.text(CX - total / 2 + i * size * 0.72 + size * 0.36, 260, ch, size, colors[i % colors.length], { ox: 0.5, oy: 0.5, bold: true });
|
||||
t.setStroke('#141019', 12);
|
||||
t.setAngle(Math.sin(i * 1.7) * 6);
|
||||
scene.tweens.add({
|
||||
targets: t, y: 260 + Math.sin(i) * 6, angle: t.angle + 4,
|
||||
duration: 1600 + i * 130, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
|
||||
});
|
||||
});
|
||||
scene.text(CX, 356, 'a poker roguelike', 26, C.muted, { ox: 0.5 });
|
||||
|
||||
let y = 500;
|
||||
if (scene._savedRun) {
|
||||
const r = scene._savedRun;
|
||||
scene.button(CX, y, `Continue (Ante ${r.ante} · ${DECK_BY_ID[r.deckId].name})`, () => scene.continueRun(), { width: 520, height: 66, fontSize: 24 });
|
||||
y += 90;
|
||||
scene.button(CX, y, 'New Run', () => confirmNewRun(scene), { width: 320, height: 58, fontSize: 22, variant: 'ghost' });
|
||||
} else {
|
||||
scene.button(CX, y, 'New Run', () => scene.setView('deckselect'), { width: 360, height: 66, fontSize: 26 });
|
||||
}
|
||||
y += 90;
|
||||
scene.button(CX, y, 'Main Menu', () => scene.scene.start('GameMenu'), { width: 280, height: 52, fontSize: 20, variant: 'ghost' });
|
||||
|
||||
const stats = store.read('balatro:stats');
|
||||
if (stats && stats.runs) {
|
||||
scene.text(CX, y + 90, `Runs ${stats.runs} · Wins ${stats.wins} · Best Ante ${stats.bestAnte} · Best Hand ${fmtChips(stats.bestHandScore)}`, 20, C.muted, { ox: 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNewRun(scene) {
|
||||
const ov = scene.add.container(0, 0).setDepth(95);
|
||||
scene.fxLayer.add(ov);
|
||||
const shade = scene.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive();
|
||||
const g = scene.add.graphics();
|
||||
g.fillStyle(C.panel, 0.97); g.fillRoundedRect(CX - 280, 420, 560, 240, 16);
|
||||
g.lineStyle(2, C.panelEdge, 1); g.strokeRoundedRect(CX - 280, 420, 560, 240, 16);
|
||||
ov.add([shade, g]);
|
||||
ov.add(scene.add.text(CX, 470, 'Abandon the saved run?', { fontFamily: '"Julius Sans One"', fontSize: '28px', color: C.ink }).setOrigin(0.5));
|
||||
const yes = scene.button(CX - 110, 590, 'Yes', () => { scene._savedRun = null; store.write('balatro:run', null); ov.destroy(); scene.setView('deckselect'); }, { width: 160, height: 54, fontSize: 22 });
|
||||
const no = scene.button(CX + 110, 590, 'No', () => ov.destroy(), { width: 160, height: 54, fontSize: 22, variant: 'ghost' });
|
||||
ov.add([yes, no]);
|
||||
shade.on('pointerdown', () => ov.destroy());
|
||||
}
|
||||
|
||||
// ── Deck select ──────────────────────────────────────────────────────────────
|
||||
export function renderDeckSelect(scene) {
|
||||
scene.text(CX, 90, 'Choose a Deck', 52, C.ink, { ox: 0.5, bold: true });
|
||||
const w = 300, h = 330;
|
||||
DECKS.forEach((deck, i) => {
|
||||
const col = i % 4, row = Math.floor(i / 4);
|
||||
const x = CX - 1.5 * (w + 60) + col * (w + 60);
|
||||
const y = 330 + row * (h + 80);
|
||||
const cont = scene.add.container(x, y);
|
||||
const g = scene.add.graphics();
|
||||
g.fillStyle(C.panel, 0.92); g.fillRoundedRect(-w / 2, -h / 2, w, h, 14);
|
||||
g.lineStyle(2, deck.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 14);
|
||||
cont.add(g);
|
||||
// Deck back: art frame if present, else colored card back.
|
||||
const af = scene.artFrame('deckBackSheet', 'deckBacks', deck.id);
|
||||
if (af) cont.add(scene.add.image(0, -60, af.key, af.frame).setDisplaySize(120, 164));
|
||||
else {
|
||||
const bg = scene.add.graphics();
|
||||
bg.fillStyle(deck.color, 0.95); bg.fillRoundedRect(-60, -142, 120, 164, 8);
|
||||
bg.lineStyle(2, 0xffffff, 0.5); bg.strokeRoundedRect(-60, -142, 120, 164, 8);
|
||||
bg.lineStyle(1, 0xffffff, 0.25); bg.strokeRoundedRect(-48, -130, 96, 140, 6);
|
||||
cont.add(bg);
|
||||
}
|
||||
cont.add(scene.add.text(0, 52, deck.name, { fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.ink, fontStyle: 'bold' }).setOrigin(0.5));
|
||||
cont.add(scene.add.text(0, 108, deck.desc, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, align: 'center', wordWrap: { width: w - 30 } }).setOrigin(0.5));
|
||||
cont.setSize(w, h);
|
||||
scene.add2(cont);
|
||||
scene.addHoverTilt(cont);
|
||||
cont.on('pointerdown', () => { playSound(scene, SFX.CARD_SHUFFLE); scene.startRun(deck.id); });
|
||||
});
|
||||
scene.button(CX, GAME_HEIGHT - 50, 'Back', () => scene.setView('title'), { width: 220, height: 50, fontSize: 20, variant: 'ghost' });
|
||||
}
|
||||
|
||||
// ── Blind select ─────────────────────────────────────────────────────────────
|
||||
export function renderBlindSelect(scene) {
|
||||
const run = scene.run;
|
||||
scene.text(CX, 70, `Ante ${run.endless ? run.ante : `${run.ante} of 8`}`, 46, C.ink, { ox: 0.5, bold: true });
|
||||
scene.text(CX, 124, `$${run.gold}`, 30, C.money, { ox: 0.5 });
|
||||
|
||||
const stages = ['small', 'big', 'boss'];
|
||||
const curIdx = stages.indexOf(run.blind);
|
||||
const w = 400, h = 560;
|
||||
stages.forEach((stage, i) => {
|
||||
const x = CX - (w + 60) + i * (w + 60);
|
||||
const y = 480;
|
||||
const isCur = i === curIdx;
|
||||
const done = i < curIdx;
|
||||
const boss = stage === 'boss' ? bossFor(run) : null;
|
||||
const color = stage === 'boss' ? 0xc22f3a : stage === 'big' ? 0x8556d6 : 0x2f6fce;
|
||||
scene.panel(x - w / 2, y - h / 2, w, h, { fill: isCur ? color : C.panel, alpha: isCur ? 0.4 : 0.75, strokeColor: color });
|
||||
scene.text(x, y - h / 2 + 46, boss ? boss.name : BLINDS[stage].name, 32, C.ink, { ox: 0.5, bold: true });
|
||||
// Requirement uses the boss multiplier for the boss column.
|
||||
const req = stage === 'boss'
|
||||
? Math.floor(anteBase(run.ante) * ((boss && boss.reqMult) || 2))
|
||||
: Math.floor(anteBase(run.ante) * BLINDS[stage].mult);
|
||||
scene.text(x, y - h / 2 + 110, 'Score at least', 18, C.muted, { ox: 0.5 });
|
||||
scene.text(x, y - h / 2 + 142, fmtChips(req), 36, '#ff8a6a', { ox: 0.5, bold: true });
|
||||
scene.text(x, y - h / 2 + 200, `Reward: $${BLINDS[stage].reward}`, 22, C.money, { ox: 0.5 });
|
||||
if (boss) scene.text(x, y - h / 2 + 260, boss.desc, 20, '#ffb9a8', { ox: 0.5, align: 'center', wrap: w - 60 });
|
||||
|
||||
if (done) {
|
||||
scene.text(x, y + h / 2 - 120, '✔ cleared', 26, '#7ce07c', { ox: 0.5 });
|
||||
} else if (isCur) {
|
||||
scene.button(x, y + h / 2 - 120, 'Select', () => {
|
||||
const r = selectBlind(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.CARD_SHUFFLE);
|
||||
scene.save();
|
||||
scene.setView('play');
|
||||
}, { width: 240, height: 64, fontSize: 26, bg: color });
|
||||
if (BLINDS[stage].skippable) {
|
||||
scene.button(x, y + h / 2 - 48, 'Skip Blind', () => {
|
||||
const r = skipBlind(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
scene.save();
|
||||
scene.renderView();
|
||||
}, { width: 200, height: 46, fontSize: 18, variant: 'ghost' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cash out overlay (rendered over the frozen play view) ────────────────────
|
||||
export function renderCashout(scene) {
|
||||
const run = scene.run;
|
||||
const cash = run.cashout || { lines: [], total: 0 };
|
||||
const w = 620, h = 200 + cash.lines.length * 44;
|
||||
const x = CX - w / 2, y = 300;
|
||||
scene.panel(x, y, w, h, { alpha: 0.97, layer: scene.fxLayer });
|
||||
scene.text(CX, y + 30, 'Round Won!', 40, '#7ce07c', { ox: 0.5, bold: true, layer: scene.fxLayer });
|
||||
cash.lines.forEach((line, i) => {
|
||||
scene.text(x + 50, y + 100 + i * 44, line.label, 24, C.ink, { layer: scene.fxLayer });
|
||||
scene.text(x + w - 50, y + 100 + i * 44, `$${line.amount}`, 24, C.money, { ox: 1, layer: scene.fxLayer });
|
||||
});
|
||||
scene.button(CX, y + h - 46, `Cash Out $${cash.total}`, () => {
|
||||
const r = collectCashOut(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.COINS);
|
||||
if (r.destroyed && r.destroyed.length) scene.toast(`${r.destroyed.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`);
|
||||
scene.save();
|
||||
if (run.phase === 'over') { scene.onRunOver(); scene.setView('gameover'); }
|
||||
else scene.setView('shop');
|
||||
}, { width: 340, height: 64, fontSize: 26, bg: 0x2e9e6a, layer: scene.fxLayer });
|
||||
}
|
||||
|
||||
// ── Shop ─────────────────────────────────────────────────────────────────────
|
||||
export function renderShop(scene) {
|
||||
const run = scene.run;
|
||||
const shop = run.shop;
|
||||
if (!shop) { scene.setView('blindselect'); return; }
|
||||
const p = passives(run);
|
||||
|
||||
// Owned jokers/consumables stay on screen (sellable while shopping).
|
||||
scene.renderJokerRow(false);
|
||||
scene.renderConsumableRow(false);
|
||||
scene.renderDeckThumb();
|
||||
|
||||
scene.text(120, 40, 'SHOP', 54, '#7ce0c0', { bold: true });
|
||||
scene._goldText = scene.text(120, 110, `$${run.gold}`, 40, C.money, { bold: true });
|
||||
const nextBlind = run.blind === 'small' ? 'Big Blind' : run.blind === 'big' ? (bossFor(run) ? bossFor(run).name : 'Boss Blind') : `Ante ${run.ante + 1}`;
|
||||
scene.text(120, 168, `Next: ${nextBlind}`, 20, C.muted);
|
||||
|
||||
const refresh = () => { scene.save(); scene.renderView(); };
|
||||
|
||||
// Card slots
|
||||
const cy = 430;
|
||||
scene.text(470, cy - 150, 'For sale', 20, C.muted);
|
||||
shop.cards.forEach((item, i) => {
|
||||
const x = 560 + i * 190;
|
||||
let cont;
|
||||
if (item.slot === 'joker') cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 });
|
||||
else if (item.slot === 'playing') cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 });
|
||||
else cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 });
|
||||
scene.add2(cont);
|
||||
scene.addHoverTilt(cont);
|
||||
scene.text(x, cy + 132, `$${item.cost}`, 26, C.money, { ox: 0.5, bold: true });
|
||||
cont.on('pointerdown', () => {
|
||||
const r = buyShopCard(run, i);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.PURCHASE);
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
|
||||
// Reroll
|
||||
scene.button(560 + p.shopCards * 190 + 40, cy, shop.freeRerolls > 0 ? 'Reroll (free)' : `Reroll $${shop.rerollCost}`, () => {
|
||||
const r = rerollShop(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.CARD_SHUFFLE);
|
||||
refresh();
|
||||
}, { width: 190, height: 60, fontSize: 20 });
|
||||
|
||||
// Packs
|
||||
const py2 = 760;
|
||||
scene.text(470, py2 - 140, 'Booster packs', 20, C.muted);
|
||||
shop.packs.forEach((slot, i) => {
|
||||
const x = 580 + i * 240;
|
||||
const def = BOOSTER_BY_ID[slot.id];
|
||||
const cont = scene.add.container(x, py2);
|
||||
const af = scene.artFrame('packSheet', 'packs', slot.id);
|
||||
if (af) cont.add(scene.add.image(0, 0, af.key, af.frame).setDisplaySize(180, 240));
|
||||
else {
|
||||
const tint = { playing: 0x4f9ad4, tarot: C.tarot, planet: C.planet, joker: 0xd4884f, spectral: C.spectral }[def.what];
|
||||
const g = scene.add.graphics();
|
||||
g.fillStyle(tint, 0.85); g.fillRoundedRect(-90, -120, 180, 240, 12);
|
||||
g.lineStyle(3, 0xffffff, 0.5); g.strokeRoundedRect(-90, -120, 180, 240, 12);
|
||||
g.fillStyle(0x141019, 0.35); g.fillRoundedRect(-90, -30, 180, 60, 0);
|
||||
cont.add(g);
|
||||
cont.add(scene.add.text(0, 0, def.name.replace(' Pack', '\nPack'), { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#ffffff', align: 'center', fontStyle: 'bold' }).setOrigin(0.5));
|
||||
}
|
||||
cont.setSize(180, 240);
|
||||
scene.add2(cont);
|
||||
scene.addHoverTilt(cont);
|
||||
scene.text(x, py2 + 148, `$${slot.cost}`, 26, C.money, { ox: 0.5, bold: true });
|
||||
cont.on('pointerdown', () => {
|
||||
const r = buyPack(run, i);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.PURCHASE);
|
||||
scene.save();
|
||||
scene.setView('packopen');
|
||||
});
|
||||
});
|
||||
|
||||
// Voucher
|
||||
const vx = 1560, vy = 560;
|
||||
if (shop.voucher && !shop.voucher.sold) {
|
||||
const v = VOUCHER_BY_ID[shop.voucher.id];
|
||||
scene.text(vx, vy - 180, 'Voucher', 20, C.muted, { ox: 0.5 });
|
||||
const cont = scene.add.container(vx, vy);
|
||||
const af = scene.artFrame('voucherSheet', 'vouchers', v.id);
|
||||
if (af) cont.add(scene.add.image(0, 0, af.key, af.frame).setDisplaySize(190, 250));
|
||||
else {
|
||||
const g = scene.add.graphics();
|
||||
g.fillStyle(0x143a2a, 0.95); g.fillRoundedRect(-95, -125, 190, 250, 10);
|
||||
g.lineStyle(3, C.voucher, 1); g.strokeRoundedRect(-95, -125, 190, 250, 10);
|
||||
g.lineStyle(1, C.voucher, 0.5); g.strokeRoundedRect(-85, -115, 170, 230, 8);
|
||||
cont.add(g);
|
||||
cont.add(scene.add.text(0, -80, v.name, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#7ce0c0', align: 'center', fontStyle: 'bold', wordWrap: { width: 170 } }).setOrigin(0.5));
|
||||
cont.add(scene.add.text(0, 20, v.desc, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.ink, align: 'center', wordWrap: { width: 165 } }).setOrigin(0.5));
|
||||
}
|
||||
cont.setSize(190, 250);
|
||||
scene.add2(cont);
|
||||
scene.addHoverTilt(cont);
|
||||
scene.text(vx, vy + 152, `$${shop.voucher.cost}`, 26, C.money, { ox: 0.5, bold: true });
|
||||
cont.on('pointerdown', () => {
|
||||
const r = buyVoucher(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.PURCHASE);
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
scene.button(GAME_WIDTH - 200, GAME_HEIGHT - 60, 'Next Round →', () => {
|
||||
const r = leaveShop(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
scene.save();
|
||||
scene.setView('blindselect');
|
||||
}, { width: 300, height: 64, fontSize: 24, bg: 0xc26a2f });
|
||||
}
|
||||
|
||||
// ── Pack opening ─────────────────────────────────────────────────────────────
|
||||
export function renderPackOpen(scene) {
|
||||
const run = scene.run;
|
||||
const pack = run.pack;
|
||||
if (!pack) { scene.setView('shop'); return; }
|
||||
const def = BOOSTER_BY_ID[pack.boosterId];
|
||||
scene.text(CX, 110, def.name, 46, C.ink, { ox: 0.5, bold: true });
|
||||
scene.text(CX, 170, `Choose ${pack.picksLeft}`, 24, C.muted, { ox: 0.5 });
|
||||
|
||||
const n = pack.options.length;
|
||||
const w = 190, h = 254;
|
||||
const step = Math.min(w + 46, 1500 / Math.max(n, 1));
|
||||
const x0 = CX - ((n - 1) * step) / 2;
|
||||
pack.options.forEach((opt, i) => {
|
||||
const x = x0 + i * step, y = 480;
|
||||
let cont;
|
||||
if (opt.what === 'playing') cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h });
|
||||
else if (opt.what === 'joker') cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h });
|
||||
else cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h });
|
||||
scene.add2(cont);
|
||||
scene.addHoverTilt(cont);
|
||||
cont.on('pointerdown', () => {
|
||||
const r = pickFromPack(run, i);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
playSound(scene, SFX.CARD_SHOW);
|
||||
scene.save();
|
||||
scene.setView(run.pack ? 'packopen' : 'shop');
|
||||
});
|
||||
});
|
||||
if (def.what === 'planet') scene.text(CX, 680, 'Planets from packs are used immediately', 18, C.muted, { ox: 0.5 });
|
||||
|
||||
scene.button(CX, 860, 'Skip Pack', () => {
|
||||
skipPack(run);
|
||||
scene.save();
|
||||
scene.setView('shop');
|
||||
}, { width: 240, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
}
|
||||
|
||||
// ── Game over ────────────────────────────────────────────────────────────────
|
||||
export function renderGameOver(scene) {
|
||||
const run = scene.run;
|
||||
if (!run) { scene.setView('title'); return; }
|
||||
const won = run.won;
|
||||
scene.text(CX, 200, won ? 'YOU WIN!' : 'GAME OVER', 96, won ? '#ffd25e' : '#ff6b5e', { ox: 0.5, bold: true }).setStroke('#141019', 10);
|
||||
if (!won && run.lostAt) {
|
||||
scene.text(CX, 300, `Defeated at Ante ${run.lostAt.ante} — ${run.lostAt.blind === 'boss' ? (bossFor(run) ? bossFor(run).name : 'Boss Blind') : BLINDS[run.lostAt.blind].name}`, 26, C.muted, { ox: 0.5 });
|
||||
}
|
||||
if (won && !Number.isFinite(run.roundScore)) {
|
||||
scene.text(CX, 300, 'Your score exceeded the bounds of reality.', 26, C.muted, { ox: 0.5 });
|
||||
}
|
||||
|
||||
const w2 = 720, y2 = 380, h2 = 300;
|
||||
scene.panel(CX - w2 / 2, y2, w2, h2, {});
|
||||
const lines = [
|
||||
['Ante reached', run.endless ? String(run.ante) : `${Math.min(run.ante, 8)}/8`],
|
||||
['Best hand', fmtChips(run.stats.bestHandScore)],
|
||||
['Hands played', String(run.stats.handsPlayed)],
|
||||
['Cards added to deck', String(run.stats.cardsAddedToDeck)],
|
||||
['Tarots / Planets used', `${run.stats.tarotsUsed} / ${run.stats.planetsUsed}`],
|
||||
['Deck', DECK_BY_ID[run.deckId].name],
|
||||
];
|
||||
lines.forEach(([label, val], i) => {
|
||||
const col = i % 2, row = Math.floor(i / 2);
|
||||
const x = CX - w2 / 2 + 60 + col * (w2 / 2);
|
||||
scene.text(x, y2 + 44 + row * 84, label, 18, C.muted);
|
||||
scene.text(x, y2 + 70 + row * 84, val, 28, C.ink, { bold: true });
|
||||
});
|
||||
|
||||
let by = 780;
|
||||
if (won && !run.endless) {
|
||||
scene.button(CX, by, 'Continue in Endless Mode', () => {
|
||||
const r = continueEndless(run);
|
||||
if (!r.ok) { scene.toast(r.error); return; }
|
||||
scene._recorded = true; // already recorded the win
|
||||
scene.save();
|
||||
scene.setView('shop');
|
||||
}, { width: 440, height: 64, fontSize: 24, bg: 0x8556d6 });
|
||||
by += 90;
|
||||
}
|
||||
scene.button(CX - 170, by, 'New Run', () => { scene.run = null; scene.setView('deckselect'); }, { width: 280, height: 60, fontSize: 24 });
|
||||
scene.button(CX + 170, by, 'Main Menu', () => scene.scene.start('GameMenu'), { width: 280, height: 60, fontSize: 24, variant: 'ghost' });
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Balatro — sprite sheets
|
||||
|
||||
All sheets are **optional drop-ins**: the game renders everything procedurally until a sheet exists.
|
||||
To activate one, save the PNG into `assets/images/` and set its `path` in `data/balatro-artwork.json`
|
||||
(e.g. `"path": "assets/images/balatro-jokers.png"`). Frames are **0-based, row-major** and must follow
|
||||
the exact order below (it matches the id maps in balatro-artwork.json).
|
||||
|
||||
Also needed: **game-icons.png frame 79** (44x44) — the Balatro icon in the game menu.
|
||||
|
||||
## balatro-jokers.png
|
||||
|
||||
- Frame size: **150x200** px
|
||||
- Frames: **114** — suggested grid **12 x 10** (image 1800x2000)
|
||||
- One frame per Joker. Rarity in-game is shown by UI, so paint the character/art only.
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `joker` | Joker |
|
||||
| 1 | `greedy` | Greedy Joker |
|
||||
| 2 | `lusty` | Lusty Joker |
|
||||
| 3 | `wrathful` | Wrathful Joker |
|
||||
| 4 | `gluttonous` | Gluttonous Joker |
|
||||
| 5 | `jolly` | Jolly Joker |
|
||||
| 6 | `zany` | Zany Joker |
|
||||
| 7 | `mad` | Mad Joker |
|
||||
| 8 | `crazy` | Crazy Joker |
|
||||
| 9 | `droll` | Droll Joker |
|
||||
| 10 | `halfjoker` | Half Joker |
|
||||
| 11 | `misprint` | Misprint |
|
||||
| 12 | `sly` | Sly Joker |
|
||||
| 13 | `wily` | Wily Joker |
|
||||
| 14 | `clever` | Clever Joker |
|
||||
| 15 | `devious` | Devious Joker |
|
||||
| 16 | `crafty` | Crafty Joker |
|
||||
| 17 | `banner` | Banner |
|
||||
| 18 | `stuntman` | Stuntman |
|
||||
| 19 | `fibonacci` | Fibonacci |
|
||||
| 20 | `scaryface` | Scary Face |
|
||||
| 21 | `evensteven` | Even Steven |
|
||||
| 22 | `oddtodd` | Odd Todd |
|
||||
| 23 | `scholar` | Scholar |
|
||||
| 24 | `smileyface` | Smiley Face |
|
||||
| 25 | `walkietalkie` | Walkie Talkie |
|
||||
| 26 | `roughgem` | Rough Gem |
|
||||
| 27 | `bloodstone` | Bloodstone |
|
||||
| 28 | `arrowhead` | Arrowhead |
|
||||
| 29 | `onyxagate` | Onyx Agate |
|
||||
| 30 | `blackboard` | Blackboard |
|
||||
| 31 | `baron` | Baron |
|
||||
| 32 | `photograph` | Photograph |
|
||||
| 33 | `ancient` | Ancient Joker |
|
||||
| 34 | `cavendish` | Cavendish |
|
||||
| 35 | `cardsharp` | Card Sharp |
|
||||
| 36 | `acrobat` | Acrobat |
|
||||
| 37 | `duo` | The Duo |
|
||||
| 38 | `trio` | The Trio |
|
||||
| 39 | `family` | The Family |
|
||||
| 40 | `order` | The Order |
|
||||
| 41 | `tribe` | The Tribe |
|
||||
| 42 | `flowerpot` | Flower Pot |
|
||||
| 43 | `runner` | Runner |
|
||||
| 44 | `icecream` | Ice Cream |
|
||||
| 45 | `bluejoker` | Blue Joker |
|
||||
| 46 | `greenjoker` | Green Joker |
|
||||
| 47 | `ridethebus` | Ride the Bus |
|
||||
| 48 | `squarejoker` | Square Joker |
|
||||
| 49 | `supernova` | Supernova |
|
||||
| 50 | `obelisk` | Obelisk |
|
||||
| 51 | `constellation` | Constellation |
|
||||
| 52 | `hologram` | Hologram |
|
||||
| 53 | `vampire` | Vampire |
|
||||
| 54 | `madness` | Madness |
|
||||
| 55 | `castle` | Castle |
|
||||
| 56 | `weejoker` | Wee Joker |
|
||||
| 57 | `erosion` | Erosion |
|
||||
| 58 | `hiker` | Hiker |
|
||||
| 59 | `ramen` | Ramen |
|
||||
| 60 | `popcorn` | Popcorn |
|
||||
| 61 | `throwback` | Throwback |
|
||||
| 62 | `goldenjoker` | Golden Joker |
|
||||
| 63 | `delayedgrat` | Delayed Gratification |
|
||||
| 64 | `businesscard` | Business Card |
|
||||
| 65 | `faceless` | Faceless Joker |
|
||||
| 66 | `cloud9` | Cloud 9 |
|
||||
| 67 | `rocket` | Rocket |
|
||||
| 68 | `egg` | Egg |
|
||||
| 69 | `tothemoon` | To the Moon |
|
||||
| 70 | `satellite` | Satellite |
|
||||
| 71 | `bull` | Bull |
|
||||
| 72 | `bootstraps` | Bootstraps |
|
||||
| 73 | `goldenticket` | Golden Ticket |
|
||||
| 74 | `redcard` | Red Card |
|
||||
| 75 | `swashbuckler` | Swashbuckler |
|
||||
| 76 | `fortuneteller` | Fortune Teller |
|
||||
| 77 | `tradingcard` | Trading Card |
|
||||
| 78 | `hack` | Hack |
|
||||
| 79 | `dusk` | Dusk |
|
||||
| 80 | `sockandbuskin` | Sock and Buskin |
|
||||
| 81 | `seltzer` | Seltzer |
|
||||
| 82 | `hangingchad` | Hanging Chad |
|
||||
| 83 | `mime` | Mime |
|
||||
| 84 | `juggler` | Juggler |
|
||||
| 85 | `drunkard` | Drunkard |
|
||||
| 86 | `troubadour` | Troubadour |
|
||||
| 87 | `merryandy` | Merry Andy |
|
||||
| 88 | `turtlebean` | Turtle Bean |
|
||||
| 89 | `burglar` | Burglar |
|
||||
| 90 | `creditcard` | Credit Card |
|
||||
| 91 | `chaostheclown` | Chaos the Clown |
|
||||
| 92 | `oops` | Oops! All 6s |
|
||||
| 93 | `pareidolia` | Pareidolia |
|
||||
| 94 | `splash` | Splash |
|
||||
| 95 | `smeared` | Smeared Joker |
|
||||
| 96 | `shortcut` | Shortcut |
|
||||
| 97 | `fourfingers` | Four Fingers |
|
||||
| 98 | `stencil` | Joker Stencil |
|
||||
| 99 | `eightball` | 8 Ball |
|
||||
| 100 | `superposition` | Superposition |
|
||||
| 101 | `vagabond` | Vagabond |
|
||||
| 102 | `cartomancer` | Cartomancer |
|
||||
| 103 | `mysticsummit` | Mystic Summit |
|
||||
| 104 | `loyaltycard` | Loyalty Card |
|
||||
| 105 | `raisedfist` | Raised Fist |
|
||||
| 106 | `shootthemoon` | Shoot the Moon |
|
||||
| 107 | `abstract` | Abstract Joker |
|
||||
| 108 | `grosmichel` | Gros Michel |
|
||||
| 109 | `stonejoker` | Stone Joker |
|
||||
| 110 | `baseball` | Baseball Card |
|
||||
| 111 | `dna` | DNA |
|
||||
| 112 | `blueprint` | Blueprint |
|
||||
| 113 | `brainstorm` | Brainstorm |
|
||||
|
||||
## balatro-tarots.png
|
||||
|
||||
- Frame size: **150x200** px
|
||||
- Frames: **22** — suggested grid **11 x 2** (image 1650x400)
|
||||
- Classic tarot-card look encouraged.
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `fool` | The Fool |
|
||||
| 1 | `magician` | The Magician |
|
||||
| 2 | `highpriestess` | The High Priestess |
|
||||
| 3 | `empress` | The Empress |
|
||||
| 4 | `emperor` | The Emperor |
|
||||
| 5 | `hierophant` | The Hierophant |
|
||||
| 6 | `lovers` | The Lovers |
|
||||
| 7 | `chariot` | The Chariot |
|
||||
| 8 | `justice` | Justice |
|
||||
| 9 | `hermit` | The Hermit |
|
||||
| 10 | `wheeloffortune` | Wheel of Fortune |
|
||||
| 11 | `strength` | Strength |
|
||||
| 12 | `hangedman` | The Hanged Man |
|
||||
| 13 | `death` | Death |
|
||||
| 14 | `temperance` | Temperance |
|
||||
| 15 | `devil` | The Devil |
|
||||
| 16 | `tower` | The Tower |
|
||||
| 17 | `star` | The Star |
|
||||
| 18 | `moon` | The Moon |
|
||||
| 19 | `sun` | The Sun |
|
||||
| 20 | `judgement` | Judgement |
|
||||
| 21 | `world` | The World |
|
||||
|
||||
## balatro-planets.png
|
||||
|
||||
- Frame size: **150x200** px
|
||||
- Frames: **12** — suggested grid **6 x 2** (image 900x400)
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `pluto` | Pluto |
|
||||
| 1 | `mercury` | Mercury |
|
||||
| 2 | `uranus` | Uranus |
|
||||
| 3 | `venus` | Venus |
|
||||
| 4 | `saturn` | Saturn |
|
||||
| 5 | `jupiter` | Jupiter |
|
||||
| 6 | `earth` | Earth |
|
||||
| 7 | `mars` | Mars |
|
||||
| 8 | `neptune` | Neptune |
|
||||
| 9 | `planetx` | Planet X |
|
||||
| 10 | `ceres` | Ceres |
|
||||
| 11 | `eris` | Eris |
|
||||
|
||||
## balatro-spectrals.png
|
||||
|
||||
- Frame size: **150x200** px
|
||||
- Frames: **15** — suggested grid **5 x 3** (image 750x600)
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `familiar` | Familiar |
|
||||
| 1 | `grim` | Grim |
|
||||
| 2 | `incantation` | Incantation |
|
||||
| 3 | `talisman` | Talisman |
|
||||
| 4 | `dejavu` | Deja Vu |
|
||||
| 5 | `trance` | Trance |
|
||||
| 6 | `medium` | Medium |
|
||||
| 7 | `aura` | Aura |
|
||||
| 8 | `wraith` | Wraith |
|
||||
| 9 | `sigil` | Sigil |
|
||||
| 10 | `ouija` | Ouija |
|
||||
| 11 | `ectoplasm` | Ectoplasm |
|
||||
| 12 | `immolate` | Immolate |
|
||||
| 13 | `blackhole` | Black Hole |
|
||||
| 14 | `hex` | Hex |
|
||||
|
||||
## balatro-vouchers.png
|
||||
|
||||
- Frame size: **190x250** px
|
||||
- Frames: **16** — suggested grid **8 x 2** (image 1520x500)
|
||||
- Poster / coupon style.
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `overstock` | Overstock |
|
||||
| 1 | `overstockplus` | Overstock Plus |
|
||||
| 2 | `clearance` | Clearance Sale |
|
||||
| 3 | `liquidation` | Liquidation |
|
||||
| 4 | `hone` | Hone |
|
||||
| 5 | `glowup` | Glow Up |
|
||||
| 6 | `rerollsurplus` | Reroll Surplus |
|
||||
| 7 | `rerollglut` | Reroll Glut |
|
||||
| 8 | `grabber` | Grabber |
|
||||
| 9 | `nachotong` | Nacho Tong |
|
||||
| 10 | `wasteful` | Wasteful |
|
||||
| 11 | `recyclomancy` | Recyclomancy |
|
||||
| 12 | `seedmoney` | Seed Money |
|
||||
| 13 | `moneytree` | Money Tree |
|
||||
| 14 | `crystalball` | Crystal Ball |
|
||||
| 15 | `telescope` | Telescope |
|
||||
|
||||
## balatro-packs.png
|
||||
|
||||
- Frame size: **180x240** px
|
||||
- Frames: **10** — suggested grid **5 x 2** (image 900x480)
|
||||
- Booster wrapper art; normal + jumbo variants are separate frames.
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `standard_normal` | Standard Pack |
|
||||
| 1 | `standard_jumbo` | Jumbo Standard Pack |
|
||||
| 2 | `arcana_normal` | Arcana Pack |
|
||||
| 3 | `arcana_jumbo` | Jumbo Arcana Pack |
|
||||
| 4 | `celestial_normal` | Celestial Pack |
|
||||
| 5 | `celestial_jumbo` | Jumbo Celestial Pack |
|
||||
| 6 | `buffoon_normal` | Buffoon Pack |
|
||||
| 7 | `buffoon_jumbo` | Jumbo Buffoon Pack |
|
||||
| 8 | `spectral_normal` | Spectral Pack |
|
||||
| 9 | `spectral_jumbo` | Jumbo Spectral Pack |
|
||||
|
||||
## balatro-deckbacks.png
|
||||
|
||||
- Frame size: **120x164** px
|
||||
- Frames: **8** — suggested grid **4 x 2** (image 480x328)
|
||||
- Card-back design per deck.
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `red` | Red Deck |
|
||||
| 1 | `blue` | Blue Deck |
|
||||
| 2 | `yellow` | Yellow Deck |
|
||||
| 3 | `green` | Green Deck |
|
||||
| 4 | `black` | Black Deck |
|
||||
| 5 | `magic` | Magic Deck |
|
||||
| 6 | `nebula` | Nebula Deck |
|
||||
| 7 | `ghost` | Ghost Deck |
|
||||
|
||||
## balatro-bossicons.png
|
||||
|
||||
- Frame size: **64x64** px
|
||||
- Frames: **24** — suggested grid **8 x 3** (image 512x192)
|
||||
- Small emblem per boss blind (shown on blind badges).
|
||||
|
||||
| # | id | name |
|
||||
|---|----|------|
|
||||
| 0 | `the_hook` | The Hook |
|
||||
| 1 | `the_ox` | The Ox |
|
||||
| 2 | `the_house` | The House |
|
||||
| 3 | `the_wall` | The Wall |
|
||||
| 4 | `the_wheel` | The Wheel |
|
||||
| 5 | `the_arm` | The Arm |
|
||||
| 6 | `the_club` | The Club |
|
||||
| 7 | `the_goad` | The Goad |
|
||||
| 8 | `the_window` | The Window |
|
||||
| 9 | `the_head` | The Head |
|
||||
| 10 | `the_psychic` | The Psychic |
|
||||
| 11 | `the_fish` | The Fish |
|
||||
| 12 | `the_water` | The Water |
|
||||
| 13 | `the_manacle` | The Manacle |
|
||||
| 14 | `the_eye` | The Eye |
|
||||
| 15 | `the_mouth` | The Mouth |
|
||||
| 16 | `the_plant` | The Plant |
|
||||
| 17 | `the_needle` | The Needle |
|
||||
| 18 | `the_flint` | The Flint |
|
||||
| 19 | `the_mark` | The Mark |
|
||||
| 20 | `the_pillar` | The Pillar |
|
||||
| 21 | `violet_vessel` | Violet Vessel |
|
||||
| 22 | `crimson_heart` | Crimson Heart |
|
||||
| 23 | `cerulean_bell` | Cerulean Bell |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Editions** (Foil/Holo/Polychrome/Negative) and **seals** are rendered as runtime overlays — do not paint them.
|
||||
- **Enhanced playing cards** (Bonus/Mult/Wild/Glass/Steel/Stone/Gold/Lucky) are procedural tints/glyphs on the standard face; no sheet needed.
|
||||
- Card backs for face-down cards reuse the shared `cardbacks` sheet.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# Balatro
|
||||
|
||||
> *The lights dim. A grinning paper jester unfolds itself from the top of the
|
||||
> deck and takes a theatrical bow.*
|
||||
>
|
||||
> Welcome, welcome, WELCOME to the greatest little card room outside of
|
||||
> reality! I'm the Joker — well, *a* Joker; you'll be collecting plenty more of
|
||||
> me — and this is **Balatro**, the game where poker hands are ammunition and
|
||||
> the house is an actual monster. Pull up a chair. The felt is melting, but
|
||||
> it's supposed to do that.
|
||||
|
||||
## The Goal (or: Feeding the Blinds)
|
||||
|
||||
A run is **8 Antes**. Each Ante serves you three **Blinds** in order: the
|
||||
**Small Blind**, the **Big Blind**, and a **Boss Blind**. Every Blind demands a
|
||||
chip score — *"Score at least 300"* and so on. Beat the score before you run
|
||||
out of hands and the Blind pays out; run dry and the run is over. Beat the Ante
|
||||
8 Boss and you win. (Then, if you're greedy — and I like greedy — **Endless
|
||||
Mode** keeps the Antes coming until the numbers stop fitting in the machine.)
|
||||
|
||||
- **Small and Big Blinds can be skipped.** You get nothing for it, but a few
|
||||
Jokers get *very* excited about skipping.
|
||||
- **Boss Blinds cannot be skipped**, and each one cheats in its own special
|
||||
way — debuffing suits, dealing your cards face down, taxing your favorite
|
||||
hand. Read the red text. Respect the red text.
|
||||
|
||||
## Playing Hands
|
||||
|
||||
Each round you get a number of **Hands** (default 4) and **Discards**
|
||||
(default 3):
|
||||
|
||||
1. Click up to **5 cards** in your hand to select them.
|
||||
2. **Play Hand** scores them as a poker hand — Pair, Flush, Full House, all
|
||||
the classics up through Straight Flush (and a few *illegal* ones you'll
|
||||
discover later).
|
||||
3. **Discard** throws up to 5 selected cards away and draws replacements.
|
||||
Discards are precious. Spend them like they're rent money.
|
||||
|
||||
## Chips × Mult (the entire game in one line)
|
||||
|
||||
Every hand scores **Chips × Mult**:
|
||||
|
||||
- The **hand type** sets the base — a level 1 Flush starts at 35 Chips × 4 Mult.
|
||||
- Each **scoring card** adds its rank in Chips (face cards 10, Aces 11).
|
||||
- Then your **Jokers** pile on: +Chips, +Mult, and the beautiful **×Mult**.
|
||||
- **Planet cards** level up hand types permanently. Play Flushes? Feed Jupiter.
|
||||
|
||||
The order matters: cards score left to right, then Jokers fire **left to
|
||||
right**. Drag Jokers to reorder them — put your ×Mult Jokers *after* your
|
||||
+Mult Jokers, or I will personally come to your house and sigh at you.
|
||||
|
||||
## Jokers
|
||||
|
||||
You can hold **5 Jokers** (more with the right tricks). They are the entire
|
||||
engine of this game: some are dumb and honest (+4 Mult, always), some scale
|
||||
forever (Ride the Bus), some retrigger cards, some print money, and some copy
|
||||
other Jokers like an unlicensed photocopier. Click a Joker to inspect or
|
||||
**sell** it. Buy them in the shop. Love them. Sacrifice them.
|
||||
|
||||
## The Shop
|
||||
|
||||
After every Blind you cash out — Blind reward, $1 per unused hand, plus
|
||||
**interest** ($1 for every $5 you hold, up to $5). Then the shop opens:
|
||||
|
||||
- **Cards for sale** — Jokers, Tarot / Planet cards, sometimes playing cards.
|
||||
- **Booster Packs** — pick 1 from a spread: Standard (playing cards), Arcana
|
||||
(Tarots), Celestial (Planets — used immediately), Buffoon (Jokers),
|
||||
Spectral (dangerous magic).
|
||||
- **Vouchers** — one per Ante, permanent run upgrades (more shop slots,
|
||||
cheaper rerolls, bigger interest cap...).
|
||||
- **Reroll** to refresh the card slots. The price climbs. The temptation too.
|
||||
|
||||
## Card Modifiers
|
||||
|
||||
Playing cards can be upgraded like little racehorses:
|
||||
|
||||
- **Enhancements** — Bonus (+30 Chips), Mult (+4), Wild (any suit), Glass
|
||||
(×2 Mult but may shatter), Steel (×1.5 while held), Stone (+50, no suit or
|
||||
rank), Gold ($3 if held at round end), Lucky (maybe +20 Mult, maybe $20).
|
||||
- **Editions** — Foil (+50 Chips), Holographic (+10 Mult), Polychrome
|
||||
(×1.5 Mult). Jokers can be **Negative**: they take up no slot at all.
|
||||
- **Seals** — Red retriggers the card, Gold pays $3 when scored, Blue makes a
|
||||
Planet if held at round end, Purple makes a Tarot when discarded.
|
||||
|
||||
## Tarot, Planet & Spectral Cards
|
||||
|
||||
Consumables live in the two slots at the top right (click to use or sell):
|
||||
|
||||
- **Tarots** transform your deck — enhance cards, change suits, raise ranks,
|
||||
destroy the chaff, double your money.
|
||||
- **Planets** level up a poker hand, permanently, for the rest of the run.
|
||||
- **Spectrals** are the back-room stuff: seals, editions, whole-hand
|
||||
transmutations. Powerful. Occasionally horrifying. Read twice, click once.
|
||||
- Targeted cards (like *The Lovers*) need you to **select cards in hand
|
||||
first**, so they can only be used mid-round.
|
||||
|
||||
## Decks
|
||||
|
||||
Eight decks, eight opening gambits — extra discards (Red), extra hands
|
||||
(Blue), head-start money (Yellow), a sixth Joker slot (Black), spooky shop
|
||||
Spectrals (Ghost), and more. Your run is saved automatically, so you can
|
||||
close the table and come back — the cards will wait. They have nowhere else
|
||||
to be.
|
||||
|
||||
> Now then. The Small Blind is sitting right there, fat and skippable.
|
||||
> Deal yourself in. *The jester folds back into the deck, still grinning.*
|
||||
|
|
@ -86,6 +86,7 @@ import AzulGame from './games/azul/AzulGame.js';
|
|||
import JumbleGame from './games/jumble/JumbleGame.js';
|
||||
import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js';
|
||||
import SWDBGGame from './games/swdbg/SWDBGGame.js';
|
||||
import BalatroGame from './games/balatro/BalatroGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -185,6 +186,7 @@ const config = {
|
|||
JumbleGame,
|
||||
DungeonBossGame,
|
||||
SWDBGGame,
|
||||
BalatroGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('swdbg-cards', 'data/swdbg-cards.json');
|
||||
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
||||
this.load.json('jumble', 'data/jumble.json');
|
||||
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
|
||||
this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png');
|
||||
this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png');
|
||||
this.load.image('spireclimb-act3', 'assets/images/spireclimb-act3.png');
|
||||
|
|
@ -244,9 +245,15 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
const swSheets = [swArt?.cardSheet, swArt?.baseSheet]
|
||||
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
|
||||
|
||||
if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0) {
|
||||
// Balatro drop-in spritesheets, same contract.
|
||||
const balArt = this.cache.json.get('balatro-artwork');
|
||||
const balSheets = [balArt?.jokerSheet, balArt?.tarotSheet, balArt?.planetSheet, balArt?.spectralSheet,
|
||||
balArt?.voucherSheet, balArt?.packSheet, balArt?.deckBackSheet, balArt?.bossIconSheet]
|
||||
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
|
||||
|
||||
if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0 || balSheets.length > 0) {
|
||||
for (const asset of toLoad) this.load.image(asset.key, asset.path);
|
||||
for (const s of [...scSheets, ...dbSheets, ...swSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight });
|
||||
for (const s of [...scSheets, ...dbSheets, ...swSheets, ...balSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight });
|
||||
await new Promise((resolve) => {
|
||||
this.load.once('complete', resolve);
|
||||
this.load.start();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
// verifyBalatro.js — headless engine verification for Balatro.
|
||||
// Content sanity, evaluator table tests, scoring goldens, greedy-bot full
|
||||
// runs across many seeds with state invariants, determinism (incl. mid-run
|
||||
// serialize round trip) and an endless smoke test. Pure-logic (no Phaser).
|
||||
//
|
||||
// node verifyBalatro.js (from the tools directory)
|
||||
|
||||
import {
|
||||
HAND_TYPES, HAND_BY_ID, handBase, anteBase, DECKS, DECK_BY_ID, TAROTS, PLANETS,
|
||||
SPECTRALS, VOUCHERS, VOUCHER_BY_ID, BOOSTERS, BOOSTER_WEIGHTS, BOOSTER_BY_ID,
|
||||
BOSSES, FINISHERS, BOSS_BY_ID, PLANET_BY_HAND, fmtChips, BLINDS,
|
||||
} from '../src/games/balatro/BalatroData.js';
|
||||
import { JOKERS, JOKER_BY_ID, makeJokerInstance, resolveEffectiveJokers, jokerPool } from '../src/games/balatro/BalatroJokers.js';
|
||||
import { evaluate, handContains, scorePlayedHand, cardHasSuit } from '../src/games/balatro/BalatroScoring.js';
|
||||
import {
|
||||
makeRng, newRun, serializeRun, deserializeRun, passives, cardsOf, handCards,
|
||||
beginAnte, selectBlind, skipBlind, validatePlay, playHand, discard,
|
||||
computeCashOut, collectCashOut, continueEndless, leaveShop, rerollShop, forfeitRound, anyLegalPlay,
|
||||
buyShopCard, buyVoucher, sellJoker, sellConsumable, buyPack, pickFromPack,
|
||||
skipPack, useConsumable, sortHand, blindChipsFor, isDebuffed, consumableDef,
|
||||
} from '../src/games/balatro/BalatroLogic.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const fails = [];
|
||||
function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); if (fails.length < 40) console.error('FAIL:', msg); } }
|
||||
|
||||
// ── 1. Content sanity ────────────────────────────────────────────────────────
|
||||
{
|
||||
const JOKER_KEYS = new Set(['id', 'name', 'rarity', 'cost', 'desc', 'copyable', 'initState',
|
||||
'onScored', 'onHeld', 'independent', 'cardRetriggers', 'heldRetriggers', 'onHandPlayed',
|
||||
'afterHand', 'onDiscard', 'onBlindStart', 'onBlindSelected', 'onCashOut', 'onRoundEnd',
|
||||
'onPackSkipped', 'onBossDefeated', 'onCardAdded', 'onConsumableUsed', 'passive', 'isDead']);
|
||||
const ids = new Set();
|
||||
for (const j of JOKERS) {
|
||||
ok(!ids.has(j.id), `joker ${j.id} unique`);
|
||||
ids.add(j.id);
|
||||
ok(['common', 'uncommon', 'rare'].includes(j.rarity), `joker ${j.id} rarity`);
|
||||
ok(typeof j.cost === 'number' && j.cost >= 1, `joker ${j.id} cost`);
|
||||
ok(typeof j.desc === 'string' || typeof j.desc === 'function', `joker ${j.id} desc`);
|
||||
for (const k of Object.keys(j)) ok(JOKER_KEYS.has(k), `joker ${j.id} unknown key '${k}'`);
|
||||
if (typeof j.desc === 'function') {
|
||||
const inst = makeJokerInstance(j.id, 1, makeRng(1));
|
||||
ok(typeof j.desc(inst) === 'string', `joker ${j.id} desc(inst) returns string`);
|
||||
}
|
||||
}
|
||||
console.log(`Jokers: ${JOKERS.length} (${jokerPool('common').length}C/${jokerPool('uncommon').length}U/${jokerPool('rare').length}R)`);
|
||||
ok(JOKERS.length >= 100, 'roster size >= 100');
|
||||
|
||||
const BOSS_KEYS = new Set(['id', 'name', 'desc', 'minAnte', 'reqMult', 'onBlindStart',
|
||||
'validPlay', 'cardDebuffed', 'drawTransform', 'afterHandPlayed', 'preScore', 'handSizeDelta']);
|
||||
for (const b of [...BOSSES, ...FINISHERS]) {
|
||||
for (const k of Object.keys(b)) ok(BOSS_KEYS.has(k), `boss ${b.id} unknown key '${k}'`);
|
||||
ok(typeof b.desc === 'string', `boss ${b.id} desc`);
|
||||
}
|
||||
ok(BOSSES.length >= 18 && FINISHERS.length === 3, 'boss pool sizes');
|
||||
|
||||
for (const h of HAND_TYPES) ok(!!PLANET_BY_HAND[h.id], `hand ${h.id} has a planet`);
|
||||
ok(PLANETS.length === HAND_TYPES.length, 'planet/hand bijection');
|
||||
|
||||
for (const v of VOUCHERS) { if (v.requires) ok(!!VOUCHER_BY_ID[v.requires], `voucher ${v.id} requires exists`); }
|
||||
for (const [bid] of BOOSTER_WEIGHTS) ok(!!BOOSTER_BY_ID[bid], `booster weight ${bid} exists`);
|
||||
for (const d of DECKS) {
|
||||
for (const vid of (d.mods.startVouchers || [])) ok(!!VOUCHER_BY_ID[vid], `deck ${d.id} voucher ${vid}`);
|
||||
for (const c of (d.mods.startConsumables || [])) {
|
||||
const pool = c.kind === 'tarot' ? TAROTS : c.kind === 'planet' ? PLANETS : SPECTRALS;
|
||||
ok(pool.some((x) => x.id === c.id), `deck ${d.id} consumable ${c.id}`);
|
||||
}
|
||||
}
|
||||
const cids = new Set();
|
||||
for (const c of [...TAROTS, ...PLANETS, ...SPECTRALS]) { ok(!cids.has(c.id), `consumable ${c.id} unique`); cids.add(c.id); }
|
||||
}
|
||||
|
||||
// ── 2. Evaluator table ───────────────────────────────────────────────────────
|
||||
{
|
||||
let uid = 1;
|
||||
const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra });
|
||||
const t = (cards, want, opts = {}, label = '') => {
|
||||
const r = evaluate(cards, opts);
|
||||
ok(r.handType === want, `evaluate ${label || want}: got ${r.handType}`);
|
||||
return r;
|
||||
};
|
||||
t([K(14, 'S'), K(13, 'S'), K(12, 'S'), K(11, 'S'), K(10, 'S')], 'straightflush', {}, 'royal flush');
|
||||
t([K(14, 'S'), K(2, 'H'), K(3, 'D'), K(4, 'C'), K(5, 'S')], 'straight', {}, 'ace-low straight');
|
||||
t([K(9, 'S'), K(10, 'H'), K(11, 'D'), K(12, 'C'), K(13, 'S')], 'straight', {}, 'K-high straight');
|
||||
t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'highcard', {}, 'gaps no shortcut');
|
||||
t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'straight', { shortcut: true }, 'shortcut straight');
|
||||
t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'straight', { fourFingers: true }, '4-card straight');
|
||||
t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'highcard', {}, '4 cards no straight');
|
||||
t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'H')], 'flush');
|
||||
t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H')], 'flush', { fourFingers: true }, '4-card flush');
|
||||
t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'flush', { smeared: true }, 'smeared flush');
|
||||
t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'highcard', {}, 'mixed no smeared');
|
||||
t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'S', { enhancement: 'wild' })], 'flush', {}, 'wild completes flush');
|
||||
t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(2, 'S')], 'fourofakind');
|
||||
t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'fullhouse');
|
||||
t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C')], 'threeofakind');
|
||||
t([K(5, 'S'), K(5, 'H'), K(2, 'D'), K(2, 'C'), K(9, 'S')], 'twopair');
|
||||
t([K(5, 'S'), K(5, 'H'), K(2, 'D')], 'pair');
|
||||
t([K(5, 'S'), K(9, 'H'), K(2, 'D')], 'highcard');
|
||||
t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(5, 'S')], 'fiveofakind', {}, '5oak off-suit');
|
||||
t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H')], 'flushfive');
|
||||
t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(2, 'H'), K(2, 'H')], 'flushhouse');
|
||||
// Stones: excluded from detection, always score.
|
||||
{
|
||||
const stone = K(9, 'S', { enhancement: 'stone' });
|
||||
const r = t([K(5, 'S'), K(5, 'H'), stone], 'pair', {}, 'pair + stone');
|
||||
ok(r.scoringUids.includes(stone.uid), 'stone always scores');
|
||||
const r2 = t([stone], 'highcard', {}, 'stone-only hand');
|
||||
ok(r2.scoringUids.includes(stone.uid), 'lone stone scores');
|
||||
}
|
||||
// Scoring set shapes.
|
||||
{
|
||||
const cards = [K(5, 'S'), K(5, 'H'), K(9, 'D'), K(11, 'C'), K(13, 'S')];
|
||||
const r = evaluate(cards, {});
|
||||
ok(r.handType === 'pair' && r.scoringUids.length === 2, 'pair scores 2 cards');
|
||||
const hi = evaluate([K(5, 'S'), K(9, 'H'), K(13, 'D')], {});
|
||||
ok(hi.scoringUids.length === 1, 'high card scores 1');
|
||||
}
|
||||
// handContains
|
||||
ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'pair'), 'full house contains pair');
|
||||
ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'threeofakind'), 'full house contains trips');
|
||||
ok(!handContains([K(5, 'S'), K(5, 'H'), K(9, 'D')], 'threeofakind'), 'pair lacks trips');
|
||||
ok(cardHasSuit(K(5, 'H'), 'D', true) && !cardHasSuit(K(5, 'H'), 'D', false), 'smeared suit merge');
|
||||
ok(cardHasSuit(K(5, 'S', { enhancement: 'wild' }), 'D', false), 'wild any suit');
|
||||
ok(!cardHasSuit(K(5, 'D', { enhancement: 'stone' }), 'D', false), 'stone no suit');
|
||||
}
|
||||
|
||||
// ── 3. Scoring goldens ───────────────────────────────────────────────────────
|
||||
{
|
||||
let uid = 100;
|
||||
const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra });
|
||||
function fixture(jokerIds = [], deckId = 'red') {
|
||||
const run = newRun(deckId, 42);
|
||||
run.jokers = jokerIds.map((id, i) => makeJokerInstance(id, 9000 + i, makeRng(7)));
|
||||
return run;
|
||||
}
|
||||
function score(run, played, held = [], boss = null, seed = 1) {
|
||||
const p = passives(run);
|
||||
return scorePlayedHand(run, played, held, {
|
||||
effJokers: resolveEffectiveJokers(run), passives: p, rng: makeRng(seed),
|
||||
isDebuffed: () => false, boss,
|
||||
});
|
||||
}
|
||||
|
||||
// Pair of Kings + Joker = (10+10+10) × (2+4) = 180
|
||||
{
|
||||
const run = fixture(['joker']);
|
||||
const r = score(run, [K(13, 'S'), K(13, 'H')]);
|
||||
ok(r.score === 180, `pair of kings + Joker = 180 (got ${r.score})`);
|
||||
ok(r.events[0].t === 'base' && r.events[r.events.length - 1].t === 'total', 'trace bookends');
|
||||
ok(r.events.every((e) => typeof e.chipsAfter === 'number' && typeof e.multAfter === 'number'), 'events carry counters');
|
||||
}
|
||||
// Red seal + Hack on a played 4 → 3 triggers of its 4 chips: highcard 5 + 4*3 = 17
|
||||
{
|
||||
const run = fixture(['hack']);
|
||||
const r = score(run, [K(4, 'D', { seal: 'red' })]);
|
||||
ok(r.score === 17 * 1, `red seal + Hack triggers x3 (got ${r.score})`);
|
||||
ok(r.events.filter((e) => e.t === 'retrigger').length === 2, 'two retrigger markers');
|
||||
}
|
||||
// Blueprint copies The Duo: pair of 2s → mult 2 × 2 × 2 = 8, chips 10+2+2=14 → 112
|
||||
{
|
||||
const run = fixture(['blueprint', 'duo']);
|
||||
const r = score(run, [K(2, 'S'), K(2, 'H')]);
|
||||
ok(r.score === 112, `Blueprint copies Duo = 112 (got ${r.score})`);
|
||||
}
|
||||
// Brainstorm copies leftmost: [duo, brainstorm] → same 112
|
||||
{
|
||||
const run = fixture(['duo', 'brainstorm']);
|
||||
const r = score(run, [K(2, 'S'), K(2, 'H')]);
|
||||
ok(r.score === 112, `Brainstorm copies leftmost Duo = 112 (got ${r.score})`);
|
||||
}
|
||||
// Blueprint pointing at Blueprint→Duo chain resolves through
|
||||
{
|
||||
const run = fixture(['blueprint', 'blueprint', 'duo']);
|
||||
const r = score(run, [K(2, 'S'), K(2, 'H')]);
|
||||
ok(r.score === 14 * 16, `Blueprint chain = ${14 * 16} (got ${r.score})`);
|
||||
}
|
||||
// The Flint halves base: pair of kings → ceil(10/2)=5 +10+10 chips, mult ceil(2/2)=1 → 25
|
||||
{
|
||||
const run = fixture([]);
|
||||
const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_flint);
|
||||
ok(r.score === 25, `The Flint halving = 25 (got ${r.score})`);
|
||||
}
|
||||
// Debuffed card counts for hand type but contributes 0
|
||||
{
|
||||
const run = fixture([]);
|
||||
const cards = [K(13, 'S'), K(13, 'H', { debuffed: true })];
|
||||
const r = score(run, cards);
|
||||
ok(r.handType === 'pair' && r.score === (10 + 10) * 2, `debuffed contributes 0 (got ${r.score})`);
|
||||
}
|
||||
// Steel held ×1.5: high card 5+11=16 chips... A♠ played (11), steel held: mult 1×1.5=1.5 → floor(16)*1.5=24
|
||||
{
|
||||
const run = fixture([]);
|
||||
const r = score(run, [K(14, 'S')], [K(9, 'H', { enhancement: 'steel' })]);
|
||||
ok(r.score === Math.floor(16) * 1.5, `steel held x1.5 (got ${r.score})`);
|
||||
}
|
||||
// Glass ×2 then Polychrome ×1.5 on the same card: high card A: chips 5+11+? poly? glass:
|
||||
{
|
||||
const run = fixture([]);
|
||||
const r = score(run, [K(14, 'S', { enhancement: 'glass', edition: 'poly' })], [], null, 3);
|
||||
ok(r.score === 16 * 3, `glass x2 then poly x1.5 = mult 3 (got ${r.score})`);
|
||||
}
|
||||
// Foil +50 chips on scored card
|
||||
{
|
||||
const run = fixture([]);
|
||||
const r = score(run, [K(14, 'S', { edition: 'foil' })]);
|
||||
ok(r.score === (5 + 11 + 50) * 1, `foil +50 (got ${r.score})`);
|
||||
}
|
||||
// The Arm: hand level drops before scoring (level 2 pair → level 1)
|
||||
{
|
||||
const run = fixture([]);
|
||||
run.handLevels.pair = 2;
|
||||
const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_arm);
|
||||
ok(run.handLevels.pair === 1, 'The Arm decrements level');
|
||||
ok(r.score === 180 / 6 * 2, `The Arm scores at reduced level (got ${r.score})`);
|
||||
}
|
||||
// Lucky determinism: same seed → same score twice
|
||||
{
|
||||
const mk = () => {
|
||||
const run = fixture([]);
|
||||
return score(run, [K(7, 'S', { enhancement: 'lucky' }), K(7, 'H', { enhancement: 'lucky' })], [], null, 12345);
|
||||
};
|
||||
ok(mk().score === mk().score, 'lucky cards deterministic under seed');
|
||||
}
|
||||
// Gold seal pays $3 per trigger
|
||||
{
|
||||
const run = fixture([]);
|
||||
const before = run.gold;
|
||||
score(run, [K(9, 'S', { seal: 'gold' })]);
|
||||
ok(run.gold === before + 3, 'gold seal +$3');
|
||||
}
|
||||
// Ride the Bus grows before contributing; resets on face
|
||||
{
|
||||
const run = fixture(['ridethebus']);
|
||||
const r1 = score(run, [K(9, 'S'), K(9, 'H')]);
|
||||
ok(run.jokers[0].state.n === 1 && r1.score === (10 + 9 + 9) * 3, `RTB first hand +1 (got ${r1.score})`);
|
||||
score(run, [K(13, 'S')]);
|
||||
ok(run.jokers[0].state.n === 0, 'RTB resets on face card');
|
||||
}
|
||||
// Ice Cream decays after the hand
|
||||
{
|
||||
const run = fixture(['icecream']);
|
||||
const r = score(run, [K(9, 'S')]);
|
||||
ok(r.score === (5 + 9 + 100) * 1, `Ice Cream +100 first hand (got ${r.score})`);
|
||||
ok(run.jokers[0].state.n === undefined && run.jokers[0].state.chips === 95, 'Ice Cream decayed to 95');
|
||||
}
|
||||
// Splash: every played card scores
|
||||
{
|
||||
const run = fixture(['splash']);
|
||||
const r = score(run, [K(13, 'S'), K(13, 'H'), K(2, 'D'), K(3, 'C'), K(4, 'S')]);
|
||||
ok(r.score === (10 + 10 + 10 + 2 + 3 + 4) * 2, `Splash scores all (got ${r.score})`);
|
||||
}
|
||||
// Stencil: empty slots + itself
|
||||
{
|
||||
const run = fixture(['stencil']); // 5 slots, 1 used → 4 empty + itself = x5
|
||||
const r = score(run, [K(9, 'S')]);
|
||||
ok(r.score === 14 * 5, `Stencil x5 (got ${r.score})`);
|
||||
}
|
||||
// Baseball Card: uncommon jokers give x1.5
|
||||
{
|
||||
const run = fixture(['baseball', 'hack', 'mime']);
|
||||
const r = score(run, [K(9, 'S')]);
|
||||
ok(r.score === 14 * 1 * 1.5 * 1.5, `Baseball x1.5 per uncommon (got ${r.score})`);
|
||||
}
|
||||
// Hiker: permanent +5 chips accumulates
|
||||
{
|
||||
const run = fixture(['hiker']);
|
||||
const card = K(9, 'S');
|
||||
score(run, [card]);
|
||||
ok(card.permaChips === 5, 'Hiker adds permaChips');
|
||||
const r2 = score(run, [card]);
|
||||
ok(r2.score === (5 + 9 + 5) * 1, `Hiker perma chips apply next play (got ${r2.score})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Greedy-bot full runs ──────────────────────────────────────────────────
|
||||
function combos(arr, k) {
|
||||
const out = [];
|
||||
const rec = (start, cur) => {
|
||||
if (cur.length === k) { out.push(cur.slice()); return; }
|
||||
for (let i = start; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); }
|
||||
};
|
||||
rec(0, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bestPlay(run) {
|
||||
const hand = run.hand.slice();
|
||||
const p = passives(run);
|
||||
let best = null;
|
||||
for (const k of [5, 4, 3, 2, 1]) {
|
||||
if (hand.length < k) continue;
|
||||
for (const uids of combos(hand, Math.min(k, hand.length))) {
|
||||
if (validatePlay(run, uids)) continue;
|
||||
const { handType } = evaluate(cardsOf(run, uids), p);
|
||||
const base = handBase(handType, run.handLevels[handType] || 1);
|
||||
const est = base.chips * base.mult;
|
||||
if (!best || est > best.est) best = { uids, est, handType };
|
||||
}
|
||||
if (best && k <= 3) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function invariants(run, tag) {
|
||||
const uids = run.deck.map((c) => c.uid);
|
||||
ok(new Set(uids).size === uids.length, `${tag}: deck uids unique`);
|
||||
const inDeck = new Set(uids);
|
||||
ok(run.hand.every((u) => inDeck.has(u)), `${tag}: hand ⊆ deck`);
|
||||
ok(run.drawPile.every((u) => inDeck.has(u)), `${tag}: drawPile ⊆ deck`);
|
||||
ok(run.hand.every((u) => !run.drawPile.includes(u)), `${tag}: hand ∩ drawPile empty`);
|
||||
const p = passives(run);
|
||||
ok(run.gold >= p.creditFloor - 0.001, `${tag}: gold ${run.gold} >= floor ${p.creditFloor}`);
|
||||
ok(run.handsLeft >= 0 && run.discardsLeft >= 0, `${tag}: counters >= 0`);
|
||||
ok(run.consumables.length <= p.consumableSlots, `${tag}: consumables within slots`);
|
||||
// Slots can shrink after acquisition (a Negative joker destroyed by Madness /
|
||||
// Hex, or sold) — over-cap is legal then; only gross blowout is a bug.
|
||||
ok(run.jokers.filter((j) => j.edition !== 'negative').length <= p.jokerSlots + 2, `${tag}: jokers within slots (+slack)`);
|
||||
}
|
||||
|
||||
function botRun(seed, deckId) {
|
||||
const rng = makeRng(seed ^ 0x9e3779b9);
|
||||
const run = newRun(deckId, seed);
|
||||
let steps = 0;
|
||||
const MAX = 4000;
|
||||
while (run.phase !== 'over' && steps++ < MAX) {
|
||||
if (steps % 25 === 0) invariants(run, `seed ${seed} step ${steps}`);
|
||||
if (run.phase === 'blindselect') {
|
||||
if (BLINDS[run.blind].skippable && rng() < 0.08) { skipBlind(run); continue; }
|
||||
const r = selectBlind(run);
|
||||
ok(r.ok, `seed ${seed}: selectBlind ok`);
|
||||
} else if (run.phase === 'playing') {
|
||||
// Occasionally use a consumable to exercise paths.
|
||||
if (run.consumables.length && rng() < 0.35) {
|
||||
const inst = rng.pick(run.consumables);
|
||||
const def = consumableDef(inst);
|
||||
const n = def.targets || 0;
|
||||
const targets = n ? run.hand.slice(0, def.effect && def.effect.kind === 'death' ? 2 : n) : [];
|
||||
useConsumable(run, inst.uid, targets);
|
||||
}
|
||||
const p = passives(run);
|
||||
const best = bestPlay(run);
|
||||
if (!best) {
|
||||
// No legal play (boss constraints); discard toward one, else forfeit.
|
||||
if (run.discardsLeft > 0 && run.hand.length) {
|
||||
const r = discard(run, run.hand.slice(-Math.min(3, run.hand.length)));
|
||||
ok(r.ok, `seed ${seed}: fallback discard ok`);
|
||||
continue;
|
||||
}
|
||||
ok(!anyLegalPlay(run), `seed ${seed}: forfeit only when truly stuck`);
|
||||
const r = forfeitRound(run);
|
||||
ok(r.ok && run.phase === 'over', `seed ${seed}: forfeit ends run`);
|
||||
continue;
|
||||
}
|
||||
if (best.est < 40 && run.discardsLeft > 0 && rng() < 0.6 && run.hand.length > 3) {
|
||||
const worst = run.hand.slice().sort((a, b) => {
|
||||
const ca = cardsOf(run, [a])[0], cb = cardsOf(run, [b])[0];
|
||||
return ca.rank - cb.rank;
|
||||
}).slice(0, 3);
|
||||
const r = discard(run, worst);
|
||||
ok(r.ok, `seed ${seed}: discard ok`);
|
||||
continue;
|
||||
}
|
||||
const r = playHand(run, best.uids);
|
||||
ok(r.ok, `seed ${seed}: playHand ok (${r.error || ''})`);
|
||||
if (r.ok) {
|
||||
ok(r.trace.events.length >= 2, `seed ${seed}: trace has events`);
|
||||
ok(Number.isFinite(r.trace.score) || run.endless, `seed ${seed}: finite score`);
|
||||
}
|
||||
} else if (run.phase === 'cashout') {
|
||||
const r = collectCashOut(run);
|
||||
ok(r.ok, `seed ${seed}: collectCashOut ok`);
|
||||
} else if (run.phase === 'shop') {
|
||||
if (run.pack) {
|
||||
const r = pickFromPack(run, 0);
|
||||
if (!r.ok) skipPack(run);
|
||||
continue;
|
||||
}
|
||||
const p = passives(run);
|
||||
let acted = false;
|
||||
// Buy first affordable shop card.
|
||||
for (let i = 0; i < run.shop.cards.length; i++) {
|
||||
const item = run.shop.cards[i];
|
||||
if (run.gold - item.cost >= 0) {
|
||||
const r = buyShopCard(run, i);
|
||||
if (r.ok) { acted = true; break; }
|
||||
}
|
||||
}
|
||||
if (!acted && run.shop.voucher && !run.shop.voucher.sold && run.gold >= run.shop.voucher.cost + 4) {
|
||||
if (buyVoucher(run).ok) acted = true;
|
||||
}
|
||||
if (!acted && run.shop.packs.length && run.gold >= run.shop.packs[0].cost + 2 && rng() < 0.7) {
|
||||
if (buyPack(run, 0).ok) continue;
|
||||
}
|
||||
if (!acted && rng() < 0.2 && run.gold > (run.shop.rerollCost + 6)) {
|
||||
rerollShop(run);
|
||||
continue;
|
||||
}
|
||||
if (!acted) {
|
||||
// Sometimes sell a joker to exercise selling.
|
||||
if (run.jokers.length > 4 && rng() < 0.1) sellJoker(run, rng.pick(run.jokers).uid);
|
||||
const r = leaveShop(run);
|
||||
ok(r.ok, `seed ${seed}: leaveShop ok`);
|
||||
}
|
||||
} else {
|
||||
ok(false, `seed ${seed}: unknown phase ${run.phase}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ok(steps < MAX, `seed ${seed}: run terminated (${steps} steps)`);
|
||||
invariants(run, `seed ${seed} final`);
|
||||
return run;
|
||||
}
|
||||
|
||||
{
|
||||
const SEEDS = 200;
|
||||
const anteReached = [];
|
||||
let wins = 0;
|
||||
for (let s = 1; s <= SEEDS; s++) {
|
||||
const deckId = DECKS[s % DECKS.length].id;
|
||||
let run;
|
||||
try {
|
||||
run = botRun(s * 7919, deckId);
|
||||
} catch (e) {
|
||||
ok(false, `seed ${s * 7919} (${deckId}) threw: ${e.stack.split('\n').slice(0, 3).join(' | ')}`);
|
||||
continue;
|
||||
}
|
||||
anteReached.push(run.stats.bestAnte);
|
||||
if (run.won) wins++;
|
||||
}
|
||||
const dist = {};
|
||||
for (const a of anteReached) dist[a] = (dist[a] || 0) + 1;
|
||||
console.log('Bot ante distribution:', JSON.stringify(dist), `wins: ${wins}/${SEEDS}`);
|
||||
const reach4 = anteReached.filter((a) => a >= 4).length;
|
||||
ok(reach4 >= SEEDS * 0.05, `>=5% of seeds reach ante 4 (got ${reach4}/${SEEDS})`);
|
||||
ok(anteReached.length && Math.max(...anteReached) >= 5, 'some seed reaches ante 5+');
|
||||
}
|
||||
|
||||
// ── 5. Determinism ───────────────────────────────────────────────────────────
|
||||
{
|
||||
const play = (seed) => {
|
||||
const run = newRun('blue', seed);
|
||||
const traces = [];
|
||||
let guard = 0;
|
||||
while (run.phase !== 'over' && guard++ < 400) {
|
||||
if (run.phase === 'blindselect') selectBlind(run);
|
||||
else if (run.phase === 'playing') {
|
||||
const best = bestPlay(run) || { uids: run.hand.slice(0, Math.min(5, run.hand.length)) };
|
||||
const r = playHand(run, best.uids);
|
||||
if (r.ok) traces.push(r.trace.score);
|
||||
else break;
|
||||
} else if (run.phase === 'cashout') collectCashOut(run);
|
||||
else if (run.phase === 'shop') {
|
||||
if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; }
|
||||
if (run.shop.cards.length && run.gold >= run.shop.cards[0].cost) buyShopCard(run, 0);
|
||||
leaveShop(run);
|
||||
}
|
||||
}
|
||||
return { snap: serializeRun(run), traces: traces.join(',') };
|
||||
};
|
||||
const a = play(777), b = play(777);
|
||||
ok(a.snap === b.snap, 'same seed → identical final run');
|
||||
ok(a.traces === b.traces, 'same seed → identical score traces');
|
||||
|
||||
// Mid-run serialize round trip continues identically.
|
||||
const run1 = newRun('red', 4242);
|
||||
selectBlind(run1);
|
||||
const best1 = bestPlay(run1);
|
||||
playHand(run1, best1.uids);
|
||||
const run2 = deserializeRun(serializeRun(run1));
|
||||
ok(run2 !== null, 'deserialize succeeds');
|
||||
const cont = (run) => {
|
||||
let guard = 0;
|
||||
const log = [];
|
||||
while (run.phase !== 'over' && guard++ < 200) {
|
||||
if (run.phase === 'blindselect') selectBlind(run);
|
||||
else if (run.phase === 'playing') {
|
||||
const b = bestPlay(run) || { uids: run.hand.slice(0, 1) };
|
||||
const r = playHand(run, b.uids);
|
||||
if (!r.ok) break;
|
||||
log.push(r.trace.score);
|
||||
} else if (run.phase === 'cashout') collectCashOut(run);
|
||||
else if (run.phase === 'shop') { if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; } leaveShop(run); }
|
||||
}
|
||||
return log.join(',');
|
||||
};
|
||||
ok(cont(run1) === cont(run2), 'serialize round trip continues identically');
|
||||
}
|
||||
|
||||
// ── 6. Endless smoke ─────────────────────────────────────────────────────────
|
||||
{
|
||||
const run = newRun('red', 31337);
|
||||
let lastReq = 0;
|
||||
let guard = 0;
|
||||
let overflow = false;
|
||||
while (run.phase !== 'over' && run.ante <= 20 && guard++ < 3000) {
|
||||
if (run.phase === 'blindselect') {
|
||||
selectBlind(run);
|
||||
ok(run.blindChips > 0 && Number.isFinite(run.blindChips), `ante ${run.ante} requirement finite`);
|
||||
if (run.blind === 'small') { ok(run.blindChips >= lastReq, `ante ${run.ante} small >= previous small (${run.blindChips} vs ${lastReq})`); lastReq = run.blindChips; }
|
||||
ok(typeof fmtChips(run.blindChips) === 'string' && !fmtChips(run.blindChips).includes('NaN'), 'fmtChips clean');
|
||||
} else if (run.phase === 'playing') {
|
||||
run.roundScore = run.blindChips - 1; // god hand: any valid play wins
|
||||
// Respect boss constraints: include the forced card, satisfy The Psychic.
|
||||
let sel = run.hand.slice(0, Math.min(5, run.hand.length));
|
||||
const forced = run.bossState.forcedUid;
|
||||
if (forced && !sel.includes(forced)) sel = [forced, ...sel.slice(0, 4)];
|
||||
let r = playHand(run, sel);
|
||||
if (!r.ok) r = playHand(run, forced ? [forced] : [run.hand[0]]);
|
||||
if (!r.ok) {
|
||||
if (run.discardsLeft > 0) { discard(run, [run.hand[run.hand.length - 1]]); continue; }
|
||||
forfeitRound(run);
|
||||
}
|
||||
} else if (run.phase === 'cashout') {
|
||||
collectCashOut(run);
|
||||
if (run.phase === 'over' && run.won && !run.endless) { continueEndless(run); }
|
||||
} else if (run.phase === 'shop') {
|
||||
if (run.pack) { skipPack(run); continue; }
|
||||
leaveShop(run);
|
||||
}
|
||||
}
|
||||
ok(run.ante >= 20 || run.phase === 'over', `endless reached ante ${run.ante}`);
|
||||
ok(run.endless && run.won, 'endless flag + win recorded');
|
||||
ok(anteBase(30) > anteBase(20) && anteBase(20) > anteBase(9), 'endless scaling increases');
|
||||
ok(Number.isFinite(anteBase(60)), 'anteBase clamps before Infinity');
|
||||
ok(fmtChips(anteBase(25)).length > 0 && fmtChips(1e15).includes('e'), 'e-notation past 1e11');
|
||||
}
|
||||
|
||||
console.log(`\nverifyBalatro: ${pass} passed, ${fail} failed`);
|
||||
if (fail) { console.error(fails.slice(0, 60).join('\n')); process.exit(1); }
|
||||
Loading…
Reference in New Issue