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:
parent
29a242b9f5
commit
9a97775ec9
Binary file not shown.
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue