diff --git a/assets/fx/energy-hum.mp3 b/assets/fx/energy-hum.mp3 new file mode 100644 index 0000000..b6788dd Binary files /dev/null and b/assets/fx/energy-hum.mp3 differ 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) {