feat: overhaul player turn order and add action callouts

- Replace left-to-right seat processing with clockwise PLAY_ORDER starting top-right
- Animate deal sequence to follow the new play order
- Add pulsing highlight ring for active player's portrait
- Show large animated action callouts (Hit/Stand/Double/Split) between player and cards
- Position action buttons dynamically relative to the active player's portrait
- Update result reveal order to match clockwise play order
- Move betting UI panel and adjust button depths for proper layering
- Remove unused `nextActiveSeat` import in favor of `nextPlaySeat` method
This commit is contained in:
Brian Fertig 2026-05-24 16:21:44 -06:00
parent 349d2736c9
commit 02d10faa2d
1 changed files with 141 additions and 36 deletions

View File

@ -10,7 +10,7 @@ import {
buildShoe, handValue, isBlackjack, isBust, canDouble, canSplit,
createInitialState, prepareRound, applyBet, dealAllCards,
applyInsurance, resolveInsurance, applyHit, applyStand, applyDouble, applySplit,
runDealerTurn, resolveRound, nextActiveSeat, isPlayerDone,
runDealerTurn, resolveRound, isPlayerDone,
} from './BlackjackLogic.js';
import { chooseBet, chooseAction, chooseInsurance } from './BlackjackAI.js';
@ -28,15 +28,22 @@ const DEALER_Y = 420; // ~1/3 down the table ellipse (top ≈190, bottom ≈890)
// 6 opponents fanned 3-per-side (filled outward-alternating so the table stays
// balanced when fewer than 6 opponents are chosen).
const SEAT_POS = [
{ x: CX, y: 860, portraitR: 72, portraitX: CX - 230, portraitY: 860, betX: 1110, betY: 770 }, // 0 Human (bottom centre)
{ x: 1490, y: 745, portraitR: 58, portraitX: 1600, portraitY: 800 }, // 1 right-bottom
{ x: 430, y: 745, portraitR: 58, portraitX: 320, portraitY: 800 }, // 2 left-bottom
{ x: 1670, y: 595, portraitR: 58, portraitX: 1805, portraitY: 610 }, // 3 right-mid
{ x: 250, y: 595, portraitR: 58, portraitX: 115, portraitY: 610 }, // 4 left-mid
{ x: 1585, y: 375, portraitR: 58, portraitX: 1705, portraitY: 335, labelDX: 10 }, // 5 right-top
{ x: 335, y: 375, portraitR: 58, portraitX: 215, portraitY: 335, labelDX: -10 }, // 6 left-top
{ x: CX, y: 782, portraitR: 72, portraitX: CX - 230, portraitY: 860, betX: 1110, betY: 770 }, // 0 Human (bottom centre)
{ x: 1380, y: 710, portraitR: 58, portraitX: 1600, portraitY: 800 }, // 1 right-bottom
{ x: 540, y: 710, portraitR: 58, portraitX: 320, portraitY: 800 }, // 2 left-bottom
{ x: 1560, y: 588, portraitR: 58, portraitX: 1805, portraitY: 610 }, // 3 right-mid
{ x: 360, y: 588, portraitR: 58, portraitX: 115, portraitY: 610 }, // 4 left-mid
{ x: 1470, y: 410, portraitR: 58, portraitX: 1705, portraitY: 335, labelDX: 10 }, // 5 right-top
{ x: 450, y: 410, portraitR: 58, portraitX: 215, portraitY: 335, labelDX: -10 }, // 6 left-top
];
// Turn order: start at the top-right seat and proceed clockwise around the table.
const PLAY_ORDER = [5, 3, 1, 0, 2, 4, 6];
// Large per-action callout shown between a player and their cards.
const ACTION_LABELS = { hit: 'Hit', stand: 'Stand', double: 'Double', split: 'Split' };
const ACTION_COLORS = { hit: '#3cc6c0', stand: '#f5d020', double: '#ff8c00', split: '#b07cd6' };
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
const CHIP_TEXT_COLORS = { 5: '#ffffff', 25: '#ffffff', 50: '#ffffff', 100: '#ffffff' };
const CHIP_AMOUNTS = [5, 25, 50, 100];
@ -253,6 +260,14 @@ export default class BlackjackGame extends Phaser.Scene {
const y = GAME_HEIGHT - 80;
const cx = CX + 60; // shift right slightly to avoid portrait
// Solid window behind all the betting controls (shown between rounds)
const panel = this.add.graphics().setDepth(D.ui);
panel.fillStyle(0x000000, 1);
panel.fillRoundedRect(cx - 174, y - 69, 726, 116, 18);
panel.lineStyle(3, COLORS.accent, 1);
panel.strokeRoundedRect(cx - 174, y - 69, 726, 116, 18);
this.bettingUIGroup.push(panel);
// Chip buttons
CHIP_AMOUNTS.forEach((amt, i) => {
const bx = cx - 120 + i * 80;
@ -292,6 +307,9 @@ export default class BlackjackGame extends Phaser.Scene {
width: 110, height: 50, fontSize: 20,
});
this.dealBtn.setEnabled(false);
// Keep the buttons above the betting window panel
clearBtn.setDepth(D.ui + 1);
this.dealBtn.setDepth(D.ui + 1);
this.bettingUIGroup.push(this.betDisplayText, this.balanceText, clearBtn, this.dealBtn);
this.hideBettingUI();
@ -451,6 +469,7 @@ export default class BlackjackGame extends Phaser.Scene {
this.bustedSeats = new Set();
this.gs = prepareRound(this.gs);
this.clearCardGraphics();
this.clearActiveHighlight();
this.renderAll();
this.showBettingUI();
this.updateBetDisplay();
@ -735,12 +754,13 @@ export default class BlackjackGame extends Phaser.Scene {
}
animateDeal(onComplete) {
// Build deal order: player0-c1, p1-c1, ..., dealer-upcard, p0-c2, p1-c2, ..., dealer-hole
const activePlayers = this.gs.players.filter(p => p.active);
// Deal in play order (top-right, clockwise): each player card 1, dealer upcard,
// each player card 2, dealer hole.
const seats = PLAY_ORDER.filter(seat => this.gs.players[seat]?.active);
const dealSeq = [];
for (const p of activePlayers) dealSeq.push({ type: 'player', seat: p.seat, cardIdx: 0 });
for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 0 });
dealSeq.push({ type: 'dealer', cardIdx: 0 }); // upcard
for (const p of activePlayers) dealSeq.push({ type: 'player', seat: p.seat, cardIdx: 1 });
for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 1 });
dealSeq.push({ type: 'dealer', cardIdx: 1 }); // hole card
// Hide all cards initially
@ -829,8 +849,8 @@ export default class BlackjackGame extends Phaser.Scene {
const tx = this.cardX(pos.x, cardIdx, total);
const ty = pos.y;
// Flying card-back
const flying = this.add.container(CX + 200, 50).setDepth(D.cards + 10);
// Flying card-back dealt from the dealer's area
const flying = this.add.container(DEALER_X, DEALER_Y).setDepth(D.cards + 10);
this.addCardBackToContainer(flying);
this.tweens.add({
@ -850,6 +870,67 @@ export default class BlackjackGame extends Phaser.Scene {
});
}
// ── Per-turn action callout ──────────────────────────────────────────────
// Large colored word (Hit/Stand/Double/Split) shown between the player and
// their cards; lingers >= 1s before fading. Fire-and-forget (no flow gating).
animateActionText(seat, action) {
const label = ACTION_LABELS[action];
if (!label) return;
const pos = SEAT_POS[seat];
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? pos.y;
const tx = (px + pos.x) / 2;
const ty = (py + pos.y) / 2;
const txt = this.add.text(tx, ty, label, {
fontFamily: '"Julius Sans One"',
fontSize: '44px',
color: ACTION_COLORS[action] ?? '#ffffff',
fontStyle: 'bold',
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setAlpha(0).setScale(1.4).setDepth(D.modal);
this.tweens.add({
targets: txt, alpha: 1, scaleX: 1, scaleY: 1,
duration: 180, ease: 'Back.Out',
});
this.time.delayedCall(1000, () => {
this.tweens.add({
targets: txt, alpha: 0, y: ty - 26,
duration: 350, ease: 'Power2',
onComplete: () => txt.destroy(),
});
});
}
// ── Active-player turn highlight (pulsing ring around the portrait) ────────
highlightActiveSeat(seat) {
const pos = SEAT_POS[seat];
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? pos.y;
if (!this.activeRing) {
this.activeRing = this.add.graphics().setDepth(D.ui - 1);
}
this.activeRing.clear();
this.activeRing.lineStyle(5, COLORS.accent, 0.9);
this.activeRing.strokeCircle(0, 0, pos.portraitR + 8);
this.activeRing.setPosition(px, py).setVisible(true).setScale(1).setAlpha(1);
this.activeRingTween?.stop();
this.activeRingTween = this.tweens.add({
targets: this.activeRing,
scaleX: 1.12, scaleY: 1.12, alpha: 0.45,
duration: 650, ease: 'Sine.easeInOut', yoyo: true, repeat: -1,
});
}
clearActiveHighlight() {
this.activeRingTween?.stop();
this.activeRingTween = null;
if (this.activeRing) this.activeRing.setVisible(false);
}
// ── Per-seat result animation (text + chips + fireworks) ─────────────────
animateSeatResult(seat, onComplete) {
const p = this.gs.players[seat];
@ -1054,14 +1135,29 @@ export default class BlackjackGame extends Phaser.Scene {
}
// ── Player turns ──────────────────────────────────────────────────────────
// First seat to act, walking PLAY_ORDER (clockwise from top-right).
firstPlaySeat() {
for (const seat of PLAY_ORDER) {
if (this.gs.players[seat]?.active) return seat;
}
return 0;
}
// Next seat in PLAY_ORDER after the current one that still needs to act.
nextPlaySeat() {
const start = PLAY_ORDER.indexOf(this.gs.currentSeat);
for (let i = start + 1; i < PLAY_ORDER.length; i++) {
const p = this.gs.players[PLAY_ORDER[i]];
if (p?.active && (p.status === 'playing' || p.status === 'waiting')) return PLAY_ORDER[i];
}
return null;
}
startPlayerTurns() {
this.gs = { ...this.gs, phase: 'player_turn' };
// Mark first active player as 'playing'
const p = this.gs.players.find(pl => pl.active);
if (p) {
this.gs = { ...this.gs, currentSeat: p.seat };
}
this.advanceToPlayer(p?.seat ?? 0);
const seat = this.firstPlaySeat();
this.gs = { ...this.gs, currentSeat: seat };
this.advanceToPlayer(seat);
}
advanceToPlayer(seat) {
@ -1071,13 +1167,14 @@ export default class BlackjackGame extends Phaser.Scene {
// Skip if already determined (blackjack on deal)
if (isPlayerDone(p)) {
const next = nextActiveSeat(this.gs);
const next = this.nextPlaySeat();
if (next === null) { this.startDealerTurn(); return; }
this.gs = { ...this.gs, currentSeat: next };
this.advanceToPlayer(next);
return;
}
this.highlightActiveSeat(seat);
if (p.isHuman) {
this.showActionButtons();
} else {
@ -1090,17 +1187,21 @@ export default class BlackjackGame extends Phaser.Scene {
const seat = this.gs.currentSeat;
const p = this.gs.players[seat];
const pos = SEAT_POS[seat];
const by = GAME_HEIGHT - 80;
const by = 918; // just under the table felt
let bx = pos.portraitX + pos.portraitR + 80; // first button, just right of the portrait
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, bx, by, 'Hit', () => this.onHit(), { width: 120, height: 52, fontSize: 20 }),
];
bx += 130;
btns.push(new Button(this, bx, 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 }));
bx += 130;
btns.push(new Button(this, bx, by, 'Double', () => this.onDouble(), { width: 130, height: 52, fontSize: 20 }));
}
if (canSplit(p) && p.chips >= p.bet) {
btns.push(new Button(this, CX + 220, by, 'Split', () => this.onSplit(), { width: 120, height: 52, fontSize: 20 }));
bx += 140;
btns.push(new Button(this, bx, by, 'Split', () => this.onSplit(), { width: 120, height: 52, fontSize: 20 }));
}
btns.forEach(btn => btn.setDepth(D.ui + 5));
this.actionBtns = btns;
@ -1127,6 +1228,7 @@ export default class BlackjackGame extends Phaser.Scene {
onHit() {
this.hideActionButtons();
this.animateActionText(this.gs.currentSeat, 'hit');
this.animating = true;
this.gs = applyHit(this.gs, this.gs.currentSeat, this.shoe);
const p = this.gs.players[this.gs.currentSeat];
@ -1151,6 +1253,7 @@ export default class BlackjackGame extends Phaser.Scene {
onStand() {
this.hideActionButtons();
const seat = this.gs.currentSeat;
this.animateActionText(seat, 'stand');
this.gs = applyStand(this.gs, seat);
const p = this.gs.players[seat];
// If split and now playing hand 2, stay on same seat
@ -1163,6 +1266,7 @@ export default class BlackjackGame extends Phaser.Scene {
onDouble() {
this.hideActionButtons();
this.animateActionText(this.gs.currentSeat, 'double');
this.animating = true;
this.gs = applyDouble(this.gs, this.gs.currentSeat, this.shoe);
this.renderSeatCards(this.gs.currentSeat);
@ -1181,6 +1285,7 @@ export default class BlackjackGame extends Phaser.Scene {
onSplit() {
this.hideActionButtons();
this.animateActionText(this.gs.currentSeat, 'split');
this.animating = true;
this.gs = applySplit(this.gs, this.gs.currentSeat, this.shoe);
this.renderSeatCards(this.gs.currentSeat);
@ -1205,7 +1310,7 @@ export default class BlackjackGame extends Phaser.Scene {
this.showActionButtons();
return;
}
const next = nextActiveSeat(this.gs);
const next = this.nextPlaySeat();
if (next === null) {
this.startDealerTurn();
} else {
@ -1223,6 +1328,7 @@ export default class BlackjackGame extends Phaser.Scene {
}
const action = chooseAction(p, this.gs.dealer.hand[0]);
this.animateActionText(seat, action);
this.animating = true;
const doAction = () => {
@ -1293,6 +1399,7 @@ export default class BlackjackGame extends Phaser.Scene {
// ── Dealer turn ───────────────────────────────────────────────────────────
startDealerTurn() {
this.clearActiveHighlight();
this.gs = { ...this.gs, phase: 'dealer_turn', currentSeat: -1 };
this.renderAll();
this.time.delayedCall(400, () => {
@ -1325,17 +1432,15 @@ export default class BlackjackGame extends Phaser.Scene {
}
showAllResults(onComplete) {
const active = this.gs.players.filter(p => p.active);
if (active.length === 0) { onComplete(); return; }
// Reveal top-right then clockwise around the table (PLAY_ORDER).
const seats = PLAY_ORDER.filter(seat => this.gs.players[seat]?.active);
if (seats.length === 0) { onComplete(); return; }
// Reveal right-to-left (descending seat x position)
const sorted = [...active].sort((a, b) => SEAT_POS[b.seat].x - SEAT_POS[a.seat].x);
let pending = sorted.length;
let pending = seats.length;
const done = () => { if (--pending <= 0) onComplete(); };
sorted.forEach((p, i) => {
this.time.delayedCall(i * 480, () => this.animateSeatResult(p.seat, done));
seats.forEach((seat, i) => {
this.time.delayedCall(i * 480, () => this.animateSeatResult(seat, done));
});
}
@ -1360,7 +1465,7 @@ export default class BlackjackGame extends Phaser.Scene {
return;
}
const btn = new Button(this, CX, GAME_HEIGHT / 2 + 40, 'Next Round', () => {
const btn = new Button(this, CX, 925, 'Next Round', () => {
btn.destroy();
this.startNewRound();
}, { width: 220, height: 60, fontSize: 24 });