1484 lines
53 KiB
JavaScript
1484 lines
53 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { Modal } from '../../ui/Modal.js';
|
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
|
import { api } from '../../services/api.js';
|
|
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
|
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
import {
|
|
buildShoe, handValue, isBlackjack, isBust, canDouble, canSplit,
|
|
createInitialState, prepareRound, applyBet, dealAllCards,
|
|
applyInsurance, resolveInsurance, applyHit, applyStand, applyDouble, applySplit,
|
|
runDealerTurn, resolveRound, isPlayerDone,
|
|
} from './BlackjackLogic.js';
|
|
import { chooseBet, chooseAction, chooseInsurance } from './BlackjackAI.js';
|
|
|
|
// ─── Layout ───────────────────────────────────────────────────────────────────
|
|
const CX = GAME_WIDTH / 2;
|
|
const CARD_W = 90;
|
|
const CARD_H = 126;
|
|
const CARD_R = 8;
|
|
const CARD_SPREAD = 28; // horizontal offset between stacked cards
|
|
|
|
const DEALER_X = CX;
|
|
const DEALER_Y = 420; // ~1/3 down the table ellipse (top ≈190, bottom ≈890)
|
|
|
|
// 7 seats fanned around the oval: seat 0 (human) anchored bottom-centre,
|
|
// 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: 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];
|
|
|
|
const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, modal: 50 };
|
|
|
|
const RESULT_COLORS = {
|
|
win: '#5cb85c', lose: '#e05c5c', push: '#8a94a6',
|
|
blackjack: '#f5d020', bust: '#e05c5c',
|
|
};
|
|
|
|
// ─── Scene ────────────────────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
async create() {
|
|
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
|
this.shoe = buildShoe();
|
|
this.gs = null;
|
|
this.animating = false;
|
|
this.pendingBet = 0;
|
|
this.portraits = [];
|
|
this.cardGraphics = {}; // seat → array of Phaser.Container
|
|
this.dealerCardGraphics = [];
|
|
this.betGraphics = {}; // seat → Phaser.Container
|
|
this.bustedSeats = new Set();
|
|
this.actionBtns = [];
|
|
this.bettingUIGroup = [];
|
|
this.chipBtnGraphics = [];
|
|
this.betDisplayText = null;
|
|
this.balanceText = null;
|
|
this.scoreTxts = {};
|
|
this.dealerScoreTxt = null;
|
|
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();
|
|
this.buildTable();
|
|
this.buildTableMarkings();
|
|
this.buildDealerArea();
|
|
this.buildSeats();
|
|
this.buildBettingUI();
|
|
|
|
new Button(this, 80, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
|
|
variant: 'ghost', width: 140, fontSize: 20,
|
|
});
|
|
|
|
await this.loadPlayerChips();
|
|
this.initGame();
|
|
}
|
|
|
|
// ── Playfield background ─────────────────────────────────────────────────
|
|
buildPlayfield() {
|
|
const pf = this.playfield;
|
|
if (pf?.key && this.textures.exists(pf.key)) {
|
|
this.add.image(CX, GAME_HEIGHT / 2, pf.key)
|
|
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
|
|
.setDepth(D.bg + 1);
|
|
}
|
|
}
|
|
|
|
// ── Table ─────────────────────────────────────────────────────────────────
|
|
buildTable() {
|
|
const g = this.add.graphics().setDepth(D.table);
|
|
g.fillStyle(0x1a5c2a, 1);
|
|
g.fillEllipse(CX, 540, 1500, 700);
|
|
g.lineStyle(8, 0x2e7d32, 1);
|
|
g.strokeEllipse(CX, 540, 1500, 700);
|
|
// Inner stripe
|
|
g.lineStyle(3, 0x4caf50, 0.4);
|
|
g.strokeEllipse(CX, 540, 1440, 640);
|
|
}
|
|
|
|
// ── Traditional felt markings ───────────────────────────────────────────────
|
|
// White card-deal outlines + bet circles for every seat (drawn for all 7 so
|
|
// unoccupied seats still show open spots), plus curved felt lettering.
|
|
buildTableMarkings() {
|
|
const g = this.add.graphics().setDepth(D.table + 1);
|
|
|
|
for (let seat = 0; seat < SEAT_POS.length; seat++) {
|
|
const pos = SEAT_POS[seat];
|
|
g.lineStyle(2, 0xffffff, 0.5);
|
|
g.strokeRoundedRect(
|
|
pos.x - CARD_W / 2 - 4, pos.y - CARD_H / 2 - 4,
|
|
CARD_W + 8, CARD_H + 8, CARD_R + 2,
|
|
);
|
|
const { x: bx, y: by } = this.betCirclePos(seat);
|
|
g.lineStyle(2, 0xffffff, 0.55);
|
|
g.strokeCircle(bx, by, 30);
|
|
}
|
|
|
|
// Curved insurance line + label (concentric with the text arc)
|
|
const insBaseY = 512, insRadius = 820;
|
|
const insCx = CX, insCy = insBaseY - insRadius;
|
|
const insSpan = 0.34;
|
|
g.lineStyle(3, 0xffffff, 0.45);
|
|
g.beginPath();
|
|
g.arc(insCx, insCy, insRadius + 16, Math.PI / 2 - insSpan, Math.PI / 2 + insSpan, false);
|
|
g.strokePath();
|
|
this.drawArcText('INSURANCE PAYS 2 TO 1', insCx, insBaseY, insRadius, {
|
|
fontSize: 20, color: COLORS.textHex, advanceFactor: 0.95,
|
|
});
|
|
|
|
this.drawArcText('BLACKJACK PAYS 3 TO 2', CX, 610, 760, {
|
|
fontSize: 30, color: COLORS.goldHex, bold: true, advanceFactor: 0.98,
|
|
});
|
|
|
|
this.drawArcText('DEALER MUST STAND ON ALL 17s · DRAW TO 16', CX, 668, 720, {
|
|
fontSize: 19, color: COLORS.mutedHex, advanceFactor: 0.86,
|
|
});
|
|
}
|
|
|
|
// Draws a string along a downward-bulging arc, glyph by glyph (Phaser has no
|
|
// native curved text). (centerX, baseY) is where the middle of the text sits;
|
|
// the line curves upward toward both ends.
|
|
drawArcText(text, centerX, baseY, radius, opts = {}) {
|
|
const fontSize = opts.fontSize ?? 24;
|
|
const anglePer = ((opts.advanceFactor ?? 0.92) * fontSize) / radius;
|
|
const cy = baseY - radius; // circle centre, above the text
|
|
const start = -anglePer * (text.length - 1) / 2;
|
|
const style = {
|
|
fontFamily: opts.fontFamily ?? '"Julius Sans One"',
|
|
fontSize: `${fontSize}px`,
|
|
color: opts.color ?? COLORS.textHex,
|
|
...(opts.bold ? { fontStyle: 'bold' } : {}),
|
|
};
|
|
const depth = opts.depth ?? (D.table + 1);
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (ch === ' ') continue;
|
|
const a = start + i * anglePer; // offset from straight-down
|
|
this.add.text(centerX + radius * Math.sin(a), cy + radius * Math.cos(a), ch, style)
|
|
.setOrigin(0.5)
|
|
.setRotation(-a)
|
|
.setDepth(depth);
|
|
}
|
|
}
|
|
|
|
// ── Dealer area ───────────────────────────────────────────────────────────
|
|
buildDealerArea() {
|
|
this.add.text(CX, 60, 'Blackjack', {
|
|
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
this.dealerScoreTxt = this.add.text(CX, DEALER_Y - CARD_H / 2 - 22, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
}
|
|
|
|
// ── Seat labels & portraits ───────────────────────────────────────────────
|
|
buildSeats() {
|
|
for (let seat = 0; seat < SEAT_POS.length; seat++) {
|
|
const pos = SEAT_POS[seat];
|
|
const player = seat === 0
|
|
? { name: 'You', isHuman: true, active: true }
|
|
: { name: this.opponents[seat - 1]?.name ?? '', isHuman: false, active: seat <= this.opponents.length };
|
|
|
|
if (!player.active) continue;
|
|
|
|
// Portrait centre
|
|
const px = pos.portraitX ?? pos.x;
|
|
const py = pos.portraitY ?? (seat === 0 ? pos.y : pos.y - CARD_H / 2 - pos.portraitR - 68);
|
|
|
|
// Name + bankroll stacked, centred under the portrait
|
|
const labelX = px + (pos.labelDX ?? 0);
|
|
const nameY = py + pos.portraitR + 18;
|
|
const chipY = nameY + 24;
|
|
|
|
this.nameTxts[seat] = this.add.text(labelX, nameY, player.name, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
this.chipTxts[seat] = this.add.text(labelX, chipY, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
// 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 bg = this.add.graphics().setDepth(D.ui - 1);
|
|
bg.fillStyle(0x000000, 0.60);
|
|
bg.fillRoundedRect(labelX - rectW / 2, (nameY + chipY) / 2 - rectH / 2, rectW, rectH, 6);
|
|
}
|
|
|
|
this.scoreTxts[seat] = this.add.text(pos.x, pos.y - CARD_H / 2 - 11, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
// Portraits
|
|
if (seat === 0) {
|
|
this.portraits[seat] = createPlayerPortrait(this, px, py, pos.portraitR, D.ui, 'BlackjackGame');
|
|
} else {
|
|
this.portraits[seat] = createOpponentPortrait(this, this.opponents[seat - 1], px, py, pos.portraitR, D.ui);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Betting UI (human only) ───────────────────────────────────────────────
|
|
buildBettingUI() {
|
|
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;
|
|
const container = this.add.container(bx, y).setDepth(D.ui + 1);
|
|
const g = this.add.graphics();
|
|
g.lineStyle(3, 0xffffff, 0.4);
|
|
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: '"Julius Sans One"', fontSize: '13px',
|
|
color: CHIP_TEXT_COLORS[amt], fontStyle: 'bold',
|
|
}).setOrigin(0.5).setDepth(D.ui + 2);
|
|
|
|
this.bettingUIGroup.push(g, t);
|
|
this.chipBtnGraphics.push(g);
|
|
});
|
|
|
|
this.betDisplayText = this.add.text(cx + 170, y, 'Bet: $0', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
|
}).setOrigin(0, 0.5).setDepth(D.ui + 1);
|
|
|
|
this.balanceText = this.add.text(cx + 170, y - 32, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
|
}).setOrigin(0, 0.5).setDepth(D.ui + 1);
|
|
|
|
const clearBtn = new Button(this, cx + 360, y, 'Clear', () => this.onClearBet(), {
|
|
width: 100, height: 50, fontSize: 18, variant: 'ghost',
|
|
});
|
|
this.dealBtn = new Button(this, cx + 470, y, 'Deal', () => this.onDealClick(), {
|
|
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();
|
|
}
|
|
|
|
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: '"Julius Sans One"',
|
|
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() {
|
|
try {
|
|
const { profile } = await api.get('/profile');
|
|
this._playerChips = profile.chips ?? 2000;
|
|
} catch {
|
|
this._playerChips = 2000;
|
|
}
|
|
}
|
|
|
|
// ── Game init ─────────────────────────────────────────────────────────────
|
|
initGame() {
|
|
this.gs = createInitialState(this.opponents, this._playerChips);
|
|
this.startNewRound();
|
|
}
|
|
|
|
startNewRound() {
|
|
if (this.shoe.length < 52) this.shoe = buildShoe();
|
|
playSound(this, SFX.CARD_SHUFFLE);
|
|
this.pendingBet = 0;
|
|
this.bustedSeats = new Set();
|
|
this.gs = prepareRound(this.gs);
|
|
this.clearCardGraphics();
|
|
this.clearActiveHighlight();
|
|
this.renderAll();
|
|
this.showBettingUI();
|
|
this.updateBetDisplay();
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
renderAll() {
|
|
this.renderDealer();
|
|
for (let seat = 0; seat < SEAT_POS.length; seat++) {
|
|
const p = this.gs.players[seat];
|
|
if (!p.active) continue;
|
|
this.renderSeatCards(seat);
|
|
this.renderSeatInfo(seat);
|
|
}
|
|
this.renderBetAreas();
|
|
}
|
|
|
|
renderDealer() {
|
|
// Destroy old dealer cards
|
|
for (const c of this.dealerCardGraphics) c.destroy();
|
|
this.dealerCardGraphics = [];
|
|
|
|
const hand = this.gs.dealer.hand;
|
|
if (hand.length === 0) {
|
|
this.dealerScoreTxt.setText('');
|
|
return;
|
|
}
|
|
|
|
hand.forEach((card, i) => {
|
|
const x = this.cardX(DEALER_X, i, hand.length);
|
|
const cont = this.add.container(x, DEALER_Y).setDepth(D.cards);
|
|
const faceUp = i === 0 || this.gs.dealer.revealed;
|
|
this.drawCard(cont, faceUp ? card : null, faceUp);
|
|
this.dealerCardGraphics.push(cont);
|
|
});
|
|
|
|
const revealed = this.gs.dealer.revealed;
|
|
const { score, soft } = handValue(revealed ? hand : [hand[0]]);
|
|
const bust = revealed && score > 21;
|
|
this.dealerScoreTxt.setText(
|
|
bust ? 'BUST' : `${soft && score < 21 ? 'Soft ' : ''}${score}`
|
|
);
|
|
this.dealerScoreTxt.setColor(bust ? COLORS.dangerHex : COLORS.textHex);
|
|
}
|
|
|
|
renderSeatCards(seat) {
|
|
if (this.cardGraphics[seat]) {
|
|
for (const c of this.cardGraphics[seat]) c.destroy();
|
|
}
|
|
this.cardGraphics[seat] = [];
|
|
|
|
const p = this.gs.players[seat];
|
|
const pos = SEAT_POS[seat];
|
|
|
|
// Main hand
|
|
const h1Offset = p.hand2 ? -80 : 0;
|
|
p.hand.forEach((card, i) => {
|
|
const x = this.cardX(pos.x + h1Offset, i, p.hand.length);
|
|
const cont = this.add.container(x, pos.y).setDepth(D.cards);
|
|
this.drawCard(cont, card, true);
|
|
this.cardGraphics[seat].push(cont);
|
|
});
|
|
|
|
// Split hand
|
|
if (p.hand2) {
|
|
p.hand2.forEach((card, i) => {
|
|
const x = this.cardX(pos.x + 80, i, p.hand2.length);
|
|
const cont = this.add.container(x, pos.y).setDepth(D.cards);
|
|
this.drawCard(cont, card, true);
|
|
this.cardGraphics[seat].push(cont);
|
|
});
|
|
}
|
|
|
|
// Score
|
|
const { score, soft } = handValue(p.hand);
|
|
const bust = score > 21;
|
|
const bj = isBlackjack(p.hand);
|
|
let scoreStr = bj ? 'BJ' : (bust ? 'BUST' : `${soft && score < 21 ? 'soft ' : ''}${score}`);
|
|
if (p.hand2) {
|
|
const { score: s2, soft: sf2 } = handValue(p.hand2);
|
|
const b2 = s2 > 21;
|
|
const bj2 = isBlackjack(p.hand2);
|
|
scoreStr += ` / ${bj2 ? 'BJ' : (b2 ? 'BUST' : `${sf2 && s2 < 21 ? 'soft ' : ''}${s2}`)}`;
|
|
}
|
|
if (this.scoreTxts[seat]) this.scoreTxts[seat].setText(p.hand.length ? scoreStr : '');
|
|
|
|
// Keep busted cards faded across re-renders
|
|
if (this.bustedSeats?.has(seat)) {
|
|
for (const c of this.cardGraphics[seat]) c.setAlpha(0.3);
|
|
}
|
|
}
|
|
|
|
renderSeatInfo(seat) {
|
|
const p = this.gs.players[seat];
|
|
if (this.chipTxts[seat]) this.chipTxts[seat].setText(`$${p.chips.toLocaleString()}`);
|
|
// Highlight current player
|
|
if (this.nameTxts[seat]) {
|
|
this.nameTxts[seat].setColor(p.seat === this.gs.currentSeat && this.gs.phase === 'player_turn'
|
|
? COLORS.accentHex : COLORS.textHex);
|
|
}
|
|
}
|
|
|
|
renderBetAreas() {
|
|
for (const g of Object.values(this.betGraphics)) g.destroy();
|
|
this.betGraphics = {};
|
|
for (let seat = 0; seat < SEAT_POS.length; seat++) {
|
|
const p = this.gs.players[seat];
|
|
if (!p.active || p.bet === 0) continue;
|
|
const { x: betX, y: betY } = this.betCirclePos(seat);
|
|
const cont = this.add.container(betX, betY).setDepth(D.chips);
|
|
const circle = this.add.graphics();
|
|
circle.fillStyle(0x2a2a2a, 1);
|
|
circle.fillCircle(0, 0, 28);
|
|
circle.lineStyle(2, 0xf0e8d0, 0.8);
|
|
circle.strokeCircle(0, 0, 28);
|
|
const txt = this.add.text(0, 0, `$${p.bet}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#f0e8d0',
|
|
}).setOrigin(0.5);
|
|
cont.add([circle, txt]);
|
|
this.betGraphics[seat] = cont;
|
|
}
|
|
}
|
|
|
|
clearCardGraphics() {
|
|
for (const cards of Object.values(this.cardGraphics)) {
|
|
for (const c of cards) c.destroy();
|
|
}
|
|
this.cardGraphics = {};
|
|
for (const c of this.dealerCardGraphics) c.destroy();
|
|
this.dealerCardGraphics = [];
|
|
for (const g of Object.values(this.betGraphics)) g.destroy();
|
|
this.betGraphics = {};
|
|
}
|
|
|
|
// Returns the {x,y} of a seat's bet chip circle, placed just inboard of the
|
|
// card spot (toward the table centre at CX, 540).
|
|
betCirclePos(seat) {
|
|
const pos = SEAT_POS[seat];
|
|
if (pos.betX !== undefined && pos.betY !== undefined) {
|
|
return { x: pos.betX, y: pos.betY };
|
|
}
|
|
const t = 0.22;
|
|
return {
|
|
x: Math.round(pos.x + t * (CX - pos.x)),
|
|
y: Math.round(pos.y + t * (540 - pos.y)),
|
|
};
|
|
}
|
|
|
|
// ── Card drawing ──────────────────────────────────────────────────────────
|
|
addCardBackToContainer(container) {
|
|
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
|
|
container.add(
|
|
this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
|
|
.setDisplaySize(CARD_W, CARD_H)
|
|
.setOrigin(0.5)
|
|
);
|
|
} else {
|
|
const g = this.add.graphics();
|
|
this.drawCardBack(g, -CARD_W / 2, -CARD_H / 2);
|
|
container.add(g);
|
|
}
|
|
}
|
|
|
|
cardX(centerX, idx, total) {
|
|
const totalW = total * CARD_SPREAD + (CARD_W - CARD_SPREAD);
|
|
return centerX - totalW / 2 + CARD_W / 2 + idx * CARD_SPREAD;
|
|
}
|
|
|
|
drawCard(container, card, faceUp) {
|
|
container.removeAll(true);
|
|
const x = -CARD_W / 2, y = -CARD_H / 2;
|
|
if (faceUp && card) {
|
|
const g = this.add.graphics();
|
|
g.fillStyle(0xffffff, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(1, 0xcccccc, 1);
|
|
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
container.add(g);
|
|
const color = card.isRed ? '#c0392b' : '#1a1a2e';
|
|
const style = (sz, bold = false) => ({
|
|
fontFamily: '"Julius Sans One"', 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(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 - 22, card.suitSymbol, style(13)).setOrigin(1, 1));
|
|
} else {
|
|
this.addCardBackToContainer(container);
|
|
}
|
|
}
|
|
|
|
drawCardBack(g, x, y) {
|
|
const color = this.cardBack?.fallbackColor
|
|
? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b;
|
|
g.fillStyle(color, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(2, 0xffffff, 0.25);
|
|
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
|
|
g.lineStyle(1, 0xffffff, 0.1);
|
|
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
|
|
}
|
|
|
|
// ── Betting phase ─────────────────────────────────────────────────────────
|
|
onChipClick(amount) {
|
|
if (this.animating) return;
|
|
const human = this.gs.players[0];
|
|
if (this.pendingBet + amount > 100) return;
|
|
if (this.pendingBet + amount > human.chips) return;
|
|
this.pendingBet += amount;
|
|
this.updateBetDisplay();
|
|
this.hideBettingPrompts();
|
|
}
|
|
|
|
onClearBet() {
|
|
this.pendingBet = 0;
|
|
this.updateBetDisplay();
|
|
this.startBettingPromptsIfNoBet();
|
|
}
|
|
|
|
updateBetDisplay() {
|
|
if (this.betDisplayText) this.betDisplayText.setText(`Bet: $${this.pendingBet}`);
|
|
if (this.dealBtn) this.dealBtn.setEnabled(this.pendingBet >= 5);
|
|
const human = this.gs?.players[0];
|
|
if (this.balanceText && human) this.balanceText.setText(`Balance: $${human.chips.toLocaleString()}`);
|
|
}
|
|
|
|
onDealClick() {
|
|
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);
|
|
|
|
// AI bets
|
|
for (let seat = 1; seat < SEAT_POS.length; seat++) {
|
|
const p = this.gs.players[seat];
|
|
if (!p.active) continue;
|
|
const bet = chooseBet(p);
|
|
this.gs = applyBet(this.gs, seat, Math.max(5, bet));
|
|
}
|
|
|
|
// Deal all cards
|
|
this.gs = dealAllCards(this.gs, this.shoe);
|
|
|
|
// Animate bets then deal
|
|
this.animateBets(() => {
|
|
this.animateDeal(() => {
|
|
this.animating = false;
|
|
this.renderAll();
|
|
this.checkInsuranceOrStartPlay();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Animations ────────────────────────────────────────────────────────────
|
|
animateBets(onComplete) {
|
|
this.renderBetAreas();
|
|
playChipBet(this);
|
|
let done = 0;
|
|
const active = this.gs.players.filter(p => p.active);
|
|
if (active.length === 0) { onComplete(); return; }
|
|
for (const p of active) {
|
|
const pos = SEAT_POS[p.seat];
|
|
const { x: betX, y: betY } = this.betCirclePos(p.seat);
|
|
const chip = this.add.graphics().setDepth(D.chips + 5);
|
|
chip.fillStyle(CHIP_COLORS[25], 1);
|
|
chip.fillCircle(0, 0, 16);
|
|
chip.x = pos.x; chip.y = pos.y;
|
|
this.tweens.add({
|
|
targets: chip, x: betX, y: betY, duration: 280, ease: 'Power2',
|
|
onComplete: () => {
|
|
chip.destroy();
|
|
done++;
|
|
if (done >= active.length) onComplete();
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
animateDeal(onComplete) {
|
|
// 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 seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 0 });
|
|
dealSeq.push({ type: 'dealer', cardIdx: 0 }); // upcard
|
|
for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: 1 });
|
|
dealSeq.push({ type: 'dealer', cardIdx: 1 }); // hole card
|
|
|
|
// Hide all cards initially
|
|
for (const cards of Object.values(this.cardGraphics)) {
|
|
for (const c of cards) c.setAlpha(0);
|
|
}
|
|
for (const c of this.dealerCardGraphics) c.setAlpha(0);
|
|
|
|
let idx = 0;
|
|
const DECK_X = CX + 200, DECK_Y = 50;
|
|
const stagger = 90;
|
|
|
|
const dealNext = () => {
|
|
if (idx >= dealSeq.length) {
|
|
onComplete();
|
|
return;
|
|
}
|
|
const entry = dealSeq[idx++];
|
|
// Flying card-back
|
|
const flying = this.add.container(DECK_X, DECK_Y).setDepth(D.cards + 10);
|
|
this.addCardBackToContainer(flying);
|
|
|
|
let tx, ty;
|
|
if (entry.type === 'dealer') {
|
|
const total = this.gs.dealer.hand.length;
|
|
tx = this.cardX(DEALER_X, entry.cardIdx, total);
|
|
ty = DEALER_Y;
|
|
} else {
|
|
const pos = SEAT_POS[entry.seat];
|
|
const p = this.gs.players[entry.seat];
|
|
const h1Offset = p.hand2 ? -80 : 0;
|
|
const total = p.hand.length;
|
|
tx = this.cardX(pos.x + h1Offset, entry.cardIdx, total);
|
|
ty = pos.y;
|
|
}
|
|
|
|
this.tweens.add({
|
|
targets: flying, x: tx, y: ty, duration: 180, ease: 'Power2',
|
|
onComplete: () => {
|
|
flying.destroy();
|
|
playSound(this, SFX.CARD_DEAL);
|
|
// Reveal the card at destination
|
|
if (entry.type === 'dealer') {
|
|
const cont = this.dealerCardGraphics[entry.cardIdx];
|
|
if (cont) cont.setAlpha(1);
|
|
} else {
|
|
const cardConts = this.cardGraphics[entry.seat] ?? [];
|
|
if (cardConts[entry.cardIdx]) cardConts[entry.cardIdx].setAlpha(1);
|
|
}
|
|
this.time.delayedCall(stagger, dealNext);
|
|
},
|
|
});
|
|
};
|
|
dealNext();
|
|
}
|
|
|
|
animateDealerReveal(onComplete) {
|
|
const holeCard = this.dealerCardGraphics[1];
|
|
if (!holeCard) { this.gs.dealer.revealed = true; this.renderAll(); onComplete(); return; }
|
|
playSound(this, SFX.CARD_SHOW);
|
|
|
|
// Flip: scale X 1→0, redraw face-up, scale X 0→1
|
|
this.tweens.add({
|
|
targets: holeCard, scaleX: 0, duration: 120, ease: 'Linear',
|
|
onComplete: () => {
|
|
this.gs = { ...this.gs, dealer: { ...this.gs.dealer, revealed: true } };
|
|
this.drawCard(holeCard, this.gs.dealer.hand[1], true);
|
|
this.tweens.add({
|
|
targets: holeCard, scaleX: 1, duration: 120, ease: 'Linear',
|
|
onComplete: () => {
|
|
this.renderDealer();
|
|
onComplete();
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
animateSingleCard(seat, card, onComplete) {
|
|
const pos = SEAT_POS[seat];
|
|
const p = this.gs.players[seat];
|
|
const isH2 = p.activeHand === 1;
|
|
const hand = isH2 ? p.hand2 : p.hand;
|
|
const total = hand.length;
|
|
const cardIdx = total - 1;
|
|
const tx = this.cardX(pos.x, cardIdx, total);
|
|
const ty = pos.y;
|
|
|
|
// 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({
|
|
targets: flying, x: tx, y: ty, duration: 200, ease: 'Power2',
|
|
onComplete: () => {
|
|
flying.destroy();
|
|
playSound(this, SFX.CARD_PLACE);
|
|
// Reveal the card face-up at the destination
|
|
const cardConts = this.cardGraphics[seat] ?? [];
|
|
const targetCont = cardConts[cardIdx];
|
|
if (targetCont) {
|
|
targetCont.setAlpha(1);
|
|
this.drawCard(targetCont, card, true);
|
|
}
|
|
onComplete();
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
// Rendered as a DOM element with a very high depth so it sits above
|
|
// everything, including the opponent portrait videos (which are DOM and
|
|
// therefore always drawn above the Phaser canvas).
|
|
const el = document.createElement('div');
|
|
el.textContent = label;
|
|
el.style.cssText = [
|
|
'font-family:"Julius Sans One",sans-serif',
|
|
'font-size:44px',
|
|
'font-weight:bold',
|
|
`color:${ACTION_COLORS[action] ?? '#ffffff'}`,
|
|
'text-shadow:1px 1px 0 #000,-1px 1px 0 #000,1px -1px 0 #000,-1px -1px 0 #000,2px 0 0 #000,-2px 0 0 #000,0 2px 0 #000,0 -2px 0 #000,0 0 6px rgba(0,0,0,0.85)',
|
|
'white-space:nowrap',
|
|
'pointer-events:none',
|
|
'user-select:none',
|
|
].join(';');
|
|
|
|
const dom = this.add.dom(tx, ty, el).setDepth(D.modal + 100).setAlpha(0).setScale(1.4);
|
|
|
|
this.tweens.add({
|
|
targets: dom, alpha: 1, scaleX: 1, scaleY: 1,
|
|
duration: 180, ease: 'Back.Out',
|
|
});
|
|
this.time.delayedCall(1000, () => {
|
|
this.tweens.add({
|
|
targets: dom, alpha: 0, y: ty - 26,
|
|
duration: 350, ease: 'Power2',
|
|
onComplete: () => dom.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];
|
|
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';
|
|
|
|
if (result === 'blackjack') playSound(this, SFX.CASINO_BLACKJACK);
|
|
else if (result === 'win') playSound(this, SFX.CASINO_WIN);
|
|
else if (result === 'lose' || result === 'bust') playSound(this, SFX.CASINO_LOSE);
|
|
|
|
if (seat > 0 && this.portraits[seat]) {
|
|
this.portraits[seat].playEmotion?.(isWin ? 'happy' : 'upset');
|
|
}
|
|
|
|
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 linger = isWin ? 1500 : isLose ? 1100 : 700;
|
|
|
|
const textY = pos.y - CARD_H / 2 - 60;
|
|
const badge = this.add.text(pos.x, textY, LABELS[result] ?? result, {
|
|
fontFamily: '"Julius Sans One"',
|
|
fontSize: SIZES[result] ?? '48px',
|
|
color: COLORS[result] ?? '#ffffff',
|
|
fontStyle: 'bold',
|
|
stroke: '#000000', strokeThickness: 5,
|
|
shadow: isWin
|
|
? { offsetX: 0, offsetY: 0, color: COLORS[result], blur: 24, fill: true }
|
|
: undefined,
|
|
}).setOrigin(0.5).setAlpha(0).setScale(1.4).setDepth(D.modal);
|
|
|
|
// Pop-in
|
|
this.tweens.add({
|
|
targets: badge, alpha: 1, scaleX: 1, scaleY: 1,
|
|
duration: 200, ease: 'Back.Out',
|
|
});
|
|
|
|
// Linger then fade up
|
|
this.time.delayedCall(linger, () => {
|
|
this.tweens.add({
|
|
targets: badge, alpha: 0, y: textY - 30,
|
|
duration: 380, ease: 'Power2',
|
|
onComplete: () => { badge.destroy(); onComplete(); },
|
|
});
|
|
});
|
|
|
|
// Chip movement (runs in parallel with text)
|
|
if (isWin) {
|
|
this.animateChipsFromDealer(seat, p.chipsWon ?? p.bet);
|
|
this.animateFireworks(pos.x, textY);
|
|
} else if (isLose) {
|
|
this.animateChipsToDealer(seat);
|
|
} else {
|
|
// Push — bet returns to player
|
|
this.animateChipReturn(seat);
|
|
}
|
|
}
|
|
|
|
animateChipsToDealer(seat) {
|
|
const { x: betX, y: betY } = this.betCirclePos(seat);
|
|
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.x = betX + (Math.random() - 0.5) * 22;
|
|
chip.y = betY;
|
|
this.tweens.add({
|
|
targets: chip, x: DEALER_X, y: DEALER_Y,
|
|
duration: 440, delay: i * 55, ease: 'Power2',
|
|
onComplete: () => chip.destroy(),
|
|
});
|
|
}
|
|
}
|
|
|
|
animateChipsFromDealer(seat, amount) {
|
|
if (!amount || amount <= 0) return;
|
|
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.x = DEALER_X + (Math.random() - 0.5) * 50;
|
|
chip.y = DEALER_Y;
|
|
this.tweens.add({
|
|
targets: chip,
|
|
x: pos.x + (Math.random() - 0.5) * 28,
|
|
y: pos.y,
|
|
duration: 460, delay: i * 55, ease: 'Power2',
|
|
onComplete: () => chip.destroy(),
|
|
});
|
|
}
|
|
}
|
|
|
|
animateChipReturn(seat) {
|
|
const pos = SEAT_POS[seat];
|
|
const { x: betX, y: betY } = this.betCirclePos(seat);
|
|
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.x = betX + (Math.random() - 0.5) * 20;
|
|
chip.y = betY;
|
|
this.tweens.add({
|
|
targets: chip,
|
|
x: pos.x + (Math.random() - 0.5) * 20,
|
|
y: pos.y,
|
|
duration: 380, delay: i * 50, ease: 'Power2',
|
|
onComplete: () => chip.destroy(),
|
|
});
|
|
}
|
|
}
|
|
|
|
animateFireworks(cx, cy) {
|
|
const palette = [0xf5d020, 0xff8c00, 0x5cb85c, 0x4a90d9, 0xff69b4, 0xffffff, 0x00e5ff];
|
|
for (let burst = 0; burst < 3; burst++) {
|
|
this.time.delayedCall(burst * 380, () => {
|
|
if (!this.scene.isActive('BlackjackGame')) return;
|
|
const bx = cx + (Math.random() - 0.5) * 140;
|
|
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 color = palette[Math.floor(Math.random() * palette.length)];
|
|
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;
|
|
this.tweens.add({
|
|
targets: dot,
|
|
x: bx + Math.cos(angle) * dist,
|
|
y: by + Math.sin(angle) * dist,
|
|
alpha: 0, scaleX: 0.1, scaleY: 0.1,
|
|
duration: 700 + Math.random() * 400,
|
|
delay: Math.random() * 100,
|
|
ease: 'Power2',
|
|
onComplete: () => dot.destroy(),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Insurance ─────────────────────────────────────────────────────────────
|
|
checkInsuranceOrStartPlay() {
|
|
if (this.gs.phase === 'insurance') {
|
|
this.offerInsurance();
|
|
} else {
|
|
this.checkImmediateDealerBJ();
|
|
}
|
|
}
|
|
|
|
offerInsurance() {
|
|
// AI declines immediately
|
|
for (let seat = 1; seat < SEAT_POS.length; seat++) {
|
|
const p = this.gs.players[seat];
|
|
if (p.active) this.gs = applyInsurance(this.gs, seat, false);
|
|
}
|
|
|
|
// Offer human
|
|
const modal = this.add.container(CX, GAME_HEIGHT / 2).setDepth(D.modal);
|
|
const bg = this.add.rectangle(0, 0, 560, 200, COLORS.panel).setStrokeStyle(2, COLORS.accent);
|
|
const txt = this.add.text(0, -55, 'Dealer shows Ace\nTake insurance? (pays 2:1)', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
|
}).setOrigin(0.5);
|
|
modal.add([bg, txt]);
|
|
|
|
const yesBtn = new Button(this, CX - 120, GAME_HEIGHT / 2 + 30, 'Yes', () => {
|
|
modal.destroy();
|
|
yesBtn.destroy(); noBtn.destroy();
|
|
this.gs = applyInsurance(this.gs, 0, true);
|
|
this.checkImmediateDealerBJ();
|
|
}, { width: 120, height: 52, fontSize: 20 });
|
|
yesBtn.setDepth(D.modal + 1);
|
|
|
|
const noBtn = new Button(this, CX + 120, GAME_HEIGHT / 2 + 30, 'No', () => {
|
|
modal.destroy();
|
|
yesBtn.destroy(); noBtn.destroy();
|
|
this.gs = applyInsurance(this.gs, 0, false);
|
|
this.checkImmediateDealerBJ();
|
|
}, { width: 120, height: 52, fontSize: 20, variant: 'ghost' });
|
|
noBtn.setDepth(D.modal + 1);
|
|
}
|
|
|
|
checkImmediateDealerBJ() {
|
|
if (isBlackjack(this.gs.dealer.hand)) {
|
|
// Dealer blackjack — resolve insurance then show result
|
|
this.gs = resolveInsurance(this.gs);
|
|
this.animateDealerReveal(() => {
|
|
this.gs = resolveRound(this.gs);
|
|
this.renderAll();
|
|
this.showAllResults(() => this.showNextRoundPrompt());
|
|
});
|
|
} else {
|
|
this.gs = resolveInsurance(this.gs);
|
|
this.startPlayerTurns();
|
|
}
|
|
}
|
|
|
|
// ── 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' };
|
|
const seat = this.firstPlaySeat();
|
|
this.gs = { ...this.gs, currentSeat: seat };
|
|
this.advanceToPlayer(seat);
|
|
}
|
|
|
|
advanceToPlayer(seat) {
|
|
this.renderAll();
|
|
const p = this.gs.players[seat];
|
|
if (!p || !p.active) { this.startDealerTurn(); return; }
|
|
|
|
// Skip if already determined (blackjack on deal)
|
|
if (isPlayerDone(p)) {
|
|
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 {
|
|
this.time.delayedCall(900, () => this.runAITurn(seat));
|
|
}
|
|
}
|
|
|
|
showActionButtons() {
|
|
this.hideActionButtons();
|
|
const seat = this.gs.currentSeat;
|
|
const p = this.gs.players[seat];
|
|
const pos = SEAT_POS[seat];
|
|
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, 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)) {
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
hideActionButtons() {
|
|
for (const b of this.actionBtns) b.destroy?.();
|
|
this.actionBtns = [];
|
|
}
|
|
|
|
onPlayerBust(seat) {
|
|
this.bustedSeats.add(seat);
|
|
// Fade current card graphics (new renders will stay faded via bustedSeats)
|
|
for (const c of this.cardGraphics[seat] ?? []) {
|
|
this.tweens.add({ targets: c, alpha: 0.3, duration: 280, ease: 'Power2' });
|
|
}
|
|
// Chips fly to dealer immediately
|
|
this.animateChipsToDealer(seat);
|
|
// Opponent portrait: play upset once, Portrait.js auto-returns to idle on ended
|
|
if (seat > 0 && this.portraits[seat]) {
|
|
this.portraits[seat].playEmotion?.('upset');
|
|
}
|
|
}
|
|
|
|
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];
|
|
const newCard = p.activeHand === 1
|
|
? p.hand2[p.hand2.length - 1]
|
|
: p.hand[p.hand.length - 1];
|
|
|
|
this.animateSingleCard(this.gs.currentSeat, newCard, () => {
|
|
this.renderSeatCards(this.gs.currentSeat);
|
|
this.time.delayedCall(200, () => {
|
|
this.animating = false;
|
|
if (isBust(p.activeHand === 1 ? p.hand2 : p.hand)) {
|
|
this.onPlayerBust(this.gs.currentSeat);
|
|
this.time.delayedCall(380, () => this.playerDone(this.gs.currentSeat));
|
|
} else {
|
|
this.showActionButtons();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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
|
|
if (p.activeHand === 1 && p.status2 === null) {
|
|
this.showActionButtons();
|
|
return;
|
|
}
|
|
this.playerDone(seat);
|
|
}
|
|
|
|
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);
|
|
this.renderBetAreas();
|
|
this.time.delayedCall(300, () => {
|
|
this.animating = false;
|
|
const p = this.gs.players[this.gs.currentSeat];
|
|
if (p.status === 'bust' || p.status2 === 'bust') {
|
|
this.onPlayerBust(this.gs.currentSeat);
|
|
this.time.delayedCall(380, () => this.playerDone(this.gs.currentSeat));
|
|
} else {
|
|
this.playerDone(this.gs.currentSeat);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
this.renderBetAreas();
|
|
this.time.delayedCall(400, () => {
|
|
this.animating = false;
|
|
const p = this.gs.players[this.gs.currentSeat];
|
|
// If ace split, both hands are immediately standing
|
|
if (isPlayerDone(p)) {
|
|
this.playerDone(this.gs.currentSeat);
|
|
} else {
|
|
this.showActionButtons();
|
|
}
|
|
});
|
|
}
|
|
|
|
playerDone(seat) {
|
|
const p = this.gs.players[seat];
|
|
// If split: check if hand 2 needs to be played
|
|
if (p.hand2 !== null && p.activeHand === 0 && !isPlayerDone(p)) {
|
|
this.gs = applyStand(this.gs, seat); // moves to hand 2
|
|
this.showActionButtons();
|
|
return;
|
|
}
|
|
const next = this.nextPlaySeat();
|
|
if (next === null) {
|
|
this.startDealerTurn();
|
|
} else {
|
|
this.gs = { ...this.gs, currentSeat: next };
|
|
this.advanceToPlayer(next);
|
|
}
|
|
}
|
|
|
|
// ── AI turn ───────────────────────────────────────────────────────────────
|
|
runAITurn(seat) {
|
|
const p = this.gs.players[seat];
|
|
if (!p || !p.active || isPlayerDone(p)) {
|
|
this.playerDone(seat);
|
|
return;
|
|
}
|
|
|
|
const action = chooseAction(p, this.gs.dealer.hand[0]);
|
|
this.animateActionText(seat, action);
|
|
this.animating = true;
|
|
|
|
const doAction = () => {
|
|
switch (action) {
|
|
case 'hit':
|
|
this.gs = applyHit(this.gs, seat, this.shoe);
|
|
const pp = this.gs.players[seat];
|
|
const aiNewCard = pp.activeHand === 1
|
|
? pp.hand2[pp.hand2.length - 1]
|
|
: pp.hand[pp.hand.length - 1];
|
|
this.animateSingleCard(seat, aiNewCard, () => {
|
|
this.renderSeatCards(seat);
|
|
this.time.delayedCall(400, () => {
|
|
this.animating = false;
|
|
const pp2 = this.gs.players[seat];
|
|
if (isBust(pp2.activeHand === 1 ? pp2.hand2 : pp2.hand)) {
|
|
this.onPlayerBust(seat);
|
|
this.time.delayedCall(400, () => this.playerDone(seat));
|
|
} else {
|
|
this.time.delayedCall(600, () => this.runAITurn(seat));
|
|
}
|
|
});
|
|
});
|
|
break;
|
|
case 'double':
|
|
this.gs = applyDouble(this.gs, seat, this.shoe);
|
|
this.renderSeatCards(seat);
|
|
this.renderBetAreas();
|
|
this.time.delayedCall(600, () => {
|
|
this.animating = false;
|
|
const pp = this.gs.players[seat];
|
|
if (pp.status === 'bust' || pp.status2 === 'bust') {
|
|
this.onPlayerBust(seat);
|
|
this.time.delayedCall(400, () => this.playerDone(seat));
|
|
} else {
|
|
this.playerDone(seat);
|
|
}
|
|
});
|
|
break;
|
|
case 'split':
|
|
this.gs = applySplit(this.gs, seat, this.shoe);
|
|
this.renderSeatCards(seat);
|
|
this.renderBetAreas();
|
|
this.time.delayedCall(600, () => {
|
|
this.animating = false;
|
|
// Play each hand in turn
|
|
this.time.delayedCall(700, () => this.runAITurn(seat));
|
|
});
|
|
break;
|
|
case 'stand':
|
|
default:
|
|
this.gs = applyStand(this.gs, seat);
|
|
this.time.delayedCall(400, () => {
|
|
this.animating = false;
|
|
// Handle split hand 2
|
|
const pp = this.gs.players[seat];
|
|
if (pp.hand2 !== null && pp.activeHand === 1 && pp.status2 === null) {
|
|
this.time.delayedCall(600, () => this.runAITurn(seat));
|
|
} else {
|
|
this.playerDone(seat);
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
};
|
|
doAction();
|
|
}
|
|
|
|
// ── Dealer turn ───────────────────────────────────────────────────────────
|
|
startDealerTurn() {
|
|
this.clearActiveHighlight();
|
|
this.gs = { ...this.gs, phase: 'dealer_turn', currentSeat: -1 };
|
|
this.renderAll();
|
|
this.time.delayedCall(400, () => {
|
|
this.animateDealerReveal(() => {
|
|
this.runDealerHits();
|
|
});
|
|
});
|
|
}
|
|
|
|
runDealerHits() {
|
|
const { score } = handValue(this.gs.dealer.hand);
|
|
if (score >= 17) {
|
|
this.finishRound();
|
|
return;
|
|
}
|
|
const newCard = this.shoe.pop();
|
|
this.gs = { ...this.gs, dealer: { ...this.gs.dealer, hand: [...this.gs.dealer.hand, newCard] } };
|
|
this.renderDealer();
|
|
this.time.delayedCall(700, () => this.runDealerHits());
|
|
}
|
|
|
|
// ── Resolve ───────────────────────────────────────────────────────────────
|
|
finishRound() {
|
|
this.gs = resolveRound(this.gs);
|
|
this.renderAll();
|
|
this.showAllResults(() => {
|
|
this.updateServerChips();
|
|
this.showNextRoundPrompt();
|
|
});
|
|
}
|
|
|
|
showAllResults(onComplete) {
|
|
// 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; }
|
|
|
|
let pending = seats.length;
|
|
const done = () => { if (--pending <= 0) onComplete(); };
|
|
|
|
seats.forEach((seat, i) => {
|
|
this.time.delayedCall(i * 480, () => this.animateSeatResult(seat, done));
|
|
});
|
|
}
|
|
|
|
async updateServerChips() {
|
|
const human = this.gs.players[0];
|
|
const delta = human.chipsWon ?? 0;
|
|
if (delta === 0) return;
|
|
try {
|
|
await api.post('/profile/chips/adjust', { delta });
|
|
} catch { /* balance will resync next load */ }
|
|
}
|
|
|
|
showNextRoundPrompt() {
|
|
const human = this.gs.players[0];
|
|
this.renderAll();
|
|
|
|
if (human.chips <= 0) {
|
|
new Modal(this, 'Out of chips! Visit your profile to request a reset.', {
|
|
autoCloseMs: 4000,
|
|
});
|
|
this.time.delayedCall(4200, () => this.scene.start('GameMenu'));
|
|
return;
|
|
}
|
|
|
|
const btn = new Button(this, CX, 925, 'Next Round', () => {
|
|
btn.destroy();
|
|
this.startNewRound();
|
|
}, { width: 220, height: 60, fontSize: 24 });
|
|
}
|
|
}
|