// 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.animating = true; scene._pendingDeal = new Set(run.hand); 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, info; if (item.slot === 'joker') { cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 }); info = () => scene.jokerInfo(item.id, null, item.edition); } else if (item.slot === 'playing') { cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 }); info = () => scene.playingCardInfo(item.card); } else { cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 }); info = () => scene.consumableInfo(item.slot, item.id); } scene.add2(cont); scene.addHoverTilt(cont, { info }); 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); const kindName = { playing: 'playing', tarot: 'Tarot', planet: 'Planet', joker: 'Joker', spectral: 'Spectral' }[def.what]; scene.addHoverTilt(cont, { info: () => ({ title: def.name, tag: 'Booster Pack', lines: [{ text: `Choose ${def.picks} of ${def.options} ${kindName} cards`, color: C.ink }], }), }); 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, { info: () => ({ title: v.name, tag: 'Voucher', tagColor: '#7ce0c0', stroke: C.voucher, lines: [{ text: v.desc, color: C.ink }], }), }); 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, info; if (opt.what === 'playing') { cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h }); info = () => scene.playingCardInfo(opt.card); } else if (opt.what === 'joker') { cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h }); info = () => scene.jokerInfo(opt.id, null, opt.edition); } else { cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h }); info = () => scene.consumableInfo(opt.what, opt.id); } scene.add2(cont); scene.addHoverTilt(cont, { info }); 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' }); }