feat(blackjack): enhance betting UI with prompts and fix button interactions

- Add animated betting prompts with pulsing chip buttons and text overlay
- Implement delayed start for betting animations after 5 seconds
- Refactor chip buttons to use containers for proper hit detection
- Update Button component to ensure interactive events work on all child objects
- Improve betting UI visibility management with proper show/hide states
- Add depth management for betting prompts and chip buttons
This commit is contained in:
Brian Fertig 2026-05-16 16:57:15 -06:00
parent 48f7ade241
commit 1f1897c8fe
2 changed files with 192 additions and 39 deletions

View File

@ -23,9 +23,9 @@ const DEALER_X = CX;
const DEALER_Y = 420; // ~1/3 down the table ellipse (top ≈190, bottom ≈890)
const SEAT_POS = [
{ x: CX, y: 860, portraitR: 72, portraitX: CX - 230, portraitY: 860 }, // Human
{ x: 380, y: 775, portraitR: 58, portraitX: 200, portraitY: 650 }, // Opp 1 bottom-left (outside ellipse)
{ x: 150, y: 490, portraitR: 58 }, // Opp 2 left (already outside ellipse)
{ x: CX, y: 860, portraitR: 72, portraitX: CX - 230, portraitY: 860 }, // Human
{ x: 380, y: 775, portraitR: 58, portraitX: 200, portraitY: 650 }, // Opp 1 bottom-left (outside ellipse)
{ x: 150, y: 490, portraitR: 58 }, // Opp 2 left (already outside ellipse)
{ x: 1770, y: 490, portraitR: 58 }, // Opp 3 right (already outside ellipse)
{ x: 1540, y: 775, portraitR: 58, portraitX: 1720, portraitY: 650 }, // Opp 4 bottom-right (outside ellipse)
];
@ -46,15 +46,15 @@ export default class BlackjackGame extends Phaser.Scene {
constructor() { super('BlackjackGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
}
async create() {
this.shoe = buildShoe();
this.gs = null;
this.gs = null;
this.animating = false;
this.pendingBet = 0;
this.portraits = [];
@ -72,6 +72,9 @@ export default class BlackjackGame extends Phaser.Scene {
this.statusBadges = {};
this.nameTxts = {};
this.chipTxts = {};
this.bettingPromptGroup = [];
this.bettingPromptTimer = null;
this.chipPulseTweens = [];
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg);
this.buildPlayfield();
@ -165,9 +168,9 @@ export default class BlackjackGame extends Phaser.Scene {
// Semi-transparent backing behind both text lines, depth below portrait images/videos
{
const maxTW = Math.max(this.nameTxts[seat].width, 130);
const rectW = maxTW + 20;
const rectH = (chipY - nameY) + 36;
const maxTW = Math.max(this.nameTxts[seat].width, 130);
const rectW = maxTW + 20;
const rectH = (chipY - nameY) + 36;
// For right-anchored text the anchor is the RIGHT edge; for left-anchored it's the LEFT edge
const rectCX = labelAnchorX === 1 ? nameX - maxTW / 2 : nameX + maxTW / 2;
const rectCY = (nameY + chipY) / 2;
@ -199,15 +202,17 @@ export default class BlackjackGame extends Phaser.Scene {
// Chip buttons
CHIP_AMOUNTS.forEach((amt, i) => {
const bx = cx - 120 + i * 80;
const g = this.add.graphics().setDepth(D.ui + 1);
g.fillStyle(CHIP_COLORS[amt], 1);
g.fillCircle(bx, y, 28);
const container = this.add.container(bx, y).setDepth(D.ui + 1);
const g = this.add.graphics();
g.lineStyle(3, 0xffffff, 0.4);
g.strokeCircle(bx, y, 28);
g.setInteractive(new Phaser.Geom.Circle(bx, y, 28), Phaser.Geom.Circle.Contains);
g.on('pointerdown', () => this.onChipClick(amt));
g.on('pointerover', () => g.setAlpha(0.8));
g.on('pointerout', () => g.setAlpha(1));
g.strokeCircle(0, 0, 28);
g.fillStyle(CHIP_COLORS[amt], 1);
g.fillCircle(0, 0, 28);
container.add(g);
container.setInteractive(new Phaser.Geom.Circle(0, 0, 28), Phaser.Geom.Circle.Contains);
container.on('pointerdown', () => this.onChipClick(amt));
container.on('pointerover', () => container.setAlpha(0.8));
container.on('pointerout', () => container.setAlpha(1));
const t = this.add.text(bx, y, `$${amt}`, {
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
@ -238,8 +243,136 @@ export default class BlackjackGame extends Phaser.Scene {
this.hideBettingUI();
}
showBettingUI() { for (const o of this.bettingUIGroup) o.setVisible?.(true) || (o.visible = true); }
hideBettingUI() { for (const o of this.bettingUIGroup) o.setVisible?.(false) || (o.visible = false); }
showBettingUI() {
for (const o of this.bettingUIGroup) o.setVisible?.(true) || (o.visible = true);
this.chipBtnGraphics.forEach(g => g.setDepth(D.ui + 1));
this.startBettingPrompts();
}
hideBettingUI() {
for (const o of this.bettingUIGroup) o.setVisible?.(false) || (o.visible = false);
this.hideBettingPrompts();
}
startBettingPrompts() {
this.hideBettingPrompts();
// Wait 5 seconds before starting any animations
this.bettingPromptTimer = this.time.delayedCall(5000, () => {
if (!this.scene.isActive('BlackjackGame')) return;
if (this.pendingBet > 0) return; // Don't start if already betting
// Start subtle radius pulsing animation
this.chipBtnGraphics.forEach((chip, index) => {
const delay = index * 200;
const tween = this.tweens.add({
targets: chip,
scaleX: 1.25,
scaleY: 1.25,
duration: 1200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
delay: delay,
});
this.chipPulseTweens.push(tween);
});
// Show prompt text
this.showBettingPrompt();
});
}
showBettingPrompt() {
if (this.pendingBet > 0) return; // Don't show if already betting
const y = GAME_HEIGHT - 160;
const cx = CX;
// Create semi-transparent background
const bg = this.add.rectangle(cx + 100, y, 420, 60, 0x000000, 0.7)
.setOrigin(0.5, 0.5)
.setDepth(D.ui + 10)
.setAlpha(0);
// Create prompt text
const text = this.add.text(cx + 100, y, 'Choose an amount to bet and click Deal to begin', {
fontFamily: 'system-ui, sans-serif',
fontSize: '18px',
color: '#ffffff',
align: 'center',
})
.setOrigin(0.5, 0.5)
.setDepth(D.ui + 11)
.setAlpha(0);
this.bettingPromptGroup = [bg, text];
// Fade in
this.tweens.add({
targets: bg,
alpha: 0.7,
duration: 300,
ease: 'Power2',
});
this.tweens.add({
targets: text,
alpha: 1,
duration: 300,
ease: 'Power2',
delay: 100,
});
}
hideBettingPrompts() {
// Fade out prompt if visible
if (this.bettingPromptGroup && this.bettingPromptGroup.length > 0) {
this.tweens.add({
targets: this.bettingPromptGroup,
alpha: 0,
duration: 200,
ease: 'Power2',
onComplete: () => {
this.bettingPromptGroup.forEach(obj => obj.destroy());
this.bettingPromptGroup = [];
}
});
} else if (this.bettingPromptGroup.length > 0) {
this.bettingPromptGroup.forEach(obj => obj.destroy());
this.bettingPromptGroup = [];
}
// Stop chip pulsing
this.chipPulseTweens.forEach(tween => tween.destroy());
this.chipPulseTweens = [];
this.chipBtnGraphics.forEach(chip => {
chip.setScale(1, 1);
chip.setAlpha(1);
});
// Clear timer
if (this.bettingPromptTimer) {
this.bettingPromptTimer.remove();
this.bettingPromptTimer = null;
}
}
startBettingPromptsIfNoBet() {
// Only start prompts if no bet has been placed yet
if (this.pendingBet === 0) {
this.startBettingPrompts();
}
}
hideBettingUI() {
for (const o of this.bettingUIGroup) {
o.setVisible?.(false) || (o.visible = false);
}
this.hideBettingPrompts();
// Also hide chip containers from the betting UI
this.chipBtnGraphics.forEach(g => g.setVisible?.(false) || (g.visible = false));
this.chipBtnGraphics.forEach(g => g.setDepth(-100));
}
// ── Chip balance ──────────────────────────────────────────────────────────
async loadPlayerChips() {
@ -445,10 +578,10 @@ export default class BlackjackGame extends Phaser.Scene {
fontFamily: 'system-ui, sans-serif', fontSize: `${sz}px`, color,
...(bold ? { fontStyle: 'bold' } : {}),
});
container.add(this.add.text(x + 7, y + 5, card.label, style(17, true)));
container.add(this.add.text(x + 7, y + 23, card.suitSymbol, style(13)));
container.add(this.add.text(x + 7, y + 5, card.label, style(17, true)));
container.add(this.add.text(x + 7, y + 23, card.suitSymbol, style(13)));
container.add(this.add.text(0, 4, card.suitSymbol, style(40)).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, card.label, style(17, true)).setOrigin(1, 1));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, card.label, style(17, true)).setOrigin(1, 1));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 22, card.suitSymbol, style(13)).setOrigin(1, 1));
} else {
this.addCardBackToContainer(container);
@ -474,11 +607,13 @@ export default class BlackjackGame extends Phaser.Scene {
if (this.pendingBet + amount > human.chips) return;
this.pendingBet += amount;
this.updateBetDisplay();
this.hideBettingPrompts();
}
onClearBet() {
this.pendingBet = 0;
this.updateBetDisplay();
this.startBettingPromptsIfNoBet();
}
updateBetDisplay() {
@ -492,6 +627,7 @@ export default class BlackjackGame extends Phaser.Scene {
if (this.animating || this.pendingBet < 5) return;
this.animating = true;
this.hideBettingUI();
this.hideBettingPrompts();
// Commit human bet
this.gs = applyBet(this.gs, 0, this.pendingBet);
@ -659,10 +795,10 @@ export default class BlackjackGame extends Phaser.Scene {
const p = this.gs.players[seat];
if (!p?.active || !p.result) { onComplete(); return; }
const result = p.result;
const pos = SEAT_POS[seat];
const isWin = result === 'win' || result === 'blackjack';
const isLose = result === 'lose' || result === 'bust';
const result = p.result;
const pos = SEAT_POS[seat];
const isWin = result === 'win' || result === 'blackjack';
const isLose = result === 'lose' || result === 'bust';
if (seat > 0 && this.portraits[seat]) {
this.portraits[seat].playEmotion?.(isWin ? 'happy' : 'upset');
@ -670,7 +806,7 @@ export default class BlackjackGame extends Phaser.Scene {
const LABELS = { win: 'Win!', blackjack: 'Blackjack!', push: 'Push', lose: 'Lose', bust: 'Bust' };
const COLORS = { win: '#f5d020', blackjack: '#ffe066', push: '#9aa5b4', lose: '#e05c5c', bust: '#e05c5c' };
const SIZES = { win: '54px', blackjack: '58px', push: '34px', lose: '48px', bust: '48px' };
const SIZES = { win: '54px', blackjack: '58px', push: '34px', lose: '48px', bust: '48px' };
const linger = isWin ? 1500 : isLose ? 1100 : 700;
const textY = pos.y - CARD_H / 2 - 60;
@ -717,7 +853,7 @@ export default class BlackjackGame extends Phaser.Scene {
for (let i = 0; i < 4; i++) {
const chip = this.add.graphics().setDepth(D.chips + 5);
chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.x = betX + (Math.random() - 0.5) * 22;
chip.y = betY;
this.tweens.add({
@ -730,12 +866,12 @@ export default class BlackjackGame extends Phaser.Scene {
animateChipsFromDealer(seat, amount) {
if (!amount || amount <= 0) return;
const pos = SEAT_POS[seat];
const pos = SEAT_POS[seat];
const count = Math.min(Math.ceil(amount / 25), 7);
for (let i = 0; i < count; i++) {
const chip = this.add.graphics().setDepth(D.chips + 5);
chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.x = DEALER_X + (Math.random() - 0.5) * 50;
chip.y = DEALER_Y;
this.tweens.add({
@ -754,7 +890,7 @@ export default class BlackjackGame extends Phaser.Scene {
for (let i = 0; i < 3; i++) {
const chip = this.add.graphics().setDepth(D.chips + 5);
chip.fillStyle(CHIP_COLORS[25], 1); chip.fillCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.lineStyle(2, 0xffffff, 0.3); chip.strokeCircle(0, 0, 14);
chip.x = betX + (Math.random() - 0.5) * 20;
chip.y = betY;
this.tweens.add({
@ -776,9 +912,9 @@ export default class BlackjackGame extends Phaser.Scene {
const by = cy - 10 + (Math.random() - 0.5) * 80;
for (let i = 0; i < 10; i++) {
const angle = (i / 10) * Math.PI * 2 + Math.random() * 0.3;
const dist = 65 + Math.random() * 65;
const dist = 65 + Math.random() * 65;
const color = palette[Math.floor(Math.random() * palette.length)];
const dot = this.add.graphics().setDepth(D.modal + 5);
const dot = this.add.graphics().setDepth(D.modal + 5);
dot.fillStyle(color, 1);
dot.fillCircle(0, 0, 4 + Math.random() * 3);
dot.x = bx; dot.y = by;
@ -891,8 +1027,8 @@ export default class BlackjackGame extends Phaser.Scene {
const by = GAME_HEIGHT - 80;
const btns = [
new Button(this, CX - 180, by, 'Hit', () => this.onHit(), { width: 120, height: 52, fontSize: 20 }),
new Button(this, CX - 50, by, 'Stand', () => this.onStand(), { width: 120, height: 52, fontSize: 20 }),
new Button(this, CX - 180, by, 'Hit', () => this.onHit(), { width: 120, height: 52, fontSize: 20 }),
new Button(this, CX - 50, by, 'Stand', () => this.onStand(), { width: 120, height: 52, fontSize: 20 }),
];
if (canDouble(p)) {
btns.push(new Button(this, CX + 80, by, 'Double', () => this.onDouble(), { width: 130, height: 52, fontSize: 20 }));
@ -900,6 +1036,7 @@ export default class BlackjackGame extends Phaser.Scene {
if (canSplit(p) && p.chips >= p.bet) {
btns.push(new Button(this, CX + 220, by, 'Split', () => this.onSplit(), { width: 120, height: 52, fontSize: 20 }));
}
btns.forEach(btn => btn.setDepth(D.ui + 5));
this.actionBtns = btns;
}
@ -1129,7 +1266,7 @@ export default class BlackjackGame extends Phaser.Scene {
const sorted = [...active].sort((a, b) => SEAT_POS[b.seat].x - SEAT_POS[a.seat].x);
let pending = sorted.length;
const done = () => { if (--pending <= 0) onComplete(); };
const done = () => { if (--pending <= 0) onComplete(); };
sorted.forEach((p, i) => {
this.time.delayedCall(i * 480, () => this.animateSeatResult(p.seat, done));

View File

@ -28,7 +28,9 @@ export class Button extends Phaser.GameObjects.Container {
this.add([this.bgRect, this.text]);
this.setSize(width, height);
this.setInteractive({ useHandCursor: true });
this.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(0, 0, width, height), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
this.bgRect.setInteractive({ hitArea: new Phaser.Geom.Rectangle(0, 0, width, height), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
this.text.setInteractive({ hitArea: new Phaser.Geom.Rectangle(0, 0, width, height), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
this.on('pointerover', () => this.bgRect.setFillStyle(bgHover, 1));
this.on('pointerout', () => this.bgRect.setFillStyle(bg, variant === 'ghost' ? 0 : 1));
this.on('pointerdown', () => this.bgRect.setScale(0.97));
@ -36,6 +38,20 @@ export class Button extends Phaser.GameObjects.Container {
this.on('pointerupoutside', () => this.bgRect.setScale(1));
if (onClick) this.on('pointerup', onClick);
this.bgRect.on('pointerover', () => this.bgRect.setFillStyle(bgHover, 1));
this.bgRect.on('pointerout', () => this.bgRect.setFillStyle(bg, variant === 'ghost' ? 0 : 1));
this.bgRect.on('pointerdown', () => this.bgRect.setScale(0.97));
this.bgRect.on('pointerup', () => this.bgRect.setScale(1));
this.bgRect.on('pointerupoutside', () => this.bgRect.setScale(1));
if (onClick) this.bgRect.on('pointerup', onClick);
this.text.on('pointerover', () => this.bgRect.setFillStyle(bgHover, 1));
this.text.on('pointerout', () => this.bgRect.setFillStyle(bg, variant === 'ghost' ? 0 : 1));
this.text.on('pointerdown', () => this.bgRect.setScale(0.97));
this.text.on('pointerup', () => this.bgRect.setScale(1));
this.text.on('pointerupoutside', () => this.bgRect.setScale(1));
if (onClick) this.text.on('pointerup', onClick);
scene.add.existing(this);
}