fertig-classic-games/src/games/balatro/BalatroViews.js

407 lines
20 KiB
JavaScript

// 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' });
}