feat(balatro): add hover tooltips, scoring hum, and chip shake

- Add Balatro-style hover tooltips for cards, jokers, consumables, shop
  items, packs, and vouchers with detailed stat information
- Add a looping energy hum that climbs in pitch with the running score
- Add visual jitter to chips/mult boxes that intensifies with the hum
- Improve scoring animation by grouping events per card/joker anchor
- Stamp joker UIDs onto events for proper animation anchoring
- Add drag distance threshold to prevent accidental joker drags
This commit is contained in:
Brian Fertig 2026-07-10 15:46:49 -06:00
parent 29a242b9f5
commit 9a97775ec9
6 changed files with 413 additions and 41 deletions

BIN
assets/fx/energy-hum.mp3 Normal file

Binary file not shown.

View File

@ -14,7 +14,7 @@ import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import * as store from '../../services/localStore.js'; import * as store from '../../services/localStore.js';
import { import {
SUIT_NAMES, RANK_NAMES, HAND_BY_ID, handBase, fmtChips, BLINDS, DECK_BY_ID, 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, ENHANCEMENTS, EDITIONS, SEALS, TAROT_BY_ID, PLANET_BY_ID, SPECTRAL_BY_ID,
VOUCHER_BY_ID, BOOSTER_BY_ID, BOSS_BY_ID, VOUCHER_BY_ID, BOOSTER_BY_ID, BOSS_BY_ID,
} from './BalatroData.js'; } from './BalatroData.js';
@ -48,6 +48,17 @@ export const C = {
}; };
export const SUIT_GLYPH = { S: '♠', H: '♥', D: '♦', C: '♣' }; export const SUIT_GLYPH = { S: '♠', H: '♥', D: '♦', C: '♣' };
const SUIT_COLOR = { S: '#2b2733', C: '#2b3a63', H: '#c8342c', D: '#d07a2c' }; 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 { export default class BalatroGame extends Phaser.Scene {
constructor() { super('BalatroGame'); } constructor() { super('BalatroGame'); }
@ -66,6 +77,10 @@ export default class BalatroGame extends Phaser.Scene {
this._toastText = null; this._toastText = null;
this._recorded = false; this._recorded = false;
this._pendingDeal = null; this._pendingDeal = null;
this._scoreHum = null;
this._hideUids = null;
this._shakeTargets = null;
this._roundScoreOverride = null;
} }
create() { create() {
@ -88,6 +103,14 @@ export default class BalatroGame extends Phaser.Scene {
// Fast-forward: tapping during a scoring animation snaps to the end. // Fast-forward: tapping during a scoring animation snaps to the end.
this.input.on('pointerdown', () => { if (this.animating) this._skipAnim = true; }); 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); const saved = store.read(SAVE_KEY);
if (saved) { if (saved) {
const run = deserializeRun(saved); const run = deserializeRun(saved);
@ -97,6 +120,25 @@ export default class BalatroGame extends Phaser.Scene {
this.renderView(); 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 ───────────────────────────────────────────────── // ── persistence / results ─────────────────────────────────────────────────
save() { save() {
if (!this.run) return; if (!this.run) return;
@ -152,6 +194,9 @@ export default class BalatroGame extends Phaser.Scene {
this._playedSprites = {}; this._playedSprites = {};
this._jokerSprites = {}; this._jokerSprites = {};
this._toastText = null; this._toastText = null;
this._tooltip = null;
this._tooltipOwner = null;
this._shakeTargets = null;
} }
setView(v) { setView(v) {
@ -403,17 +448,170 @@ export default class BalatroGame extends Phaser.Scene {
return cont; return cont;
} }
// Hover tilt for any card container. // ── 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 || 330, 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: '"Julius Sans One"', fontSize: '22px', 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: '"Julius Sans One"', fontSize: '14px', 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: '"Julius Sans One"', fontSize: '16px', 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 = {}) { addHoverTilt(cont, base = {}) {
const bs = base.scale ?? 1; const bs = base.scale ?? 1;
cont.setInteractive({ useHandCursor: true }); cont.setInteractive({ useHandCursor: true });
cont.on('pointerover', () => { cont.on('pointerover', () => {
if (this.animating) return; if (this.animating) return;
this.tweens.add({ targets: cont, scale: bs * 1.07, angle: (base.angle ?? 0) + Phaser.Math.Between(-3, 3), duration: 120 }); 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', () => { cont.on('pointerout', () => {
this.tweens.add({ targets: cont, scale: bs, angle: base.angle ?? 0, duration: 140 }); 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; return cont;
} }
@ -447,15 +645,18 @@ export default class BalatroGame extends Phaser.Scene {
// Round score // Round score
const sy = boss ? 226 : 190; const sy = boss ? 226 : 190;
this.text(X + 24, sy, 'Round score', 18, C.muted); this.text(X + 24, sy, 'Round score', 18, C.muted);
this._roundScoreText = this.text(X + W - 24, sy - 6, fmtChips(run.roundScore), 34, C.ink, { ox: 1, bold: true }); 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 // chips × mult live boxes
const cy = sy + 66; const cy = sy + 66;
const half = (W - 64) / 2; const half = (W - 64) / 2;
this.panel(X + 24, cy, half, 64, { fill: C.chips, alpha: 0.85, stroke: false, radius: 10 }); const chipsBox = this.panel(X + 24, cy, half, 64, { fill: C.chips, alpha: 0.85, stroke: false, radius: 10 });
this.panel(X + 40 + half, cy, half, 64, { fill: C.mult, 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._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 }); 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 - 8, cy + 32, '×', 30, C.muted, { ox: 0.5, oy: 0.5 }); this.text(X + 16 + W / 2 - 8, cy + 32, '×', 30, C.muted, { ox: 0.5, oy: 0.5 });
this._handNameText = this.text(X + W / 2, cy - 26, '', 20, C.gold ? '#e7c14b' : C.ink, { ox: 0.5 }); this._handNameText = this.text(X + W / 2, cy - 26, '', 20, C.gold ? '#e7c14b' : C.ink, { ox: 0.5 });
@ -535,7 +736,7 @@ export default class BalatroGame extends Phaser.Scene {
this.add2(cont); this.add2(cont);
this._jokerSprites[inst.uid] = cont; this._jokerSprites[inst.uid] = cont;
if (!frozen) { if (!frozen) {
this.addHoverTilt(cont); this.addHoverTilt(cont, { info: () => this.jokerInfo(inst.id, inst) });
cont.on('pointerdown', () => this.showJokerPanel(inst)); cont.on('pointerdown', () => this.showJokerPanel(inst));
this.input.setDraggable(cont); this.input.setDraggable(cont);
cont.on('drag', (ptr, dx) => { cont.x = dx; }); cont.on('drag', (ptr, dx) => { cont.x = dx; });
@ -546,8 +747,12 @@ export default class BalatroGame extends Phaser.Scene {
run.jokers.splice(cur, 1); run.jokers.splice(cur, 1);
run.jokers.splice(idx, 0, inst); run.jokers.splice(idx, 0, inst);
this.save(); this.save();
}
this.renderView(); 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' });
}
}); });
} }
}); });
@ -595,7 +800,7 @@ export default class BalatroGame extends Phaser.Scene {
const cont = this.drawConsumableCard(cx, y, inst.kind, inst.id, { w, h }); const cont = this.drawConsumableCard(cx, y, inst.kind, inst.id, { w, h });
this.add2(cont); this.add2(cont);
if (!frozen) { if (!frozen) {
this.addHoverTilt(cont); this.addHoverTilt(cont, { info: () => this.consumableInfo(inst.kind, inst.id) });
cont.on('pointerdown', () => this.showConsumablePanel(inst)); cont.on('pointerdown', () => this.showConsumablePanel(inst));
} }
}); });
@ -658,7 +863,10 @@ export default class BalatroGame extends Phaser.Scene {
renderHand(frozen) { renderHand(frozen) {
const run = this.run; const run = this.run;
const hand = handCards(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 w = 150, h = 204;
const centerX = 1160, y = 880; const centerX = 1160, y = 880;
const maxSpan = 1300; const maxSpan = 1300;
@ -694,9 +902,17 @@ export default class BalatroGame extends Phaser.Scene {
} }
if (!frozen) { if (!frozen) {
cont.setInteractive({ useHandCursor: true }); cont.setInteractive({ useHandCursor: true });
cont.on('pointerover', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy - 16, duration: 100 }); }); cont.on('pointerover', () => {
cont.on('pointerout', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy, duration: 120 }); }); if (this.animating) return;
cont.on('pointerdown', () => this.toggleSelect(card.uid)); 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(); this.updateHandPreview();
@ -808,15 +1024,17 @@ export default class BalatroGame extends Phaser.Scene {
doDiscard() { doDiscard() {
if (!this.selected.length) { this.toast('Select cards to discard'); return; } if (!this.selected.length) { this.toast('Select cards to discard'); return; }
const uids = this.selected.slice(); 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); const sprites = uids.map((u) => this._handSprites[u]).filter(Boolean);
sprites.forEach((s) => s.disableInteractive()); sprites.forEach((s) => s.disableInteractive());
uids.forEach((u) => delete this._handSprites[u]); uids.forEach((u) => delete this._handSprites[u]);
this.selected = []; this.selected = [];
this.save();
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
this.discardCardsOut(sprites, () => { this.discardCardsOut(sprites, () => {
const r = discard(this.run, uids);
if (!r.ok) { this.toast(r.error); this.animating = false; return; }
this.save();
this._pendingDeal = new Set(r.drawn); this._pendingDeal = new Set(r.drawn);
this.renderView(); this.renderView();
}); });
@ -827,6 +1045,7 @@ export default class BalatroGame extends Phaser.Scene {
const run = this.run; const run = this.run;
const uids = run.hand.filter((u) => this.selected.includes(u)); const uids = run.hand.filter((u) => this.selected.includes(u));
const playedCards = cardsOf(run, uids).map((c) => ({ ...c })); // snapshot for display 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 // 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. // can fly from there to the played row instead of popping into place.
const startPos = {}; const startPos = {};
@ -836,17 +1055,53 @@ export default class BalatroGame extends Phaser.Scene {
}); });
const res = playHand(run, uids); const res = playHand(run, uids);
if (!res.ok) { this.toast(res.error); return; } if (!res.ok) { this.toast(res.error); return; }
res.prevRoundScore = prevRoundScore;
this.selected = []; this.selected = [];
this.save(); this.save();
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
this.animateScoring(playedCards, res, startPos); 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 ───────────────────────── // ── scoring animation: pure playback of the trace ─────────────────────────
animateScoring(playedCards, res, startPos = {}) { animateScoring(playedCards, res, startPos = {}) {
this.animating = true; this.animating = true;
this._skipAnim = false; this._skipAnim = false;
this.dismissPanel(); 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). // Freeze the table (re-render without input).
this.clearView(); this.clearView();
@ -867,8 +1122,6 @@ export default class BalatroGame extends Phaser.Scene {
const scoringSet = new Set(res.trace.scoringUids); const scoringSet = new Set(res.trace.scoringUids);
const events = res.trace.events; const events = res.trace.events;
const stepMs = Phaser.Math.Clamp(Math.round(2600 / Math.max(events.length, 1)), 70, 240);
let idx = 0;
const findSprite = (source) => { const findSprite = (source) => {
if (!source) return null; if (!source) return null;
@ -876,19 +1129,24 @@ export default class BalatroGame extends Phaser.Scene {
if (source.kind === 'joker') { if (source.kind === 'joker') {
if (source.jokerUid && this._jokerSprites[source.jokerUid]) return this._jokerSprites[source.jokerUid]; if (source.jokerUid && this._jokerSprites[source.jokerUid]) return this._jokerSprites[source.jokerUid];
if (source.cardUid) return this._playedSprites[source.cardUid] || null; if (source.cardUid) return this._playedSprites[source.cardUid] || null;
if (source.uid) return this._playedSprites[source.uid] || this._handSprites[source.uid] || null;
} }
return 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 floater = (sprite, textStr, color) => {
const x = sprite ? this.fxX(sprite) : 980; const x = sprite ? this.fxX(sprite) : 980;
const y = sprite ? this.fxY(sprite) - 80 : 400; const y = sprite ? this.fxY(sprite) - 90 : 400;
const t = this.add.text(x, y, textStr, { const t = this.add.text(x, y, textStr, {
fontFamily: '"Julius Sans One"', fontSize: '34px', color, fontStyle: 'bold', fontFamily: '"Julius Sans One"', fontSize: '34px', color, fontStyle: 'bold',
stroke: '#141019', strokeThickness: 5, stroke: '#141019', strokeThickness: 5,
}).setOrigin(0.5).setDepth(85); }).setOrigin(0.5).setDepth(85);
this.fxLayer.add(t); this.fxLayer.add(t);
this.tweens.add({ targets: t, y: y - 56, alpha: 0, duration: 700, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); 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) => { const pop = (sprite, big = false) => {
@ -900,6 +1158,7 @@ export default class BalatroGame extends Phaser.Scene {
if (this._chipsText && this._chipsText.active) this._chipsText.setText(fmtChips(ev.chipsAfter)); 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 (this._multText && this._multText.active) this._multText.setText(String(Math.round(ev.multAfter * 100) / 100));
if (silent) return; if (silent) return;
this.setScoreHumFromTotal(Math.floor(ev.chipsAfter) * ev.multAfter);
const sprite = findSprite(ev.source); const sprite = findSprite(ev.source);
switch (ev.t) { switch (ev.t) {
case 'base': case 'base':
@ -924,6 +1183,7 @@ export default class BalatroGame extends Phaser.Scene {
}; };
const finish = () => { const finish = () => {
this.stopScoreHum();
const totalEv = events[events.length - 1]; const totalEv = events[events.length - 1];
applyEvent(totalEv, true); applyEvent(totalEv, true);
const score = res.trace.score; const score = res.trace.score;
@ -936,9 +1196,11 @@ export default class BalatroGame extends Phaser.Scene {
this.tweens.add({ targets: slam, alpha: 1, scale: 1, duration: 180, ease: 'Back.easeOut' }); 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.cameras.main.shake(160, score >= this.run.blindChips ? 0.008 : 0.004);
playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK); playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK);
this._roundScoreOverride = null;
if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore)); if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore));
this.time.delayedCall(this._skipAnim ? 250 : 900, () => { this.time.delayedCall(this._skipAnim ? 250 : 900, () => {
this._hideUids = null;
if (res.destroyedJokers && res.destroyedJokers.length) { if (res.destroyedJokers && res.destroyedJokers.length) {
this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`);
} }
@ -959,23 +1221,88 @@ export default class BalatroGame extends Phaser.Scene {
}); });
}; };
const tick = () => { // Group consecutive events by the sprite they anchor to, so each scored
if (this._skipAnim) { // card and each contributing joker gets its own ~1s dwell moment.
// Snap: apply remaining counter states instantly. const anchorKey = (ev) => {
finish(); 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; return;
} }
if (idx >= events.length) { finish(); return; }
const ev = events[idx++]; const sprite = findSprite(first.source);
applyEvent(ev); const isJoker = group.key && group.key.startsWith('joker:');
this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick); 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 // Fly each played card in from its former hand position; scoring only
// begins once every card has landed in the played row. // begins once every card has landed in the played row.
const flyIn = () => { const flyIn = () => {
let remaining = playedCards.length; let remaining = playedCards.length;
if (!remaining) { tick(); return; } if (!remaining) { playGroup(); return; }
playedCards.forEach((card, i) => { playedCards.forEach((card, i) => {
const tx = x0 + i * step, ty = py; const tx = x0 + i * step, ty = py;
const from = startPos[card.uid] || { x: tx, y: -220, angle: 0 }; const from = startPos[card.uid] || { x: tx, y: -220, angle: 0 };
@ -989,7 +1316,7 @@ export default class BalatroGame extends Phaser.Scene {
if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); if (!scoringSet.has(card.uid)) cont.setAlpha(0.45);
this._playedSprites[card.uid] = cont; this._playedSprites[card.uid] = cont;
remaining -= 1; remaining -= 1;
if (remaining === 0) tick(); if (remaining === 0) playGroup();
}, },
}); });
}); });

View File

@ -241,23 +241,37 @@ export function scorePlayedHand(run, played, held, opts) {
} }
} }
// Joker defs emit sources without the physical joker's uid; stamp it onto
// events they just appended so the scene can anchor the scoring animation
// to the right joker sprite (copies attribute to the copying joker).
const stampJoker = (from, uid) => {
for (let k = from; k < events.length; k++) {
const s = events[k].source;
if (s && s.kind === 'joker' && s.jokerUid === undefined) s.jokerUid = uid;
}
};
// 3. Jokers left to right: Foil/Holo before the effect, Polychrome after. // 3. Jokers left to right: Foil/Holo before the effect, Polychrome after.
// State updates (onHandPlayed) land just before the joker's own // State updates (onHandPlayed) land just before the joker's own
// contribution so scalers apply to the hand that grew them. // contribution so scalers apply to the hand that grew them.
for (const e of effJokers) { for (const e of effJokers) {
if (e.inst.debuffedByBoss) continue; if (e.inst.debuffedByBoss) continue;
const mark = events.length;
const src = { kind: 'joker', id: e.def.id || e.inst.id, jokerUid: e.inst.uid }; const src = { kind: 'joker', id: e.def.id || e.inst.id, jokerUid: e.inst.uid };
if (e.inst.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' }); if (e.inst.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' });
if (e.inst.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' }); if (e.inst.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' });
if (e.def.onHandPlayed && !e.isCopy) e.def.onHandPlayed(ctx, e.hookInst); if (e.def.onHandPlayed && !e.isCopy) e.def.onHandPlayed(ctx, e.hookInst);
if (e.def.independent) e.def.independent(ctx, e.hookInst); if (e.def.independent) e.def.independent(ctx, e.hookInst);
if (e.inst.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' }); if (e.inst.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' });
stampJoker(mark, e.inst.uid);
} }
// 4. Post-hand decays / triggers (Ice Cream, Seltzer, Loyalty, DNA...). // 4. Post-hand decays / triggers (Ice Cream, Seltzer, Loyalty, DNA...).
for (const e of effJokers) { for (const e of effJokers) {
if (e.inst.debuffedByBoss || e.isCopy || !e.def.afterHand) continue; if (e.inst.debuffedByBoss || e.isCopy || !e.def.afterHand) continue;
const mark = events.length;
e.def.afterHand(ctx, e.hookInst); e.def.afterHand(ctx, e.hookInst);
stampJoker(mark, e.inst.uid);
} }
const score = Math.floor(chips) * mult; const score = Math.floor(chips) * mult;

View File

@ -202,12 +202,19 @@ export function renderShop(scene) {
scene.text(470, cy - 150, 'For sale', 20, C.muted); scene.text(470, cy - 150, 'For sale', 20, C.muted);
shop.cards.forEach((item, i) => { shop.cards.forEach((item, i) => {
const x = 560 + i * 190; const x = 560 + i * 190;
let cont; let cont, info;
if (item.slot === 'joker') cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 }); if (item.slot === 'joker') {
else if (item.slot === 'playing') cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 }); cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 });
else cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 }); info = () => scene.jokerInfo(item.id, null, item.edition);
} else if (item.slot === 'playing') {
cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 });
info = () => scene.playingCardInfo(item.card);
} else {
cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 });
info = () => scene.consumableInfo(item.slot, item.id);
}
scene.add2(cont); scene.add2(cont);
scene.addHoverTilt(cont); scene.addHoverTilt(cont, { info });
scene.text(x, cy + 132, `$${item.cost}`, 26, C.money, { ox: 0.5, bold: true }); scene.text(x, cy + 132, `$${item.cost}`, 26, C.money, { ox: 0.5, bold: true });
cont.on('pointerdown', () => { cont.on('pointerdown', () => {
const r = buyShopCard(run, i); const r = buyShopCard(run, i);
@ -245,7 +252,14 @@ export function renderShop(scene) {
} }
cont.setSize(180, 240); cont.setSize(180, 240);
scene.add2(cont); scene.add2(cont);
scene.addHoverTilt(cont); const kindName = { playing: 'playing', tarot: 'Tarot', planet: 'Planet', joker: 'Joker', spectral: 'Spectral' }[def.what];
scene.addHoverTilt(cont, {
info: () => ({
title: def.name,
tag: 'Booster Pack',
lines: [{ text: `Choose ${def.picks} of ${def.options} ${kindName} cards`, color: C.ink }],
}),
});
scene.text(x, py2 + 148, `$${slot.cost}`, 26, C.money, { ox: 0.5, bold: true }); scene.text(x, py2 + 148, `$${slot.cost}`, 26, C.money, { ox: 0.5, bold: true });
cont.on('pointerdown', () => { cont.on('pointerdown', () => {
const r = buyPack(run, i); const r = buyPack(run, i);
@ -275,7 +289,15 @@ export function renderShop(scene) {
} }
cont.setSize(190, 250); cont.setSize(190, 250);
scene.add2(cont); scene.add2(cont);
scene.addHoverTilt(cont); scene.addHoverTilt(cont, {
info: () => ({
title: v.name,
tag: 'Voucher',
tagColor: '#7ce0c0',
stroke: C.voucher,
lines: [{ text: v.desc, color: C.ink }],
}),
});
scene.text(vx, vy + 152, `$${shop.voucher.cost}`, 26, C.money, { ox: 0.5, bold: true }); scene.text(vx, vy + 152, `$${shop.voucher.cost}`, 26, C.money, { ox: 0.5, bold: true });
cont.on('pointerdown', () => { cont.on('pointerdown', () => {
const r = buyVoucher(run); const r = buyVoucher(run);
@ -308,12 +330,19 @@ export function renderPackOpen(scene) {
const x0 = CX - ((n - 1) * step) / 2; const x0 = CX - ((n - 1) * step) / 2;
pack.options.forEach((opt, i) => { pack.options.forEach((opt, i) => {
const x = x0 + i * step, y = 480; const x = x0 + i * step, y = 480;
let cont; let cont, info;
if (opt.what === 'playing') cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h }); if (opt.what === 'playing') {
else if (opt.what === 'joker') cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h }); cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h });
else cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h }); info = () => scene.playingCardInfo(opt.card);
} else if (opt.what === 'joker') {
cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h });
info = () => scene.jokerInfo(opt.id, null, opt.edition);
} else {
cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h });
info = () => scene.consumableInfo(opt.what, opt.id);
}
scene.add2(cont); scene.add2(cont);
scene.addHoverTilt(cont); scene.addHoverTilt(cont, { info });
cont.on('pointerdown', () => { cont.on('pointerdown', () => {
const r = pickFromPack(run, i); const r = pickFromPack(run, i);
if (!r.ok) { scene.toast(r.error); return; } if (!r.ok) { scene.toast(r.error); return; }

View File

@ -102,6 +102,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-chip-bet', 'assets/fx/chip-bet.mp3'); this.load.audio('sfx-chip-bet', 'assets/fx/chip-bet.mp3');
this.load.audio('sfx-dice-roll', 'assets/fx/dice-roll.mp3'); this.load.audio('sfx-dice-roll', 'assets/fx/dice-roll.mp3');
this.load.audio('sfx-bingo-balls', 'assets/fx/bingo-balls.mp3'); this.load.audio('sfx-bingo-balls', 'assets/fx/bingo-balls.mp3');
this.load.audio('sfx-energy-hum', 'assets/fx/energy-hum.mp3');
this.load.audio('sfx-pencil-write', 'assets/fx/pencil-write.mp3'); this.load.audio('sfx-pencil-write', 'assets/fx/pencil-write.mp3');
this.load.audio('sfx-piece-click', 'assets/fx/piece-click.mp3'); this.load.audio('sfx-piece-click', 'assets/fx/piece-click.mp3');
this.load.audio('sfx-mastermind-glitch-1', 'assets/fx/mastermind-glitch-01.mp3'); this.load.audio('sfx-mastermind-glitch-1', 'assets/fx/mastermind-glitch-01.mp3');

View File

@ -69,6 +69,7 @@ export const SFX = {
SW_DARK_1: 'sfx-sw-dark-1', SW_DARK_1: 'sfx-sw-dark-1',
SW_DARK_2: 'sfx-sw-dark-2', SW_DARK_2: 'sfx-sw-dark-2',
SW_DARK_3: 'sfx-sw-dark-3', SW_DARK_3: 'sfx-sw-dark-3',
ENERGY_HUM: 'sfx-energy-hum',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {