fertig-classic-games/src/games/paigow/PaiGowPokerGame.js

1718 lines
66 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 {
HAND_RANK, makeJoker,
evaluate5Card, evaluate2Card, compare5Card, compare2Card,
isFoul, handName5, rankLabel,
createInitialState, prepareRound, applyBet, dealHands,
applyHumanSplit, applyHouseWaySplit, applyDealerHouseWay, resolveRound,
} from './PaiGowPokerLogic.js';
import { chooseBet, houseWay } from './PaiGowPokerAI.js';
// ─── Layout ───────────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CARD_W = 90;
const CARD_H = 126;
const CARD_R = 8;
const CARD_SPREAD_FAN = 22; // overlap in 7-card fan at opponent seats
const CARD_SPREAD_SPLIT = 10; // tighter spread for 5-card split display on main table
const MODAL_CARD_SPREAD = 55; // wider spread so each card is clearly visible in comparison modal
const TABLE_CY = 475; // table centre — slightly higher than Blackjack to make room for panel
const DEALER_X = CX;
const DEALER_Y = 315;
// Seat 0 = human (bottom-centre); seats 1-5 = AI
const SEAT_POS = [
{ x: CX, y: 762, portraitR: 72, portraitX: CX - 230, portraitY: 840, betX: 1110, betY: 750 },
{ x: 1380, y: 690, portraitR: 58, portraitX: 1600, portraitY: 780 },
{ x: 540, y: 690, portraitR: 58, portraitX: 320, portraitY: 780 },
{ x: 1560, y: 560, portraitR: 58, portraitX: 1805, portraitY: 583 },
{ x: 360, y: 560, portraitR: 58, portraitX: 115, portraitY: 583 },
{ x: 1440, y: 395, portraitR: 58, portraitX: 1675, portraitY: 320, labelDX: 10 },
];
// AI turn order (clockwise from top-right)
const PLAY_ORDER = [5, 3, 1, 0, 2, 4];
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
const CHIP_AMOUNTS = [5, 25, 50, 100];
const RESULT_COLORS = { win: '#5cb85c', lose: '#e05c5c', push: '#8a94a6', foul: '#e05c5c' };
const D = { bg: -1, table: 0, chips: 10, cards: 20, ui: 30, panel: 25, modal: 50 };
// ─── Hand-setting panel geometry ──────────────────────────────────────────────
const PANEL_H = 390; // panel height
const PANEL_Y0 = Math.round(540 - PANEL_H / 2); // vertically centred on 1080px canvas
const HIGH_SLOT_CX = 480; // centre-X of 5-card zone
const LOW_SLOT_CX = 1440; // centre-X of 2-card zone
const SLOT_Y = PANEL_Y0 + 95; // centre-Y of drop slots
const SOURCE_Y = PANEL_Y0 + 275; // centre-Y of source cards (180px below slot centre)
const SLOT_GAP = 100; // slot-to-slot horizontal spacing
// ─── Scene ────────────────────────────────────────────────────────────────────
export default class PaiGowPokerGame extends Phaser.Scene {
constructor() { super('PaiGowPokerGame'); }
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.gs = null;
this.animating = false;
this.pendingBet = 0;
this.portraits = [];
this.seatCardGraphics = {}; // seat → [containers] for the 7-card fan
this.dealerCardGraphics = [];
this.betGraphics = {};
this.actionBtns = [];
this.bettingUIGroup = [];
this.chipBtnGraphics = [];
this.betDisplayText = null;
this.balanceText = null;
this.scoreTxts = {};
this.nameTxts = {};
this.chipTxts = {};
// Hand-setting panel state
this.setPanelGroup = []; // all objects in the panel (toggled visible)
this.setCardConts = []; // 7 containers, one per dealt card
this.setHighSlots = []; // 5 slot graphics
this.setLowSlots = []; // 2 slot graphics
this.humanHighSlots = [null, null, null, null, null];
this.humanLowSlots = [null, null];
this.selectedCardIdx = null; // which source-card index is selected
this.highRankTxt = null;
this.lowRankTxt = null;
this.foulWarnTxt = null;
this.setHandBtn = null;
this.houseWayBtn = null;
this.panelVisible = false;
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();
this.buildHandSetPanel();
new Button(this, 80, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 140, fontSize: 20,
});
await this.loadPlayerChips();
this.initGame();
}
// ── Playfield ─────────────────────────────────────────────────────────────
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, TABLE_CY, 1500, 680);
g.lineStyle(8, 0x2e7d32, 1);
g.strokeEllipse(CX, TABLE_CY, 1500, 680);
g.lineStyle(3, 0x4caf50, 0.4);
g.strokeEllipse(CX, TABLE_CY, 1440, 620);
}
buildTableMarkings() {
const g = this.add.graphics().setDepth(D.table + 1);
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const active = seat === 0 ? true : !!(this.opponents[seat - 1] ?? null);
if (!active) continue;
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);
}
this.drawArcText('PAI GOW POKER', CX, 580, 760, {
fontSize: 32, color: COLORS.goldHex, bold: true, advanceFactor: 0.96,
});
this.drawArcText('BANKER WINS ALL TIES · 5% COMMISSION ON WINS', CX, 636, 720, {
fontSize: 19, color: COLORS.mutedHex, advanceFactor: 0.84,
});
}
drawArcText(text, centerX, baseY, radius, opts = {}) {
const fontSize = opts.fontSize ?? 24;
const anglePer = ((opts.advanceFactor ?? 0.92) * fontSize) / radius;
const cy = baseY - radius;
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++) {
if (text[i] === ' ') continue;
const a = start + i * anglePer;
this.add.text(centerX + radius * Math.sin(a), cy + radius * Math.cos(a), text[i], style)
.setOrigin(0.5).setRotation(-a).setDepth(depth);
}
}
// ── Dealer area ───────────────────────────────────────────────────────────
buildDealerArea() {
this.add.text(CX, 55, 'Pai Gow Poker', {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.dealerHighLabel = this.add.text(CX - 170, DEALER_Y - CARD_H / 2 - 24, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.dealerLowLabel = this.add.text(CX + 170, DEALER_Y - CARD_H / 2 - 24, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
}
// ── Seats ─────────────────────────────────────────────────────────────────
buildSeats() {
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const pos = SEAT_POS[seat];
const opp = seat === 0 ? null : this.opponents[seat - 1] ?? null;
const active = seat === 0 ? true : !!opp;
if (!active) continue;
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? (pos.y - CARD_H / 2 - (pos.portraitR ?? 58) - 60);
const labelX = px + (pos.labelDX ?? 0);
const nameY = py + (pos.portraitR ?? 58) + 18;
const chipY = nameY + 24;
this.nameTxts[seat] = this.add.text(labelX, nameY, seat === 0 ? 'You' : opp.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);
{
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: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
if (seat === 0) {
this.portraits[seat] = createPlayerPortrait(this, px, py, pos.portraitR ?? 72, D.ui, 'PaiGowPokerGame');
} else {
this.portraits[seat] = createOpponentPortrait(this, opp, px, py, pos.portraitR ?? 58, D.ui);
}
}
}
// ── Betting UI ────────────────────────────────────────────────────────────
buildBettingUI() {
const y = GAME_HEIGHT - 80;
const cx = CX + 60;
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_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: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(D.ui + 2);
this.bettingUIGroup.push(container, t);
this.chipBtnGraphics.push(container);
});
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);
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);
}
hideBettingUI() {
for (const o of this.bettingUIGroup) o.setVisible?.(false) ?? (o.visible = false);
}
// ── Hand-setting panel ────────────────────────────────────────────────────
buildHandSetPanel() {
const panelBg = this.add.graphics().setDepth(D.panel).setVisible(false);
panelBg.fillStyle(0x000000, 0.90);
panelBg.fillRoundedRect(40, PANEL_Y0, GAME_WIDTH - 80, PANEL_H, 14);
panelBg.lineStyle(2, COLORS.accent, 0.7);
panelBg.strokeRoundedRect(40, PANEL_Y0, GAME_WIDTH - 80, PANEL_H, 14);
this.setPanelGroup.push(panelBg);
// Zone labels
const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex };
const hiLabel = this.add.text(HIGH_SLOT_CX, PANEL_Y0 + 20, 'HIGH HAND (5 cards)', labelStyle)
.setOrigin(0.5).setDepth(D.panel + 1).setVisible(false);
const loLabel = this.add.text(LOW_SLOT_CX, PANEL_Y0 + 20, 'LOW HAND (2 cards)', labelStyle)
.setOrigin(0.5).setDepth(D.panel + 1).setVisible(false);
this.setPanelGroup.push(hiLabel, loLabel);
// Separator line between high and low zones
const sep = this.add.graphics().setDepth(D.panel + 1).setVisible(false);
sep.lineStyle(1, 0xffffff, 0.15);
sep.beginPath();
sep.moveTo(1060, PANEL_Y0 + 10);
sep.lineTo(1060, PANEL_Y0 + PANEL_H - 10);
sep.strokePath();
this.setPanelGroup.push(sep);
// 5 drop-zone slots for high hand
this.setHighSlots = [];
for (let i = 0; i < 5; i++) {
const x = HIGH_SLOT_CX - 2 * SLOT_GAP + i * SLOT_GAP;
const g = this.add.graphics().setDepth(D.panel + 1).setVisible(false);
this.drawEmptySlot(g, x, SLOT_Y);
const zone = this.add.zone(x, SLOT_Y, CARD_W + 10, CARD_H + 10)
.setDepth(D.panel + 2).setDropZone().setVisible(false);
zone._pgSlot = { type: 'high', idx: i };
zone.on('pointerdown', () => this.onHighSlotClick(i));
this.setHighSlots.push({ g, zone, x, y: SLOT_Y });
this.setPanelGroup.push(g, zone);
}
// 2 drop-zone slots for low hand
this.setLowSlots = [];
for (let i = 0; i < 2; i++) {
const x = LOW_SLOT_CX - SLOT_GAP / 2 + i * SLOT_GAP;
const g = this.add.graphics().setDepth(D.panel + 1).setVisible(false);
this.drawEmptySlot(g, x, SLOT_Y);
const zone = this.add.zone(x, SLOT_Y, CARD_W + 10, CARD_H + 10)
.setDepth(D.panel + 2).setDropZone().setVisible(false);
zone._pgSlot = { type: 'low', idx: i };
zone.on('pointerdown', () => this.onLowSlotClick(i));
this.setLowSlots.push({ g, zone, x, y: SLOT_Y });
this.setPanelGroup.push(g, zone);
}
// Phaser drop-zone events — handle the actual card placement on drop
this.input.on('drop', (_ptr, gameObj, dropZone) => {
if (!this.panelVisible || !dropZone._pgSlot) return;
const cardIdx = this.setCardConts.indexOf(gameObj);
if (cardIdx === -1) return;
const { type, idx } = dropZone._pgSlot;
if (type === 'high') this._dropToHighSlot(cardIdx, idx);
else this._dropToLowSlot(cardIdx, idx);
});
this.input.on('dragenter', (_ptr, _obj, zone) => {
if (!this.panelVisible || !zone._pgSlot) return;
const { type, idx } = zone._pgSlot;
const slot = type === 'high' ? this.setHighSlots[idx] : this.setLowSlots[idx];
this._drawHighlightSlot(slot.g, slot.x, SLOT_Y);
});
this.input.on('dragleave', (_ptr, _obj, zone) => {
if (!this.panelVisible || !zone._pgSlot) return;
const { type, idx } = zone._pgSlot;
const slot = type === 'high' ? this.setHighSlots[idx] : this.setLowSlots[idx];
this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
});
// Live rank labels below the slots
this.highRankTxt = this.add.text(HIGH_SLOT_CX, SLOT_Y + CARD_H / 2 + 14, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false);
this.lowRankTxt = this.add.text(LOW_SLOT_CX, SLOT_Y + CARD_H / 2 + 14, '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false);
// Foul warning
this.foulWarnTxt = this.add.text(CX, SLOT_Y + CARD_H / 2 + 14, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#e06c75',
}).setOrigin(0.5).setDepth(D.panel + 1).setVisible(false);
this.setPanelGroup.push(this.highRankTxt, this.lowRankTxt, this.foulWarnTxt);
// Buttons
this.houseWayBtn = new Button(this, HIGH_SLOT_CX - 100, PANEL_Y0 + PANEL_H - 28, 'House Way', () => this.applyHouseWayUI(), {
width: 150, height: 44, fontSize: 16,
});
this.clearSetBtn = new Button(this, HIGH_SLOT_CX + 75, PANEL_Y0 + PANEL_H - 28, 'Clear', () => this.clearHandSet(), {
width: 110, height: 44, fontSize: 16, variant: 'ghost',
});
this.setHandBtn = new Button(this, CX + 20, PANEL_Y0 + PANEL_H - 28, 'Set Hand', () => this.onSetHand(), {
width: 160, height: 44, fontSize: 18,
});
this.setHandBtn.setEnabled(false);
this.houseWayBtn.setDepth(D.panel + 2);
this.clearSetBtn.setDepth(D.panel + 2);
this.setHandBtn.setDepth(D.panel + 2);
this.setPanelGroup.push(this.houseWayBtn, this.clearSetBtn, this.setHandBtn);
this.hideHandSetPanel();
}
drawEmptySlot(g, cx, cy) {
g.clear();
g.lineStyle(2, 0xffffff, 0.25);
g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R);
}
drawSelectedSlot(g, cx, cy) {
g.clear();
g.lineStyle(3, 0xf5d020, 0.9);
g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R);
}
showHandSetPanel() {
this.panelVisible = true;
this.animating = true;
for (const o of this.setPanelGroup) o.setVisible(true);
for (const p of this.portraits) p?.hide?.();
for (const t of Object.values(this.nameTxts)) t.setVisible(false);
for (const t of Object.values(this.chipTxts)) t.setVisible(false);
// Grab the face-up seat cards that were just dealt
const seatCards = [...(this.seatCardGraphics[0] ?? [])];
const onAllArrived = () => {
// Swap animated seat cards for interactive panel cards
for (const c of seatCards) c.destroy();
this.seatCardGraphics[0] = [];
this.buildSetCardContainers();
this.updateHandSetUI();
this.animating = false;
};
if (!seatCards.length) { onAllArrived(); return; }
let done = 0;
seatCards.forEach((cont, i) => {
cont.setDepth(D.panel + 10);
this.tweens.add({
targets: cont,
x: this.sourceCardX(i),
y: SOURCE_Y,
duration: 380,
delay: i * 65,
ease: 'Back.easeOut',
onComplete: () => { if (++done === seatCards.length) onAllArrived(); },
});
});
}
hideHandSetPanel() {
this.panelVisible = false;
this._clearDragHighlights();
if (this._selRings) { for (const r of this._selRings) r.destroy(); this._selRings = []; }
for (const o of this.setPanelGroup) o.setVisible(false);
for (const c of this.setCardConts) c.destroy();
this.setCardConts = [];
// Restore portraits and seat labels
for (const p of this.portraits) p?.show?.();
for (const t of Object.values(this.nameTxts)) t.setVisible(true);
for (const t of Object.values(this.chipTxts)) t.setVisible(true);
}
buildSetCardContainers() {
for (const c of this.setCardConts) c.destroy();
this.setCardConts = [];
const hand = this.gs.players[0].hand;
hand.forEach((card, i) => {
const sx = this.sourceCardX(i);
const cont = this.add.container(sx, SOURCE_Y).setDepth(D.panel + 3);
this.drawCard(cont, card, true);
cont.setSize(CARD_W, CARD_H);
cont.setInteractive({ useHandCursor: true });
this.input.setDraggable(cont);
let _dragged = false;
cont.on('dragstart', () => {
_dragged = true;
this.selectedCardIdx = null;
if (this._selRings) { for (const r of this._selRings) r.destroy(); this._selRings = []; }
cont.setDepth(D.panel + 10);
});
cont.on('drag', (_ptr, dragX, dragY) => {
cont.setPosition(dragX, dragY);
});
cont.on('dragend', () => {
cont.setDepth(D.panel + 3);
this._clearDragHighlights();
// 'drop' event (fired before dragend) already placed the card if it hit a zone.
// Re-render to snap card to its slot (or back to source on a miss).
this.updateHandSetUI();
});
// Only treat as a click when the pointer never moved far enough to trigger a drag
cont.on('pointerdown', () => { _dragged = false; });
cont.on('pointerup', () => {
if (!_dragged) this.onSourceCardClick(i);
_dragged = false; // reset after the check so dragend's state is preserved
});
this.setCardConts.push(cont);
});
}
// Highlights whichever slot the card is hovering over; clears all others
_drawHighlightSlot(g, cx, cy) {
g.clear();
g.fillStyle(0xf5d020, 0.18);
g.fillRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, 0xf5d020, 1);
g.strokeRoundedRect(cx - CARD_W / 2, cy - CARD_H / 2, CARD_W, CARD_H, CARD_R);
}
_clearDragHighlights() {
for (let i = 0; i < 5; i++) {
const slot = this.setHighSlots[i];
this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
}
for (let i = 0; i < 2; i++) {
const slot = this.setLowSlots[i];
this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
}
}
_dropToHighSlot(cardIdx, slotIdx) {
// Remove cardIdx from its current slot (if any)
const fromHi = this.humanHighSlots.indexOf(cardIdx);
const fromLo = this.humanLowSlots.indexOf(cardIdx);
if (fromHi !== -1) this.humanHighSlots[fromHi] = null;
if (fromLo !== -1) this.humanLowSlots[fromLo] = null;
// Displaced occupant goes back to source (its slot becomes null)
// — no extra action needed; it will re-anchor in updateHandSetUI
this.humanHighSlots[slotIdx] = cardIdx;
this.selectedCardIdx = null;
this.updateHandSetUI();
}
_dropToLowSlot(cardIdx, slotIdx) {
const fromHi = this.humanHighSlots.indexOf(cardIdx);
const fromLo = this.humanLowSlots.indexOf(cardIdx);
if (fromHi !== -1) this.humanHighSlots[fromHi] = null;
if (fromLo !== -1) this.humanLowSlots[fromLo] = null;
this.humanLowSlots[slotIdx] = cardIdx;
this.selectedCardIdx = null;
this.updateHandSetUI();
}
sourceCardX(idx) {
// 7 cards spread centered at CX
return CX - 3 * SLOT_GAP + idx * SLOT_GAP;
}
updateHandSetUI() {
const hand = this.gs.players[0].hand;
const placedSet = new Set([
...this.humanHighSlots.filter(v => v !== null),
...this.humanLowSlots.filter(v => v !== null),
]);
// Position each card container
this.setCardConts.forEach((cont, i) => {
const highIdx = this.humanHighSlots.indexOf(i);
const lowIdx = this.humanLowSlots.indexOf(i);
if (highIdx !== -1) {
// In high hand slot
const slot = this.setHighSlots[highIdx];
cont.setPosition(slot.x, SLOT_Y);
cont.setAlpha(1);
// Highlight slot outline if this card is "selected" (for swapping)
this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
if (this.selectedCardIdx === i) this.drawSelectedSlot(slot.g, slot.x, SLOT_Y);
} else if (lowIdx !== -1) {
// In low hand slot
const slot = this.setLowSlots[lowIdx];
cont.setPosition(slot.x, SLOT_Y);
cont.setAlpha(1);
if (this.selectedCardIdx === i) this.drawSelectedSlot(slot.g, slot.x, SLOT_Y);
else this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
} else {
// In source zone
cont.setPosition(this.sourceCardX(i), SOURCE_Y);
cont.setAlpha(this.selectedCardIdx === i ? 0.5 : 1);
// Pulse ring for selected source card
if (this.selectedCardIdx === i) {
// Add a selection ring by re-drawing card with highlight
// (simpler: just alpha fade the others is not great; instead use a ring overlay)
}
}
});
// Draw selection ring on selected source card
this.drawSelectionRings();
// Update empty slot outlines
for (let i = 0; i < 5; i++) {
const slot = this.setHighSlots[i];
if (this.humanHighSlots[i] === null) this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
}
for (let i = 0; i < 2; i++) {
const slot = this.setLowSlots[i];
if (this.humanLowSlots[i] === null) this.drawEmptySlot(slot.g, slot.x, SLOT_Y);
}
// Update rank labels
const highCards = this.humanHighSlots.filter(v => v !== null).map(i => hand[i]);
const lowCards = this.humanLowSlots.filter(v => v !== null).map(i => hand[i]);
if (highCards.length === 5) {
const ev = evaluate5Card(highCards);
this.highRankTxt.setText(handName5(ev));
} else {
this.highRankTxt.setText(`${highCards.length}/5 placed`);
}
if (lowCards.length === 2) {
const ev = evaluate2Card(lowCards);
this.lowRankTxt.setText(ev.name);
} else {
this.lowRankTxt.setText(`${lowCards.length}/2 placed`);
}
// Validate and update foul / set hand button
const complete = highCards.length === 5 && lowCards.length === 2;
let fouled = false;
if (complete) {
fouled = isFoul(highCards, lowCards);
this.foulWarnTxt.setText(fouled ? 'Invalid: High hand must outrank Low hand' : '');
} else {
this.foulWarnTxt.setText('');
}
this.setHandBtn.setEnabled(complete && !fouled);
}
drawSelectionRings() {
// Remove existing rings
if (this._selRings) {
for (const r of this._selRings) r.destroy();
}
this._selRings = [];
if (this.selectedCardIdx === null) return;
const hand = this.gs.players[0].hand;
const hi = this.humanHighSlots.indexOf(this.selectedCardIdx);
const lo = this.humanLowSlots.indexOf(this.selectedCardIdx);
let cx, cy;
if (hi !== -1) {
cx = this.setHighSlots[hi].x; cy = SLOT_Y;
} else if (lo !== -1) {
cx = this.setLowSlots[lo].x; cy = SLOT_Y;
} else {
cx = this.sourceCardX(this.selectedCardIdx); cy = SOURCE_Y;
}
const ring = this.add.graphics().setDepth(D.panel + 4);
ring.lineStyle(4, 0xf5d020, 1);
ring.strokeRoundedRect(cx - CARD_W / 2 - 3, cy - CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2);
this._selRings.push(ring);
}
onSourceCardClick(idx) {
if (this.animating) return;
if (this.selectedCardIdx === idx) {
// Deselect
this.selectedCardIdx = null;
} else {
// If a slot card was selected, swap them
const prevSelected = this.selectedCardIdx;
if (prevSelected !== null) {
const hiPrev = this.humanHighSlots.indexOf(prevSelected);
const loPrev = this.humanLowSlots.indexOf(prevSelected);
if (hiPrev !== -1) {
// Prev was in high slot → move source card there, return prev to source
this.humanHighSlots[hiPrev] = idx;
} else if (loPrev !== -1) {
this.humanLowSlots[loPrev] = idx;
}
this.selectedCardIdx = null;
} else {
this.selectedCardIdx = idx;
}
}
this.updateHandSetUI();
}
onHighSlotClick(slotIdx) {
if (this.animating) return;
const currentInSlot = this.humanHighSlots[slotIdx];
if (this.selectedCardIdx !== null) {
// Place selected card into this slot
const prevInSlot = currentInSlot;
// Remove selected card from wherever it currently is
const hiSel = this.humanHighSlots.indexOf(this.selectedCardIdx);
const loSel = this.humanLowSlots.indexOf(this.selectedCardIdx);
if (hiSel !== -1) this.humanHighSlots[hiSel] = null;
if (loSel !== -1) this.humanLowSlots[loSel] = null;
// Put selected card in this slot
this.humanHighSlots[slotIdx] = this.selectedCardIdx;
// If there was a card here before, it stays selected (or goes to source)
this.selectedCardIdx = prevInSlot; // null if slot was empty
} else if (currentInSlot !== null) {
// No card selected — select the card in this slot
this.selectedCardIdx = currentInSlot;
}
this.updateHandSetUI();
}
onLowSlotClick(slotIdx) {
if (this.animating) return;
const currentInSlot = this.humanLowSlots[slotIdx];
if (this.selectedCardIdx !== null) {
const prevInSlot = currentInSlot;
const hiSel = this.humanHighSlots.indexOf(this.selectedCardIdx);
const loSel = this.humanLowSlots.indexOf(this.selectedCardIdx);
if (hiSel !== -1) this.humanHighSlots[hiSel] = null;
if (loSel !== -1) this.humanLowSlots[loSel] = null;
this.humanLowSlots[slotIdx] = this.selectedCardIdx;
this.selectedCardIdx = prevInSlot;
} else if (currentInSlot !== null) {
this.selectedCardIdx = currentInSlot;
}
this.updateHandSetUI();
}
applyHouseWayUI() {
const hand = this.gs.players[0].hand;
const { highHand, lowHand } = houseWay(hand);
// Map card objects to indices in the hand array
this.humanHighSlots = highHand.map(c => hand.indexOf(c));
this.humanLowSlots = lowHand.map(c => hand.indexOf(c));
this.selectedCardIdx = null;
this.updateHandSetUI();
}
clearHandSet() {
this.humanHighSlots = [null, null, null, null, null];
this.humanLowSlots = [null, null];
this.selectedCardIdx = null;
this.updateHandSetUI();
}
onSetHand() {
if (this.animating) return;
const hand = this.gs.players[0].hand;
const highHand = this.humanHighSlots.map(i => hand[i]);
const lowHand = this.humanLowSlots.map(i => hand[i]);
this.gs = applyHumanSplit(this.gs, highHand, lowHand);
this.animating = true;
this._animateHandReturnToSeat(() => {
// Clear panel card containers before hideHandSetPanel destroys them
for (const c of this.setCardConts) c.destroy();
this.setCardConts = [];
this.hideHandSetPanel();
this.startComparingPhase();
});
}
_animateHandReturnToSeat(onComplete) {
const pos = SEAT_POS[0];
// Split display mirrors renderSeat's comparing layout
const highCX = pos.x - 130;
const lowCX = pos.x + 130;
const splitTarget = (cx, count, slotIdx) => {
const totalW = (count - 1) * CARD_SPREAD_SPLIT;
return { x: cx - totalW / 2 + slotIdx * CARD_SPREAD_SPLIT, y: pos.y };
};
// Build ordered list: high hand cards first, then low hand
const moves = [];
this.humanHighSlots.forEach((cardIdx, slotIdx) => {
if (cardIdx !== null) moves.push({ cont: this.setCardConts[cardIdx], ...splitTarget(highCX, 5, slotIdx) });
});
this.humanLowSlots.forEach((cardIdx, slotIdx) => {
if (cardIdx !== null) moves.push({ cont: this.setCardConts[cardIdx], ...splitTarget(lowCX, 2, slotIdx) });
});
if (!moves.length) { onComplete(); return; }
let done = 0;
moves.forEach(({ cont, x, y }, i) => {
cont.setDepth(D.panel + 10);
this.tweens.add({
targets: cont,
x, y,
duration: 320,
delay: i * 45,
ease: 'Power2.Out',
onComplete: () => { if (++done === moves.length) onComplete(); },
});
});
}
// ── 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() {
playSound(this, SFX.CARD_SHUFFLE);
this.pendingBet = 0;
this.gs = prepareRound(this.gs);
this.clearCardGraphics();
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.renderSeat(seat);
this.renderSeatInfo(seat);
}
this.renderBetAreas();
}
renderDealer() {
for (const c of this.dealerCardGraphics) c.destroy();
this.dealerCardGraphics = [];
this.dealerHighLabel.setText('');
this.dealerLowLabel.setText('');
const dealer = this.gs.dealer;
if (!dealer.hand.length) return;
if (!dealer.revealed) {
// Fan of face-down cards
dealer.hand.forEach((card, i) => {
const x = this.fanCardX(DEALER_X, i, dealer.hand.length);
const cont = this.add.container(x, DEALER_Y).setDepth(D.cards);
this.drawCard(cont, null, false);
this.dealerCardGraphics.push(cont);
});
return;
}
// After reveal: show split layout (5 HIGH + 2 LOW)
this.renderSplitCards(DEALER_X - 170, DEALER_Y, dealer.highHand, this.dealerCardGraphics);
this.renderSplitCards(DEALER_X + 170, DEALER_Y, dealer.lowHand, this.dealerCardGraphics);
if (dealer.highEval) this.dealerHighLabel.setText(handName5(dealer.highEval));
if (dealer.lowEval) this.dealerLowLabel.setText(dealer.lowEval.name);
}
renderSeat(seat) {
if (this.seatCardGraphics[seat]) {
for (const c of this.seatCardGraphics[seat]) c.destroy();
}
this.seatCardGraphics[seat] = [];
const p = this.gs.players[seat];
const pos = SEAT_POS[seat];
if (!p.hand.length) return;
const phase = this.gs.phase;
const showSplit = phase === 'comparing' || phase === 'resolved';
if (seat === 0 && this.panelVisible) {
// Human cards are shown in the panel while it's open — don't render at seat
return;
}
if (showSplit && p.highHand.length > 0) {
// Split view: high hand left, low hand right
if (seat === 0) {
this.renderSplitCards(pos.x - 130, pos.y, p.highHand, this.seatCardGraphics[seat]);
this.renderSplitCards(pos.x + 130, pos.y, p.lowHand, this.seatCardGraphics[seat]);
} else {
this.renderSplitCards(pos.x - 100, pos.y, p.highHand, this.seatCardGraphics[seat]);
this.renderSplitCards(pos.x + 100, pos.y, p.lowHand, this.seatCardGraphics[seat]);
}
if (this.scoreTxts[seat]) this.scoreTxts[seat].setText('');
} else {
// Fan view: 7 face-down cards
p.hand.forEach((card, i) => {
const x = this.fanCardX(pos.x, i, p.hand.length);
const cont = this.add.container(x, pos.y).setDepth(D.cards);
// Human's cards are face-up, AI cards face-down during dealing/setting
const faceUp = seat === 0;
this.drawCard(cont, faceUp ? card : null, faceUp);
this.seatCardGraphics[seat].push(cont);
});
}
}
renderSplitCards(cx, cy, cards, targetArr) {
cards.forEach((card, i) => {
const total = cards.length;
const spread = CARD_SPREAD_SPLIT;
const totalW = (total - 1) * spread;
const x = cx - totalW / 2 + i * spread;
const cont = this.add.container(x, cy).setDepth(D.cards);
this.drawCard(cont, card, true);
targetArr.push(cont);
});
}
renderSeatInfo(seat) {
const p = this.gs.players[seat];
if (this.chipTxts[seat]) this.chipTxts[seat].setText(`$${p.chips.toLocaleString()}`);
}
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.seatCardGraphics)) {
for (const c of cards) c.destroy();
}
this.seatCardGraphics = {};
for (const c of this.dealerCardGraphics) c.destroy();
this.dealerCardGraphics = [];
for (const g of Object.values(this.betGraphics)) g.destroy();
this.betGraphics = {};
}
betCirclePos(seat) {
const pos = SEAT_POS[seat];
if (pos.betX !== 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 * (TABLE_CY - pos.y)),
};
}
fanCardX(centerX, idx, total) {
const totalW = (total - 1) * CARD_SPREAD_FAN + CARD_W;
return centerX - totalW / 2 + CARD_W / 2 + idx * CARD_SPREAD_FAN;
}
// ── 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);
}
}
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);
}
drawCard(container, card, faceUp) {
container.removeAll(true);
const x = -CARD_W / 2, y = -CARD_H / 2;
if (faceUp && card) {
if (card.isJoker) {
// Special Joker rendering
const g = this.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, 0xd4a017, 1.5);
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
container.add(g);
container.add(this.add.text(x + 7, y + 5, 'JK', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#7b2fbe', fontStyle: 'bold',
}));
container.add(this.add.text(0, 4, '★', {
fontFamily: '"Julius Sans One"', fontSize: '50px', color: '#d4a017',
}).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, 'JK', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#7b2fbe', fontStyle: 'bold',
}).setOrigin(1, 1));
} else {
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 s = (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, s(17, true)));
container.add(this.add.text(x + 7, y + 23, card.suitSymbol, s(13)));
container.add(this.add.text(0, 4, card.suitSymbol, s(40)).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 8, card.label, s(17, true)).setOrigin(1, 1));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 22, card.suitSymbol, s(13)).setOrigin(1, 1));
}
} else {
this.addCardBackToContainer(container);
}
}
// ── 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();
playChipBet(this);
}
onClearBet() {
this.pendingBet = 0;
this.updateBetDisplay();
}
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.gs = applyBet(this.gs, 0, this.pendingBet);
for (let seat = 1; seat < SEAT_POS.length; seat++) {
const p = this.gs.players[seat];
if (!p.active) continue;
this.gs = applyBet(this.gs, seat, Math.max(5, chooseBet(p)));
}
this.gs = dealHands(this.gs);
this.animateBets(() => {
this.animateDeal(() => {
this.animating = false;
this.renderAll();
this.startSettingPhase();
});
});
}
// ── Animations ────────────────────────────────────────────────────────────
animateBets(onComplete) {
this.renderBetAreas();
playChipBet(this);
let done = 0;
const active = this.gs.players.filter(p => p.active);
if (!active.length) { 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(0x5cb85c, 1); chip.fillCircle(0, 0, 14);
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) {
// All 7 cards dealt face-down to each player and dealer in sequence
const seats = PLAY_ORDER.filter(s => this.gs.players[s]?.active);
const DECK_X = CX + 200, DECK_Y = 50;
const totalDeals = (seats.length + 1) * 7; // seats + dealer
// Pre-render all cards (face-down initially via renderAll)
this.renderAll();
// Hide all seat card graphics
for (const cards of Object.values(this.seatCardGraphics)) {
for (const c of cards) c.setAlpha(0);
}
for (const c of this.dealerCardGraphics) c.setAlpha(0);
// Build deal sequence: deal 7 cards to each player in order, then dealer
const dealSeq = [];
for (let round = 0; round < 7; round++) {
for (const seat of seats) dealSeq.push({ type: 'player', seat, cardIdx: round });
dealSeq.push({ type: 'dealer', cardIdx: round });
}
let idx = 0;
const stagger = 70;
const dealNext = () => {
if (idx >= dealSeq.length) { onComplete(); return; }
const entry = dealSeq[idx++];
const flying = this.add.container(DECK_X, DECK_Y).setDepth(D.cards + 10);
this.addCardBackToContainer(flying);
let tx, ty;
if (entry.type === 'dealer') {
tx = this.fanCardX(DEALER_X, entry.cardIdx, 7);
ty = DEALER_Y;
} else {
const pos = SEAT_POS[entry.seat];
tx = this.fanCardX(pos.x, entry.cardIdx, 7);
ty = pos.y;
}
this.tweens.add({
targets: flying, x: tx, y: ty, duration: 160, ease: 'Power2',
onComplete: () => {
flying.destroy();
playSound(this, SFX.CARD_DEAL);
if (entry.type === 'dealer') {
if (this.dealerCardGraphics[entry.cardIdx]) this.dealerCardGraphics[entry.cardIdx].setAlpha(1);
} else {
const cards = this.seatCardGraphics[entry.seat] ?? [];
if (cards[entry.cardIdx]) cards[entry.cardIdx].setAlpha(1);
}
this.time.delayedCall(stagger, dealNext);
},
});
};
dealNext();
}
animateDealerReveal(onComplete) {
playSound(this, SFX.CARD_SHOW);
// Flip each dealer card to face-up
let done = 0;
const total = this.dealerCardGraphics.length;
if (!total) { this.renderDealer(); onComplete(); return; }
this.dealerCardGraphics.forEach((cont, i) => {
this.time.delayedCall(i * 120, () => {
this.tweens.add({
targets: cont, scaleX: 0, duration: 110, ease: 'Linear',
onComplete: () => {
// Reveal the face
const card = this.gs.dealer.hand[i];
this.drawCard(cont, card, true);
this.tweens.add({
targets: cont, scaleX: 1, duration: 110, ease: 'Linear',
onComplete: () => {
done++;
if (done === total) {
// Now show split layout
this.time.delayedCall(400, () => {
this.gs = { ...this.gs, dealer: { ...this.gs.dealer, revealed: true } };
this.renderDealer();
onComplete();
});
}
},
});
},
});
});
});
}
// ── Setting phase ─────────────────────────────────────────────────────────
startSettingPhase() {
// Auto-set all AI players and dealer via House Way
for (let seat = 1; seat < SEAT_POS.length; seat++) {
if (this.gs.players[seat].active) {
this.gs = applyHouseWaySplit(this.gs, seat);
}
}
this.gs = applyDealerHouseWay(this.gs);
// Reset human split state
this.humanHighSlots = [null, null, null, null, null];
this.humanLowSlots = [null, null];
this.selectedCardIdx = null;
// Show the hand-setting panel
this.showHandSetPanel();
}
// ── Comparing phase ───────────────────────────────────────────────────────
startComparingPhase() {
this.animating = true;
// Advance phase so renderSeat renders everyone (including the human) in split layout,
// preserving the animated split the player's cards just landed in.
this.gs = { ...this.gs, phase: 'comparing' };
// Briefly reveal all AI splits (1.5s), then reveal dealer
this.renderAll(); // shows AI split layouts
this.time.delayedCall(1500, () => {
// Reveal dealer cards
this.animateDealerReveal(() => {
// Resolve round
this.gs = resolveRound(this.gs);
this.renderAll();
// Modal comparison sequence, then chip animations
this.startModalComparisons(() => {
this.animateAllChips(() => {
this.updateServerChips();
this.time.delayedCall(800, () => {
this.animating = false;
this.showNextRoundPrompt();
});
});
});
});
});
}
// ── Modal comparison ─────────────────────────────────────────────────────────
startModalComparisons(onComplete) {
const MODAL_W = 1100;
const MODAL_H = 530;
const MODAL_X = CX - MODAL_W / 2; // 410
const MODAL_TOP = 150;
const DEALER_ROW_Y = 262;
const PLAYER_ROW_Y = 510;
const ARROW_Y = 393;
const HIGH_CX = 650;
const LOW_CX = 1270;
const RESULT_CENTER_Y = MODAL_TOP + Math.round(MODAL_H / 2); // 415
const slotX = (cx, count, i) => cx - ((count - 1) * MODAL_CARD_SPREAD) / 2 + i * MODAL_CARD_SPREAD;
this._modal = { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y,
ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, slotX };
this._buildCompareModal();
// Fade in frame elements (arrows/rank text/result have their own reveal animations)
const frameObjs = this._modal.frameObjects;
for (const o of frameObjs) o.setAlpha(0);
this.tweens.add({
targets: frameObjs, alpha: 1, duration: 250, ease: 'Linear',
onComplete: () => {
this._animateDealerIntoModal(() => {
const players = PLAY_ORDER
.map(seat => this.gs.players[seat])
.filter(p => p?.active);
const processNext = idx => {
if (idx >= players.length) {
this._destroyModal();
onComplete();
return;
}
const isLast = idx === players.length - 1;
this._comparePlayerInModal(players[idx], isLast, () => processNext(idx + 1));
};
processNext(0);
});
},
});
}
_buildCompareModal() {
const { MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, DEALER_ROW_Y, PLAYER_ROW_Y,
HIGH_CX, LOW_CX, RESULT_CENTER_Y } = this._modal;
const LABEL_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#d4a017' };
const RANK_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#c0c0c0' };
const HEADER_STYLE = { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#6a7484' };
const bg = this.add.graphics().setDepth(D.modal);
bg.fillStyle(0x0a0f1a, 0.94);
bg.fillRoundedRect(MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, 12);
bg.lineStyle(2, 0xd4a017, 0.8);
bg.strokeRoundedRect(MODAL_X, MODAL_TOP, MODAL_W, MODAL_H, 12);
const sep = this.add.graphics().setDepth(D.modal + 1);
sep.lineStyle(1, 0x3a4454, 0.6);
sep.lineBetween(CX, MODAL_TOP + 20, CX, MODAL_TOP + MODAL_H - 20);
const dealerLabel = this.add.text(CX, MODAL_TOP + 25, 'DEALER', LABEL_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const hiLabel = this.add.text(HIGH_CX, MODAL_TOP + 28, 'HIGH HAND', HEADER_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const loLabel = this.add.text(LOW_CX, MODAL_TOP + 28, 'LOW HAND', HEADER_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const dealerRankHi = this.add.text(HIGH_CX, DEALER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const dealerRankLo = this.add.text(LOW_CX, DEALER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const playerLabel = this.add.text(CX, PLAYER_ROW_Y - CARD_H / 2 - 22, '', LABEL_STYLE)
.setOrigin(0.5, 1).setDepth(D.modal + 1);
const playerRankHi = this.add.text(HIGH_CX, PLAYER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const playerRankLo = this.add.text(LOW_CX, PLAYER_ROW_Y + CARD_H / 2 + 10, '', RANK_STYLE)
.setOrigin(0.5, 0).setDepth(D.modal + 1);
const arrowHiG = this.add.graphics().setDepth(D.modal + 2).setAlpha(0);
const arrowLoG = this.add.graphics().setDepth(D.modal + 2).setAlpha(0);
// resultTxt lives at modal center and flies to portrait after each player
const resultTxt = this.add.text(CX, RESULT_CENTER_Y, '', {
fontFamily: '"Julius Sans One"', fontSize: '72px', fontStyle: 'bold', color: '#f5d020',
}).setOrigin(0.5).setDepth(D.modal + 3).setAlpha(0).setScale(0);
Object.assign(this._modal, {
bg, sep, dealerLabel, hiLabel, loLabel,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt,
// frameObjects: modal chrome that hides/shows between players
frameObjects: [bg, sep, dealerLabel, hiLabel, loLabel, dealerRankHi, dealerRankLo],
// dynamicObjects: per-player state reset each round
dynamicObjects: [playerLabel, playerRankHi, playerRankLo, arrowHiG, arrowLoG],
// allObjects: everything for final destroy
allObjects: [bg, sep, dealerLabel, hiLabel, loLabel,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt],
});
}
_animateDealerIntoModal(onComplete) {
const { DEALER_ROW_Y, HIGH_CX, LOW_CX, slotX } = this._modal;
const cards = this.dealerCardGraphics;
const dealer = this.gs.dealer;
const hiCount = dealer.highHand.length; // 5
const loCount = dealer.lowHand.length; // 2
this._dealerCardOrigins = cards.map(c => ({ x: c.x, y: c.y }));
for (const c of cards) c.setDepth(D.modal + 5);
let done = 0;
const total = cards.length;
cards.forEach((card, i) => {
const isHi = i < hiCount;
const idx = isHi ? i : i - hiCount;
const tx = isHi ? slotX(HIGH_CX, hiCount, idx) : slotX(LOW_CX, loCount, idx);
this.tweens.add({
targets: card, x: tx, y: DEALER_ROW_Y,
duration: 350, delay: i * 50, ease: 'Power2.Out',
onComplete: () => {
done++;
if (done === total) {
const { dealerRankHi, dealerRankLo } = this._modal;
dealerRankHi.setText(handName5(dealer.highEval));
dealerRankLo.setText(dealer.lowEval?.name ?? '');
onComplete();
}
},
});
});
}
_drawModalArrow(g, x, y, dealerWins) {
g.clear();
if (dealerWins) {
g.fillStyle(0xff3a3a, 1); // bright red
g.fillTriangle(x, y - 18, x - 14, y + 14, x + 14, y + 14); // apex up
} else {
g.fillStyle(0x00e676, 1); // bright green
g.fillTriangle(x, y + 18, x - 14, y - 14, x + 14, y - 14); // apex down
}
}
_comparePlayerInModal(player, isLast, onComplete) {
const { PLAYER_ROW_Y, HIGH_CX, LOW_CX, slotX,
dealerRankHi, dealerRankLo, playerLabel, playerRankHi, playerRankLo,
arrowHiG, arrowLoG, resultTxt, RESULT_CENTER_Y } = this._modal;
const dealer = this.gs.dealer;
const cards = this.seatCardGraphics[player.seat] ?? [];
const hiCount = player.highHand.length; // 5
const loCount = player.lowHand.length; // 2
// Reset per-player UI
arrowHiG.setAlpha(0).setScale(1);
arrowLoG.setAlpha(0).setScale(1);
resultTxt.setAlpha(0).setScale(0).setText('').setPosition(CX, RESULT_CENTER_Y);
playerRankHi.setText('').setColor('#c0c0c0').setAlpha(1);
playerRankLo.setText('').setColor('#c0c0c0').setAlpha(1);
dealerRankHi.setColor('#c0c0c0');
dealerRankLo.setColor('#c0c0c0');
playerLabel.setText(player.name.toUpperCase()).setColor('#d4a017').setAlpha(1);
this._playerCardOrigins = cards.map(c => ({ x: c.x, y: c.y }));
for (const c of cards) c.setDepth(D.modal + 5);
let done = 0;
const total = cards.length;
if (total === 0) {
this._playerCardOrigins = [];
this._afterCardsIn(player, isLast, onComplete);
return;
}
cards.forEach((card, i) => {
const isHi = i < hiCount;
const idx = isHi ? i : i - hiCount;
const tx = isHi ? slotX(HIGH_CX, hiCount, idx) : slotX(LOW_CX, loCount, idx);
this.tweens.add({
targets: card, x: tx, y: PLAYER_ROW_Y,
duration: 280, delay: i * 45, ease: 'Power2.Out',
onComplete: () => {
done++;
if (done === total) {
playerRankHi.setText(handName5(player.highEval));
playerRankLo.setText(player.lowEval?.name ?? '');
this._afterCardsIn(player, isLast, onComplete);
}
},
});
});
}
_afterCardsIn(player, isLast, onComplete) {
const dealer = this.gs.dealer;
const { ARROW_Y, HIGH_CX, LOW_CX, RESULT_CENTER_Y, arrowHiG, arrowLoG, resultTxt,
dealerRankHi, dealerRankLo, playerRankHi, playerRankLo } = this._modal;
const hiWinDealer = player.isFoul || compare5Card(player.highEval, dealer.highEval) <= 0;
const loWinDealer = player.isFoul || compare2Card(player.lowEval, dealer.lowEval) <= 0;
// 500ms pause then HIGH arrow
this.time.delayedCall(500, () => {
this._drawModalArrow(arrowHiG, HIGH_CX, ARROW_Y, hiWinDealer);
arrowHiG.setAlpha(0).setScale(0);
this.tweens.add({ targets: arrowHiG, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut' });
if (hiWinDealer) { dealerRankHi.setColor('#ffe033'); playerRankHi.setColor('#ff3a3a'); }
else { dealerRankHi.setColor('#ff3a3a'); playerRankHi.setColor('#ffe033'); }
// 1000ms pause then LOW arrow + verdict
this.time.delayedCall(1000, () => {
this._drawModalArrow(arrowLoG, LOW_CX, ARROW_Y, loWinDealer);
arrowLoG.setAlpha(0).setScale(0);
this.tweens.add({ targets: arrowLoG, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut' });
if (loWinDealer) { dealerRankLo.setColor('#ffe033'); playerRankLo.setColor('#ff3a3a'); }
else { dealerRankLo.setColor('#ff3a3a'); playerRankLo.setColor('#ffe033'); }
// 500ms pause after low arrow before verdict appears
this.time.delayedCall(500, () => {
let verdict, color;
if (player.result === 'win') { verdict = 'WIN'; color = '#ffe033'; }
else if (player.result === 'push') { verdict = 'PUSH'; color = '#a0aabb'; }
else { verdict = 'LOSE'; color = '#ff3a3a'; }
resultTxt.setText(verdict).setColor(color).setAlpha(0).setScale(0)
.setPosition(CX, RESULT_CENTER_Y);
this.tweens.add({ targets: resultTxt, alpha: 1, scaleX: 1, scaleY: 1,
duration: 250, ease: 'Back.easeOut' });
const sfx = player.result === 'win' ? SFX.CASINO_WIN
: player.result === 'push' ? SFX.SQUASH
: SFX.CASINO_LOSE;
playSound(this, sfx);
if (player.seat > 0) {
const emotion = player.result === 'win' ? 'happy' : 'upset';
this.portraits[player.seat]?.playEmotion?.(emotion);
}
});
// 500ms (low arrow) + 1200ms (verdict visible) = 1700ms before fly-back
this.time.delayedCall(1700, () => {
const cards = this.seatCardGraphics[player.seat] ?? [];
const origins = this._playerCardOrigins;
const pos = SEAT_POS[player.seat];
// Fade out modal frame + dynamic UI + dealer cards together (resultTxt stays)
const fadeTargets = [
...this._modal.frameObjects,
...this._modal.dynamicObjects,
...this.dealerCardGraphics,
];
this.tweens.add({ targets: fadeTargets, alpha: 0, duration: 250, ease: 'Linear' });
// Fly player cards back to seat simultaneously
let done = 0;
const afterFly = () => {
// Once cards are home, fly resultTxt to player's portrait
const destX = pos.portraitX ?? pos.x;
const destY = pos.portraitY ?? pos.y;
this.tweens.add({
targets: resultTxt, x: destX, y: destY,
scaleX: 0.45, scaleY: 0.45,
duration: 1500, ease: 'Power2.In',
onComplete: () => {
this.tweens.add({ targets: resultTxt, alpha: 0, duration: 200, ease: 'Linear',
onComplete: () => {
if (isLast) {
// No need to reopen — _destroyModal follows in processNext
onComplete();
return;
}
// Reopen modal frame + dealer cards for next player
const reshow = [...this._modal.frameObjects, ...this.dealerCardGraphics];
for (const o of reshow) o.setAlpha(0);
this.tweens.add({ targets: reshow, alpha: 1, duration: 250, ease: 'Linear',
onComplete: () => onComplete() });
},
});
},
});
};
if (!cards.length) {
afterFly();
return;
}
cards.forEach((card, i) => {
const { x, y } = origins[i] ?? { x: card.x, y: card.y };
this.tweens.add({
targets: card, x, y, duration: 260, delay: i * 30, ease: 'Power2.In',
onComplete: () => {
done++;
if (done === cards.length) {
for (const c of cards) c.setDepth(D.cards);
afterFly();
}
},
});
});
});
});
});
}
_destroyModal() {
if (!this._modal) return;
for (const o of this._modal.allObjects) { if (o?.destroy) o.destroy(); }
// Dealer card containers were faded out in the last player's close; destroy them so
// renderAll can recreate them cleanly for the next round.
for (const c of this.dealerCardGraphics) { if (c?.destroy) c.destroy(); }
this.dealerCardGraphics = [];
delete this._modal;
delete this._dealerCardOrigins;
delete this._playerCardOrigins;
}
showComparisons(onComplete) {
const activePlayers = this.gs.players.filter(p => p.active);
let done = 0;
const total = activePlayers.length;
activePlayers.forEach((p, i) => {
this.time.delayedCall(i * 350, () => {
this.showSeatResult(p);
done++;
if (done === total) {
// Animate chip flows
this.animateAllChips(() => {
this.updateServerChips();
this.time.delayedCall(800, onComplete);
});
}
});
});
}
showSeatResult(player) {
const pos = SEAT_POS[player.seat];
const resultColor = RESULT_COLORS[player.result] ?? '#8a94a6';
// Net label for human (show commission)
let label;
if (player.result === 'win') {
label = `Win! +$${player.chipsWon}`;
} else if (player.result === 'push') {
label = 'Push';
} else if (player.result === 'foul') {
label = 'Foul — Lose';
} else {
label = `Lose -$${-player.chipsWon}`;
}
const badge = this.add.text(pos.x, pos.y - CARD_H / 2 - 36, label, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: resultColor, fontStyle: 'bold',
}).setOrigin(0.5).setDepth(D.ui + 5).setAlpha(0).setScale(0.7);
this.tweens.add({
targets: badge, alpha: 1, scaleX: 1, scaleY: 1, duration: 200, ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1800, () => {
this.tweens.add({ targets: badge, alpha: 0, y: badge.y - 30, duration: 380, ease: 'Power2',
onComplete: () => badge.destroy() });
});
},
});
playSound(this, player.result === 'win' ? SFX.WIN : SFX.LOSE);
if (this.scoreTxts[player.seat]) this.scoreTxts[player.seat].setText('');
}
animateAllChips(onComplete) {
const players = this.gs.players.filter(p => p.active);
let done = 0;
const check = () => { done++; if (done >= players.length) onComplete(); };
for (const p of players) {
if (p.result === 'win') this.animateChipsFromDealer(p.seat, check);
else if (p.result === 'push') this.animateChipReturn(p.seat, check);
else this.animateChipsToDealer(p.seat, check);
}
}
animateChipsFromDealer(seat, onComplete) {
const { x: bx, y: by } = this.betCirclePos(seat);
const numChips = 3;
let done = 0;
for (let i = 0; i < numChips; i++) {
this.time.delayedCall(i * 60, () => {
const chip = this.add.graphics().setDepth(D.chips + 5);
chip.fillStyle(0xf5d020, 1); chip.fillCircle(0, 0, 12);
chip.x = DEALER_X; chip.y = DEALER_Y;
this.tweens.add({
targets: chip, x: bx, y: by, duration: 380, ease: 'Power2',
onComplete: () => { chip.destroy(); done++; if (done === numChips) onComplete(); },
});
});
}
}
animateChipsToDealer(seat, onComplete) {
if (!this.betGraphics[seat]) { onComplete(); return; }
const { x: bx, y: by } = this.betCirclePos(seat);
const numChips = 2;
let done = 0;
for (let i = 0; i < numChips; i++) {
this.time.delayedCall(i * 60, () => {
const chip = this.add.graphics().setDepth(D.chips + 5);
chip.fillStyle(0xe05c5c, 1); chip.fillCircle(0, 0, 12);
chip.x = bx; chip.y = by;
this.tweens.add({
targets: chip, x: DEALER_X, y: DEALER_Y, duration: 380, ease: 'Power2',
onComplete: () => { chip.destroy(); done++; if (done === numChips) onComplete(); },
});
});
}
}
animateChipReturn(seat, onComplete) {
// Push — chips return to player from bet circle
if (!this.betGraphics[seat]) { onComplete(); return; }
const bg = this.betGraphics[seat];
this.tweens.add({
targets: bg, alpha: 0, duration: 320, ease: 'Power2',
onComplete: () => { onComplete(); },
});
}
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 { /* silent — local state is still correct */ }
}
// ── Next round ────────────────────────────────────────────────────────────
showNextRoundPrompt() {
const human = this.gs.players[0];
if (human.chips < 5) {
new Modal(this, {
title: 'Out of Chips',
body: 'You ran out of chips. Better luck next time!',
buttons: [{ label: 'Leave', onClick: () => this.scene.start('GameMenu') }],
});
return;
}
const btn = new Button(this, CX, GAME_HEIGHT / 2, 'Next Round', () => {
btn.destroy();
this.startNewRound();
}, { width: 220, height: 56, fontSize: 24 });
btn.setDepth(D.ui + 5);
}
}