1524 lines
68 KiB
JavaScript
1524 lines
68 KiB
JavaScript
// 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);
|
||
}
|
||
}
|