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

1524 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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