From 9a97775ec9d1e1f506362ee57edc2ec6ba3378ac Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 10 Jul 2026 15:46:49 -0600 Subject: [PATCH] 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 --- assets/fx/energy-hum.mp3 | Bin 0 -> 30194 bytes src/games/balatro/BalatroGame.js | 385 +++++++++++++++++++++++++--- src/games/balatro/BalatroScoring.js | 14 + src/games/balatro/BalatroViews.js | 53 +++- src/scenes/PreloadScene.js | 1 + src/ui/Sounds.js | 1 + 6 files changed, 413 insertions(+), 41 deletions(-) create mode 100644 assets/fx/energy-hum.mp3 diff --git a/assets/fx/energy-hum.mp3 b/assets/fx/energy-hum.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b6788ddc5b79416db81de228037e7db73201c2b4 GIT binary patch literal 30194 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 z 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 literal 0 HcmV?d00001 diff --git a/src/games/balatro/BalatroGame.js b/src/games/balatro/BalatroGame.js index 5abca4d..8a0c45d 100644 --- a/src/games/balatro/BalatroGame.js +++ b/src/games/balatro/BalatroGame.js @@ -14,7 +14,7 @@ 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, 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, VOUCHER_BY_ID, BOOSTER_BY_ID, BOSS_BY_ID, } from './BalatroData.js'; @@ -48,6 +48,17 @@ export const C = { }; 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'); } @@ -66,6 +77,10 @@ export default class BalatroGame extends Phaser.Scene { this._toastText = null; this._recorded = false; this._pendingDeal = null; + this._scoreHum = null; + this._hideUids = null; + this._shakeTargets = null; + this._roundScoreOverride = null; } create() { @@ -88,6 +103,14 @@ export default class BalatroGame extends Phaser.Scene { // 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); @@ -97,6 +120,25 @@ export default class BalatroGame extends Phaser.Scene { 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; @@ -152,6 +194,9 @@ export default class BalatroGame extends Phaser.Scene { this._playedSprites = {}; this._jokerSprites = {}; this._toastText = null; + this._tooltip = null; + this._tooltipOwner = null; + this._shakeTargets = null; } setView(v) { @@ -403,17 +448,170 @@ export default class BalatroGame extends Phaser.Scene { 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 = {}) { 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; } @@ -447,15 +645,18 @@ export default class BalatroGame extends Phaser.Scene { // Round score const sy = boss ? 226 : 190; 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 const cy = sy + 66; const half = (W - 64) / 2; - 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 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 - 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 }); @@ -535,7 +736,7 @@ export default class BalatroGame extends Phaser.Scene { this.add2(cont); this._jokerSprites[inst.uid] = cont; if (!frozen) { - this.addHoverTilt(cont); + 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; }); @@ -546,8 +747,12 @@ export default class BalatroGame extends Phaser.Scene { 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' }); } - this.renderView(); }); } }); @@ -595,7 +800,7 @@ export default class BalatroGame extends Phaser.Scene { const cont = this.drawConsumableCard(cx, y, inst.kind, inst.id, { w, h }); this.add2(cont); if (!frozen) { - this.addHoverTilt(cont); + this.addHoverTilt(cont, { info: () => this.consumableInfo(inst.kind, inst.id) }); cont.on('pointerdown', () => this.showConsumablePanel(inst)); } }); @@ -658,7 +863,10 @@ export default class BalatroGame extends Phaser.Scene { renderHand(frozen) { 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 centerX = 1160, y = 880; const maxSpan = 1300; @@ -694,9 +902,17 @@ export default class BalatroGame extends Phaser.Scene { } if (!frozen) { cont.setInteractive({ useHandCursor: true }); - cont.on('pointerover', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy - 16, duration: 100 }); }); - cont.on('pointerout', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy, duration: 120 }); }); - cont.on('pointerdown', () => this.toggleSelect(card.uid)); + 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(); @@ -808,15 +1024,17 @@ export default class BalatroGame extends Phaser.Scene { 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, () => { - 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.renderView(); }); @@ -827,6 +1045,7 @@ export default class BalatroGame extends Phaser.Scene { 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 = {}; @@ -836,17 +1055,53 @@ export default class BalatroGame extends Phaser.Scene { }); 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(); @@ -867,8 +1122,6 @@ export default class BalatroGame extends Phaser.Scene { const scoringSet = new Set(res.trace.scoringUids); 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) => { if (!source) return null; @@ -876,19 +1129,24 @@ export default class BalatroGame extends Phaser.Scene { 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) - 80 : 400; + const y = sprite ? this.fxY(sprite) - 90 : 400; const t = this.add.text(x, y, textStr, { fontFamily: '"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 - 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) => { @@ -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._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': @@ -924,6 +1183,7 @@ export default class BalatroGame extends Phaser.Scene { }; const finish = () => { + this.stopScoreHum(); const totalEv = events[events.length - 1]; applyEvent(totalEv, true); 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.cameras.main.shake(160, score >= this.run.blindChips ? 0.008 : 0.004); 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)); this.time.delayedCall(this._skipAnim ? 250 : 900, () => { + this._hideUids = null; if (res.destroyedJokers && res.destroyedJokers.length) { 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 = () => { - if (this._skipAnim) { - // Snap: apply remaining counter states instantly. - finish(); + // 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; } - if (idx >= events.length) { finish(); return; } - const ev = events[idx++]; - applyEvent(ev); - this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick); + + 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) { tick(); return; } + 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 }; @@ -989,7 +1316,7 @@ export default class BalatroGame extends Phaser.Scene { if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); this._playedSprites[card.uid] = cont; remaining -= 1; - if (remaining === 0) tick(); + if (remaining === 0) playGroup(); }, }); }); diff --git a/src/games/balatro/BalatroScoring.js b/src/games/balatro/BalatroScoring.js index 98c552f..833958c 100644 --- a/src/games/balatro/BalatroScoring.js +++ b/src/games/balatro/BalatroScoring.js @@ -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. // State updates (onHandPlayed) land just before the joker's own // contribution so scalers apply to the hand that grew them. for (const e of effJokers) { if (e.inst.debuffedByBoss) continue; + const mark = events.length; 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 === 'holo') ctx.mult(10, { ...src, edition: 'holo' }); if (e.def.onHandPlayed && !e.isCopy) e.def.onHandPlayed(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' }); + stampJoker(mark, e.inst.uid); } // 4. Post-hand decays / triggers (Ice Cream, Seltzer, Loyalty, DNA...). for (const e of effJokers) { if (e.inst.debuffedByBoss || e.isCopy || !e.def.afterHand) continue; + const mark = events.length; e.def.afterHand(ctx, e.hookInst); + stampJoker(mark, e.inst.uid); } const score = Math.floor(chips) * mult; diff --git a/src/games/balatro/BalatroViews.js b/src/games/balatro/BalatroViews.js index bcb279a..2c8ccdf 100644 --- a/src/games/balatro/BalatroViews.js +++ b/src/games/balatro/BalatroViews.js @@ -202,12 +202,19 @@ export function renderShop(scene) { scene.text(470, cy - 150, 'For sale', 20, C.muted); shop.cards.forEach((item, i) => { const x = 560 + i * 190; - let cont; - if (item.slot === 'joker') cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 }); - else if (item.slot === 'playing') cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 }); - else cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 }); + let cont, info; + if (item.slot === 'joker') { + cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 }); + info = () => scene.jokerInfo(item.id, null, item.edition); + } else if (item.slot === 'playing') { + cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 }); + info = () => scene.playingCardInfo(item.card); + } else { + cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 }); + info = () => scene.consumableInfo(item.slot, item.id); + } scene.add2(cont); - scene.addHoverTilt(cont); + scene.addHoverTilt(cont, { info }); scene.text(x, cy + 132, `$${item.cost}`, 26, C.money, { ox: 0.5, bold: true }); cont.on('pointerdown', () => { const r = buyShopCard(run, i); @@ -245,7 +252,14 @@ export function renderShop(scene) { } cont.setSize(180, 240); 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 }); cont.on('pointerdown', () => { const r = buyPack(run, i); @@ -275,7 +289,15 @@ export function renderShop(scene) { } cont.setSize(190, 250); 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 }); cont.on('pointerdown', () => { const r = buyVoucher(run); @@ -308,12 +330,19 @@ export function renderPackOpen(scene) { const x0 = CX - ((n - 1) * step) / 2; pack.options.forEach((opt, i) => { const x = x0 + i * step, y = 480; - let cont; - if (opt.what === 'playing') cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h }); - else if (opt.what === 'joker') cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h }); - else cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h }); + let cont, info; + if (opt.what === 'playing') { + cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h }); + info = () => scene.playingCardInfo(opt.card); + } else if (opt.what === 'joker') { + cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h }); + info = () => scene.jokerInfo(opt.id, null, opt.edition); + } else { + cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h }); + info = () => scene.consumableInfo(opt.what, opt.id); + } scene.add2(cont); - scene.addHoverTilt(cont); + scene.addHoverTilt(cont, { info }); cont.on('pointerdown', () => { const r = pickFromPack(run, i); if (!r.ok) { scene.toast(r.error); return; } diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 4d10ddb..4a91139 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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-dice-roll', 'assets/fx/dice-roll.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-piece-click', 'assets/fx/piece-click.mp3'); this.load.audio('sfx-mastermind-glitch-1', 'assets/fx/mastermind-glitch-01.mp3'); diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 038c653..c257ec3 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -69,6 +69,7 @@ export const SFX = { SW_DARK_1: 'sfx-sw-dark-1', SW_DARK_2: 'sfx-sw-dark-2', SW_DARK_3: 'sfx-sw-dark-3', + ENERGY_HUM: 'sfx-energy-hum', }; export function playSound(scene, key) {