1718 lines
66 KiB
JavaScript
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);
|
|
}
|
|
}
|