// 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, rankChips, 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 { attachCrt } from './BalatroCrtPipeline.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' }; // Brighter variants that read on the dark tooltip panel. const TIP_SUIT_HEX = { S: '#e8e4f0', H: '#ff6b5e', D: '#ffa24f', C: '#a8c8ff' }; const EDITION_HEX = { foil: '#5f8ad0', holo: '#5fd0d0', poly: '#d05fd0', negative: '#b9a8e8' }; const SEAL_HEX = { red: '#ff6b5e', blue: '#5aa9f4', gold: '#e7c14b', purple: '#c88aff' }; const RARITY_HEX = { common: '#4f9ad4', uncommon: '#4fae6a', rare: '#d44f4f' }; // Scoring-hum rate curve: the looped hum's playback rate (and so its register) // climbs with log10 of the running score, from MIN at 0 up to a MAX cap. const HUM_MIN_RATE = 0.75, HUM_MAX_RATE = 8, HUM_RATE_PER_LOG10 = 0.7, HUM_VOLUME = 0.45; // Max jitter (px) of the chips/mult boxes when the hum reaches HUM_MAX_RATE. const HUM_SHAKE_MAX = 6; 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; this._pendingDeal = null; this._scoreHum = null; this._hideUids = null; this._shakeTargets = null; this._roundScoreOverride = null; } 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); this.crt = attachCrt(this); this.events.once('shutdown', () => this.crt.destroy()); // Fast-forward: tapping during a scoring animation snaps to the end. this.input.on('pointerdown', () => { if (this.animating) this._skipAnim = true; }); // A plain click must not count as a drag (joker reordering), or clicking // a joker would fire dragend and re-render over its sell panel. this.input.dragDistanceThreshold = 8; // The sound manager is game-global; don't leave the hum looping if the // scene exits mid-scoring. this.events.once('shutdown', () => this.stopScoreHum(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(); } // Chips/mult boxes jitter with the scoring hum: amplitude follows the hum's // current (tweened) rate, so the shake intensifies as the register rises // and settles the moment the hum stops. update() { const targets = this._shakeTargets; if (!targets) return; const hum = this._scoreHum; let amp = 0; if (hum) { const n = Phaser.Math.Clamp((hum.rate - HUM_MIN_RATE) / (HUM_MAX_RATE - HUM_MIN_RATE), 0, 1); amp = n * HUM_SHAKE_MAX; } for (const t of targets) { if (!t.obj.active) continue; if (amp < 0.05) t.obj.setPosition(t.bx, t.by); else t.obj.setPosition(t.bx + Phaser.Math.FloatBetween(-amp, amp), t.by + Phaser.Math.FloatBetween(-amp, amp)); } } // ── 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() { // Some fx (explosion bursts, floaters) can still be mid-tween when a // view swap tears the layers down; destroying their target out from // under a running tween throws, so stop those tweens first. this.tweens.killTweensOf(this.viewLayer.list); this.tweens.killTweensOf(this.handLayer.list); this.tweens.killTweensOf(this.fxLayer.list); this.viewLayer.removeAll(true); this.handLayer.removeAll(true); this.fxLayer.removeAll(true); this._handSprites = {}; this._playedSprites = {}; this._jokerSprites = {}; this._toastText = null; this._tooltip = null; this._tooltipOwner = null; this._shakeTargets = null; } setView(v) { this.view = v; this.selected = []; this.pendingConsumable = null; this.pendingJoker = null; if (v === 'play' && this.run && this.run.blind === 'boss') this.crt.pulse(0.7); if (v === 'gameover') this.crt.pulse(1.0); 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 || 'm6x11, "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: 'm6x11, "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; const frame = map[id]; if (this.frameIsBlank(sheet.key, frame)) return null; return { key: sheet.key, frame }; } // Sheets are painted incrementally — unpainted frames are left transparent/white. // Sample a few points per frame and treat it as "not yet drawn" if none hit ink, // so unfinished sheets fall back to procedural cards instead of showing blanks. frameIsBlank(key, frame) { if (!this._blankFrameCache) this._blankFrameCache = {}; const cacheKey = `${key}:${frame}`; if (this._blankFrameCache[cacheKey] !== undefined) return this._blankFrameCache[cacheKey]; const src = this.textures.getFrame(key, frame); let blank = true; if (src) { const steps = 5; outer: for (let i = 1; i < steps; i++) { for (let j = 1; j < steps; j++) { const x = Math.floor((i / steps) * src.width); const y = Math.floor((j / steps) * src.height); const px = this.textures.getPixel(x, y, key, frame); if (px && px.alpha > 10 && !(px.red > 245 && px.green > 245 && px.blue > 245)) { blank = false; break outer; } } } } this._blankFrameCache[cacheKey] = blank; return blank; } // Bakes a rounded-corner copy of a sheet frame into its own canvas texture // (cached by key:frame:radius) so drop-in art matches the rounded look of // the procedural cards without needing a live geometry mask on the sprite. roundedArtKey(key, frame, radius = 10) { if (!this._roundedArtCache) this._roundedArtCache = {}; const cacheKey = `${key}:${frame}:${radius}`; if (this._roundedArtCache[cacheKey]) return this._roundedArtCache[cacheKey]; const src = this.textures.getFrame(key, frame); if (!src) return null; const outKey = `balatro-rounded:${cacheKey}`; if (!this.textures.exists(outKey)) { const w = src.cutWidth, h = src.cutHeight; const r = Math.min(radius, w / 2, h / 2); const canvasTex = this.textures.createCanvas(outKey, w, h); const ctx = canvasTex.getContext(); ctx.beginPath(); ctx.moveTo(r, 0); ctx.arcTo(w, 0, w, h, r); ctx.arcTo(w, h, 0, h, r); ctx.arcTo(0, h, 0, 0, r); ctx.arcTo(0, 0, w, 0, r); ctx.closePath(); ctx.clip(); ctx.drawImage(src.source.image, src.cutX, src.cutY, w, h, 0, 0, w, h); canvasTex.refresh(); } this._roundedArtCache[cacheKey] = outKey; return outKey; } // Draws a drop-in art frame with rounded corners at (0,0) inside a container. addRoundedArt(container, key, frame, w, h, radius, x = 0, y = 0) { const roundedKey = this.roundedArtKey(key, frame, radius); const img = roundedKey ? this.add.image(x, y, roundedKey) : this.add.image(x, y, key, frame); img.setDisplaySize(w, h); container.add(img); return img; } // ── 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) { const af = this.run ? this.artFrame('deckBackSheet', 'deckBacks', this.run.deckId) : null; if (af) { this.addRoundedArt(cont, af.key, af.frame, w, h, 10); } else 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: 'm6x11, 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: 'm6x11, 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: 'm6x11, 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: 'm6x11, "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: 'm6x11, 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: 'm6x11, "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: 'm6x11, "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: 'm6x11, "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: 'm6x11, 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) { this.addRoundedArt(cont, af.key, af.frame, w, h, 10); const rg = this.add.graphics(); rg.lineStyle(3, C.rarity[def.rarity], 1); rg.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); cont.add(rg); } 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: 'm6x11, "Julius Sans One"', fontSize: `${Math.min(20, Math.round(320 / 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: 'm6x11, "Julius Sans One"', fontSize: '17px', 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: 'm6x11, "Julius Sans One"', fontSize: '13px', 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: 'm6x11, "Julius Sans One"', fontSize: '18px', 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) { this.addRoundedArt(cont, af.key, af.frame, w, h, 10); } 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: 'm6x11, "Julius Sans One"', fontSize: `${Math.min(19, Math.round(340 / 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: 'm6x11, "Julius Sans One"', fontSize: '16px', 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: 'm6x11, Georgia, serif', fontSize: '22px', color: `#${tint.toString(16).padStart(6, '0')}` }).setOrigin(0.5)); } cont.setSize(w, h); return cont; } // ── hover tooltips ──────────────────────────────────────────────────────── // Balatro-style info popup: a panel above (or below, if there's no room) // the hovered card describing exactly what it's worth. `info` objects are // { title, titleColor?, tag?, tagColor?, stroke?, lines: [{ text, color? }] }. showTooltip(anchor, info) { this.hideTooltip(); if (!info || !anchor.active) return; const W = info.width || 370, PAD = 16; const cont = this.add.container(0, 0); this.fxLayer.add(cont); let y = PAD; const title = this.add.text(PAD, y, info.title, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '26px', color: info.titleColor || C.ink, fontStyle: 'bold', wordWrap: { width: W - PAD * 2 }, }); cont.add(title); y += title.height + 4; if (info.tag) { const tag = this.add.text(PAD, y, info.tag, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '16px', color: info.tagColor || C.muted, }); cont.add(tag); y += tag.height + 8; } else { y += 4; } for (const line of info.lines || []) { const t = this.add.text(PAD, y, line.text, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '19px', color: line.color || C.muted, wordWrap: { width: W - PAD * 2 }, }); cont.add(t); y += t.height + 6; } const H = y - 6 + PAD; const g = this.add.graphics(); g.fillStyle(C.panel, 0.97); g.fillRoundedRect(0, 0, W, H, 12); g.lineStyle(2, info.stroke ?? C.panelEdge, 1); g.strokeRoundedRect(0, 0, W, H, 12); cont.addAt(g, 0); const ax = this.fxX(anchor), ay = this.fxY(anchor); const cardH = anchor.height || 200; const px = Phaser.Math.Clamp(ax - W / 2, 12, GAME_WIDTH - W - 12); let py = ay - cardH / 2 - H - 16; if (py < 12) py = ay + cardH / 2 + 16; cont.setPosition(px, py); cont.setAlpha(0); this.tweens.add({ targets: cont, alpha: 1, duration: 110 }); this._tooltip = cont; this._tooltipOwner = anchor; } // With an owner, only hides the tooltip that owner opened — a stray // pointerout arriving after the next card's pointerover won't kill its tip. hideTooltip(owner) { if (owner && this._tooltipOwner !== owner) return; if (this._tooltip) this._tooltip.destroy(); this._tooltip = null; this._tooltipOwner = null; } playingCardInfo(card, debuffed = false) { if (card.faceDown) { return { title: 'Face Down', lines: [{ text: 'This card is hidden until it is revealed' }] }; } const stone = card.enhancement === 'stone'; const title = stone ? 'Stone Card' : `${RANK_NAMES[card.rank]} of ${SUIT_NAMES[card.suit]}`; const titleColor = stone ? '#b9b6b0' : TIP_SUIT_HEX[card.suit]; const chips = (stone ? 50 : rankChips(card.rank)) + (card.permaChips || 0); const lines = [{ text: `+${chips} Chips when scored`, color: C.chipsHex }]; if (card.permaChips) lines.push({ text: `Includes +${card.permaChips} permanent bonus Chips`, color: C.chipsHex }); if (card.enhancement && !stone) { const e = ENHANCEMENTS[card.enhancement]; lines.push({ text: `${e.name}: ${e.desc}`, color: '#e7c14b' }); } if (stone) lines.push({ text: ENHANCEMENTS.stone.desc, color: '#e7c14b' }); if (card.edition) { const e = EDITIONS[card.edition]; lines.push({ text: `${e.name}: ${e.desc}`, color: EDITION_HEX[card.edition] }); } if (card.seal) { const s = SEALS[card.seal]; lines.push({ text: `${s.name}: ${s.desc}`, color: SEAL_HEX[card.seal] }); } if (debuffed) lines.push({ text: 'Debuffed: scores nothing and triggers no abilities', color: '#ff8a6a' }); return { title, titleColor, lines }; } jokerInfo(id, inst, edition) { const def = JOKER_BY_ID[id]; const desc = typeof def.desc === 'function' ? def.desc(inst || { state: def.initState ? def.initState() : {} }) : def.desc; const lines = [{ text: desc, color: C.ink }]; const ed = edition ?? (inst && inst.edition); if (ed) lines.push({ text: `${EDITIONS[ed].name}: ${EDITIONS[ed].desc}`, color: EDITION_HEX[ed] }); if (inst && inst.sellValue != null) lines.push({ text: `Sell value: $${inst.sellValue}`, color: C.money }); if (inst && inst.debuffedByBoss) lines.push({ text: 'Disabled by the Boss Blind', color: '#ff8a6a' }); return { title: def.name, tag: `${def.rarity.charAt(0).toUpperCase()}${def.rarity.slice(1)} Joker`, tagColor: RARITY_HEX[def.rarity], stroke: C.rarity[def.rarity], lines, }; } consumableInfo(kind, id) { const def = kind === 'tarot' ? TAROT_BY_ID[id] : kind === 'planet' ? PLANET_BY_ID[id] : SPECTRAL_BY_ID[id]; const tint = { tarot: C.tarot, planet: C.planet, spectral: C.spectral }[kind]; const tintHex = `#${tint.toString(16).padStart(6, '0')}`; const lines = []; if (kind === 'planet') { const hd = HAND_BY_ID[def.hand]; lines.push({ text: `+1 level: ${hd.name}`, color: C.ink }); const lv = (this.run && this.run.handLevels[def.hand]) || 1; const cur = handBase(def.hand, lv); const next = handBase(def.hand, lv + 1); lines.push({ text: `lvl.${lv} (${cur.chips} × ${cur.mult}) → lvl.${lv + 1} (${next.chips} × ${next.mult})`, color: '#a8d8ff' }); } else { lines.push({ text: def.desc, color: C.ink }); // If this card turns cards into an enhanced/sealed/edition type, spell // out what that type does. const fx = def.effect || {}; if (fx.kind === 'enhance') { const e = ENHANCEMENTS[fx.enh]; lines.push({ text: `${e.name} Card: ${e.desc}`, color: '#e7c14b' }); } else if (fx.kind === 'seal') { const s = SEALS[fx.seal]; lines.push({ text: `${s.name}: ${s.desc}`, color: SEAL_HEX[fx.seal] }); } else if (fx.kind === 'aura' || fx.kind === 'wheel') { for (const ed of ['foil', 'holo', 'poly']) { lines.push({ text: `${EDITIONS[ed].name}: ${EDITIONS[ed].desc}`, color: EDITION_HEX[ed] }); } } else if (fx.kind === 'hex') { lines.push({ text: `${EDITIONS.poly.name}: ${EDITIONS.poly.desc}`, color: EDITION_HEX.poly }); } else if (fx.kind === 'ectoplasm') { lines.push({ text: `${EDITIONS.negative.name}: ${EDITIONS.negative.desc}`, color: EDITION_HEX.negative }); } } return { title: def.name, tag: { tarot: 'Tarot Card', planet: 'Planet Card', spectral: 'Spectral Card' }[kind], tagColor: tintHex, stroke: tint, lines, }; } // Hover tilt for any card container. Pass `base.info` (a function returning // a tooltip info object) to also show the Balatro-style detail popup. 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 }); if (base.info) this.showTooltip(cont, base.info()); }); cont.on('pointerout', () => { this.tweens.add({ targets: cont, scale: bs, angle: base.angle ?? 0, duration: 140 }); if (base.info) this.hideTooltip(cont); }); if (base.info) { cont.on('pointerdown', () => this.hideTooltip(cont)); cont.once('destroy', () => this.hideTooltip(cont)); } 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 bossIcon = boss ? this.artFrame('bossIconSheet', 'bosses', boss.id) : null; const badgeColor = run.blind === 'boss' ? 0xc22f3a : run.blind === 'big' ? 0x8556d6 : 0x2f6fce; const badgeH = boss ? (bossIcon ? 268 : 168) : 128; this.panel(X + 16, 40, W - 32, badgeH, { fill: badgeColor, alpha: 0.35, strokeColor: badgeColor }); let ny = 58; if (bossIcon) { const iconCont = this.add.container(X + W / 2, 40 + 16 + 28); this.addRoundedArt(iconCont, bossIcon.key, bossIcon.frame, 56, 56, 8); this.add2(iconCont); ny = 134; } this.text(X + W / 2, ny, boss ? boss.name : blind.name, 30, C.ink, { ox: 0.5, bold: true }); this.text(X + W / 2, ny + 42, `Score at least`, 17, C.muted, { ox: 0.5 }); this.text(X + W / 2, ny + 66, fmtChips(run.blindChips), 32, '#ff8a6a', { ox: 0.5, bold: true }); if (boss) this.text(X + W / 2, ny + 108, boss.desc, 16, '#ffb9a8', { ox: 0.5, align: 'center', wrap: W - 60 }); // Round score const sy = boss ? (bossIcon ? 326 : 226) : 190; this.text(X + 24, sy, 'Round score', 18, C.muted); this._roundScoreText = this.text(X + W - 24, sy - 6, fmtChips(this._roundScoreOverride ?? run.roundScore), 34, C.ink, { ox: 1, bold: true }); // chips × mult live boxes const cy = sy + 100; const half = (W - 64) / 2; this.text(X + 24 + half / 2, cy - 22, 'Chips', 15, C.chipsHex, { ox: 0.5, oy: 0.5, bold: true }); this.text(X + 40 + half * 1.5, cy - 22, 'Multi', 15, C.multHex, { ox: 0.5, oy: 0.5, bold: true }); const chipsBox = this.panel(X + 24, cy, half, 64, { fill: C.chips, alpha: 0.85, stroke: false, radius: 10 }); const multBox = 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 }); // Shaken in update() with the scoring hum's intensity. this._shakeTargets = [chipsBox, multBox, this._chipsText, this._multText] .map((obj) => ({ obj, bx: obj.x, by: obj.y })); this.text(X + 16 + W / 2 - 16, cy + 32, '×', 30, C.muted, { ox: 0.5, oy: 0.5 }); this._handNameText = this.text(X + W / 2, cy - 60, '', 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: 'm6x11, "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, { info: () => this.jokerInfo(inst.id, inst) }); 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(); } else { // Order unchanged: snap back without re-rendering the whole view, // which would destroy an open joker panel. this.tweens.add({ targets: cont, x: cx, duration: 130, ease: 'Cubic.easeOut' }); } }); } }); 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 = 210; 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: 'm6x11, "Julius Sans One"', fontSize: '28px', color: C.ink })); cont.add(this.add.text(px + 20, py + 56, desc, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', 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, { info: () => this.consumableInfo(inst.kind, inst.id) }); 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 = 230; 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: 'm6x11, "Julius Sans One"', fontSize: '28px', 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 + 56, descText, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', 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: 'm6x11, "Julius Sans One"', fontSize: '17px', 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 = 880, w = 110, h = 148; const deck = DECK_BY_ID[run.deckId]; const af = this.artFrame('deckBackSheet', 'deckBacks', deck.id); if (af) { const cont = this.add.container(x, y); this.addRoundedArt(cont, af.key, af.frame, w, h, 8); this.add2(cont); } else { 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; let hand = handCards(run); // Cards drawn by the engine but not yet dealt on screen (they fly in // after scoring) stay hidden. if (this._hideUids) hand = hand.filter((c) => !this._hideUids.has(c.uid)); 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; const pendingDeal = this._pendingDeal; this._pendingDeal = null; const dealList = []; 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; const dealing = pendingDeal && pendingDeal.has(card.uid); if (dealing) { cont.setPosition(cx, -220); cont.setAngle(tilt - 400); dealList.push({ cont, cx, cy, tilt }); } else { 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: 'm6x11, "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) return; if (!selected) this.tweens.add({ targets: cont, y: cy - 16, duration: 100 }); this.showTooltip(cont, this.playingCardInfo(card, isDebuffed(run, card))); }); cont.on('pointerout', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy, duration: 120 }); this.hideTooltip(cont); }); cont.on('pointerdown', () => { this.hideTooltip(cont); this.toggleSelect(card.uid); }); cont.once('destroy', () => this.hideTooltip(cont)); } }); this.updateHandPreview(); if (pendingDeal) { if (dealList.length) this.dealCardsSequential(dealList); else this.animating = false; } } // Fly newly-drawn cards in from off-screen, one at a time, spinning through // 400° so they always complete more than a full rotation before landing. dealCardsSequential(list) { this.animating = true; const DUR = 340, GAP = 70; const step = (i) => { if (i >= list.length) { this.animating = false; return; } const { cont, cx, cy, tilt } = list[i]; if (!cont.active) { step(i + 1); return; } playSound(this, SFX.CARD_DEAL); this.tweens.add({ targets: cont, x: cx, y: cy, angle: tilt, duration: DUR, ease: 'Cubic.easeOut', onComplete: () => this.time.delayedCall(GAP, () => step(i + 1)), }); }; step(0); } // Fly discarded cards to screen center, then shrink them away. Calls // onComplete only once every card has finished both steps. discardCardsOut(sprites, onComplete) { this.animating = true; if (!sprites.length) { onComplete(); return; } const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; let remaining = sprites.length; sprites.forEach((cont) => { this.tweens.add({ targets: cont, x: cx, y: cy, duration: 260, ease: 'Cubic.easeIn', onComplete: () => { this.tweens.add({ targets: cont, scaleX: 0, scaleY: 0, duration: 180, ease: 'Cubic.easeIn', onComplete: () => { cont.destroy(); remaining -= 1; if (remaining === 0) onComplete(); }, }); }, }); }); } 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 uids = this.selected.slice(); // Validate (and apply) the discard before animating so a rejected discard // (e.g. none left) never plays the fly-out. const r = discard(this.run, uids); if (!r.ok) { this.toast(r.error); return; } const sprites = uids.map((u) => this._handSprites[u]).filter(Boolean); sprites.forEach((s) => s.disableInteractive()); uids.forEach((u) => delete this._handSprites[u]); this.selected = []; this.save(); playSound(this, SFX.CARD_DEAL); this.discardCardsOut(sprites, () => { this._pendingDeal = new Set(r.drawn); 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 prevRoundScore = run.roundScore; // shown until the scoring animation completes // Remember where each played card currently sits in the hand fan so it // can fly from there to the played row instead of popping into place. const startPos = {}; uids.forEach((u) => { const s = this._handSprites[u]; if (s) startPos[u] = { x: s.x, y: s.y, angle: s.angle }; }); const res = playHand(run, uids); if (!res.ok) { this.toast(res.error); return; } res.prevRoundScore = prevRoundScore; this.selected = []; this.save(); playSound(this, SFX.CARD_PLACE); this.animateScoring(playedCards, res, startPos); } // ── scoring hum: looped fx whose register climbs with the running score ─── startScoreHum() { this.stopScoreHum(true); try { this._scoreHum = this.sound.add(SFX.ENERGY_HUM, { loop: true, volume: HUM_VOLUME, rate: HUM_MIN_RATE }); this._scoreHum.play(); } catch (_) { this._scoreHum = null; } } setScoreHumFromTotal(total) { if (!this._scoreHum) return; let rate = HUM_MIN_RATE + HUM_RATE_PER_LOG10 * Math.log10(Math.max(1, total) + 1); if (!Number.isFinite(rate)) rate = HUM_MAX_RATE; rate = Phaser.Math.Clamp(rate, HUM_MIN_RATE, HUM_MAX_RATE); this.tweens.add({ targets: this._scoreHum, rate, duration: 150 }); } stopScoreHum(immediate = false) { const hum = this._scoreHum; if (!hum) return; this._scoreHum = null; if (immediate) { try { hum.stop(); hum.destroy(); } catch (_) {} return; } this.tweens.add({ targets: hum, volume: 0, duration: 350, onComplete: () => { try { hum.stop(); hum.destroy(); } catch (_) {} }, }); } // ── scoring animation: pure playback of the trace ───────────────────────── animateScoring(playedCards, res, startPos = {}) { this.animating = true; this._skipAnim = false; this.dismissPanel(); this.startScoreHum(); // The engine already drew replacements into run.hand; keep them off // screen until the deal-in animation after scoring. Likewise the round // score already includes this hand — show the pre-hand value until the // slam. this._hideUids = res.drawn && res.drawn.length ? new Set(res.drawn) : null; this._roundScoreOverride = res.prevRoundScore ?? null; // Freeze the table (re-render without input). this.clearView(); this.renderLeftPanel(); this.renderJokerRow(true); this.renderConsumableRow(true); this.renderDeckThumb(); // Hand without the played cards this.renderHand(true); // Played row targets 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); const events = res.trace.events; 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; if (source.uid) return this._playedSprites[source.uid] || this._handSprites[source.uid] || null; } return null; }; // Score text flutters upward for ~1s: slow rise + gentle side-to-side // wobble, fading only near the end. const floater = (sprite, textStr, color) => { const x = sprite ? this.fxX(sprite) : 980; const y = sprite ? this.fxY(sprite) - 90 : 400; const t = this.add.text(x, y, textStr, { fontFamily: 'm6x11, "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 - 70, duration: 950, ease: 'Sine.easeOut' }); this.tweens.add({ targets: t, x: x + 9, duration: 160, yoyo: true, repeat: 5, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: t, alpha: 0, delay: 650, duration: 300, 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 }); }; // Chips/mult counters grow and shake as they take a hit, settling back // to normal size once the bump passes. Uses angle (not x/y) for the // shake so it doesn't fight the ambient hum-driven position jitter from // update()/_shakeTargets. const bumpStat = (textObj, big = false) => { if (!textObj || !textObj.active) return; this.tweens.killTweensOf(textObj); textObj.angle = 0; textObj.setScale(1); this.tweens.add({ targets: textObj, scale: big ? 2.6 : 2.15, duration: 130, ease: 'Back.easeOut', yoyo: true, hold: 110 }); this.tweens.add({ targets: textObj, angle: big ? 20 : 15, duration: 35, yoyo: true, repeat: 8, ease: 'Sine.easeInOut', onComplete: () => { if (textObj.active) textObj.angle = 0; }, }); }; 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; this.setScoreHumFromTotal(Math.floor(ev.chipsAfter) * ev.multAfter); 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}`); bumpStat(this._chipsText); bumpStat(this._multText); playSound(this, SFX.CARD_SHOW); break; case 'chips': floater(sprite, `+${fmtChips(ev.v)}`, C.chipsHex); pop(sprite); bumpStat(this._chipsText); playSound(this, SFX.PIECE_CLICK); break; case 'mult': floater(sprite, `+${ev.v} Mult`, C.multHex); pop(sprite); bumpStat(this._multText); playSound(this, SFX.PIECE_CLICK); break; case 'xmult': floater(sprite, `×${ev.v}`, '#ffa24f'); pop(sprite, true); bumpStat(this._multText, ev.v >= 2); playSound(this, SFX.SCIFI_PLINK); if (ev.v >= 2) { this.cameras.main.shake(120, 0.004); this.crt.pulse(0.5); } 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 = () => { this.stopScoreHum(); 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: 'm6x11, "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); this.crt.pulse(score >= this.run.blindChips ? 0.8 : 0.4); playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK); const skip = this._skipAnim; // Hold the tally at the played row, then fly it into the round-score // readout on the left bar and detonate on arrival. The new total is // only revealed once the explosion lands. const dwell = skip ? 60 : 550; const flightDur = skip ? 100 : 380; this.time.delayedCall(dwell, () => { const targetX = this._roundScoreText && this._roundScoreText.active ? this._roundScoreText.x : centerX; const targetY = this._roundScoreText && this._roundScoreText.active ? this._roundScoreText.y : py - 170; const detonate = () => { this.explodeBurst(targetX, targetY, score >= this.run.blindChips ? 0xffd873 : 0xffe9a8); this.cameras.main.shake(110, 0.006); this.crt.pulse(0.5); playSound(this, SFX.SCIFI_PLONK); this._roundScoreOverride = null; if (this._roundScoreText && this._roundScoreText.active) { this._roundScoreText.setText(fmtChips(this.run.roundScore)); this._roundScoreText.setScale(1); this.tweens.add({ targets: this._roundScoreText, scale: 1.5, duration: 140, ease: 'Back.easeOut', yoyo: true, hold: 90 }); } this.time.delayedCall(skip ? 60 : 250, () => { this._hideUids = null; if (res.destroyedJokers && res.destroyedJokers.length) { this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); } if (res.outcome === 'blindWon') { this.animating = false; playSound(this, SFX.CASINO_WIN); this.setView('cashout'); } else if (res.outcome === 'runLost') { this.animating = false; playSound(this, SFX.CASINO_LOSE); this.onRunOver(); this.setView('gameover'); } else { // animating stays true; the new deal-in sequence clears it once cards land. this._pendingDeal = new Set(res.drawn); this.setView('play'); } }); }; if (slam.active) { this.tweens.killTweensOf(slam); this.tweens.add({ targets: slam, x: targetX, y: targetY, scale: 0.3, alpha: 0.7, duration: flightDur, ease: 'Cubic.easeIn', onComplete: () => { slam.destroy(); detonate(); }, }); } else { detonate(); } }); }; // Group consecutive events by the sprite they anchor to, so each scored // card and each contributing joker gets its own ~1s dwell moment. const anchorKey = (ev) => { if (ev.t === 'base' || ev.t === 'total') return ev.t; const s = ev.source; if (!s) return null; if (s.jokerUid !== undefined) return `joker:${s.jokerUid}`; const uid = s.uid !== undefined ? s.uid : s.cardUid; // retriggers carry uid under kind 'joker' return uid !== undefined ? `card:${uid}` : null; }; const groups = []; for (const ev of events) { const key = anchorKey(ev); const last = groups[groups.length - 1]; if (key && last && last.key === key && key !== 'base' && key !== 'total') last.events.push(ev); else groups.push({ key, events: [ev] }); } const STAGGER = 280; let gi = 0; const playGroup = () => { if (this._skipAnim) { finish(); return; } if (gi >= groups.length) { finish(); return; } const group = groups[gi++]; const first = group.events[0]; if (first.t === 'total') { finish(); return; } if (first.t === 'base') { applyEvent(first); this.time.delayedCall(500, playGroup); return; } const sprite = findSprite(first.source); const isJoker = group.key && group.key.startsWith('joker:'); const dwell = Math.max(1000, group.events.length * 300); const runEvents = (startDelay) => { group.events.forEach((ev, i) => { this.time.delayedCall(startDelay + i * STAGGER, () => { if (!this._skipAnim) applyEvent(ev); }); }); }; if (!sprite || !sprite.active) { // No anchor (boss info lines etc.): play quickly and move on. runEvents(0); this.time.delayedCall(group.events.length * 200 + 100, playGroup); return; } if (isJoker) { // Grow + shake the joker while its boost flutters, then settle back. this.tweens.add({ targets: sprite, scale: 1.15, duration: 120, ease: 'Back.easeOut' }); const shake = this.tweens.add({ targets: sprite, angle: { from: -5, to: 5 }, duration: 60, yoyo: true, repeat: Math.floor((dwell - 300) / 120), ease: 'Sine.easeInOut', }); runEvents(140); this.time.delayedCall(dwell, () => { shake.stop(); if (sprite.active) this.tweens.add({ targets: sprite, scale: 1, angle: 0, duration: 140 }); playGroup(); }); } else { // Raise + slightly enlarge the card; played cards stay raised, held // (in-hand) cards settle back so the fan isn't left broken. const uid = Number(group.key.slice(5)); const isHeld = !this._playedSprites[uid]; const baseY = sprite.y; this.tweens.add({ targets: sprite, y: baseY - 34, scale: 1.12, duration: 140, ease: 'Back.easeOut' }); runEvents(160); this.time.delayedCall(dwell, () => { if (isHeld && sprite.active) this.tweens.add({ targets: sprite, y: baseY, scale: 1, duration: 140 }); playGroup(); }); } }; // Fly each played card in from its former hand position; scoring only // begins once every card has landed in the played row. const flyIn = () => { let remaining = playedCards.length; if (!remaining) { playGroup(); return; } playedCards.forEach((card, i) => { const tx = x0 + i * step, ty = py; const from = startPos[card.uid] || { x: tx, y: -220, angle: 0 }; const cont = this.drawPlayingCard(from.x, from.y, card, { w, h, reveal: true }); cont.setAngle(from.angle); this.add2(cont); this.tweens.add({ targets: cont, x: tx, y: ty, angle: 0, duration: 260, ease: 'Cubic.easeOut', onComplete: () => { if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); this._playedSprites[card.uid] = cont; remaining -= 1; if (remaining === 0) playGroup(); }, }); }); }; flyIn(); } 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() }); } explodeBurst(x, y, color = 0xffe9a8) { const flash = this.add.circle(x, y, 10, 0xffffff, 0.9).setDepth(87); this.fxLayer.add(flash); this.tweens.add({ targets: flash, radius: 70, alpha: 0, duration: 260, ease: 'Cubic.easeOut', onComplete: () => flash.destroy() }); const ring = this.add.circle(x, y, 20, 0xffffff, 0).setStrokeStyle(5, color, 0.95).setDepth(86); this.fxLayer.add(ring); this.tweens.add({ targets: ring, radius: 130, alpha: 0, duration: 420, ease: 'Cubic.easeOut', onComplete: () => ring.destroy() }); for (let i = 0; i < 10; i++) { const shard = this.add.rectangle(x, y, 12, 18, color, 0.95).setDepth(85).setAngle(Phaser.Math.Between(0, 360)); this.fxLayer.add(shard); this.tweens.add({ targets: shard, x: x + Phaser.Math.Between(-90, 90), y: y + Phaser.Math.Between(-70, 90), angle: shard.angle + Phaser.Math.Between(-260, 260), alpha: 0, duration: 460, ease: 'Cubic.easeOut', onComplete: () => shard.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); } }