1124 lines
41 KiB
JavaScript
1124 lines
41 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
|
import { auth } from '../../services/auth.js';
|
|
import { api } from '../../services/api.js';
|
|
import { PHASES, getPhase } from './PhaseSpec.js';
|
|
import {
|
|
applyDrawFromDeck,
|
|
applyDrawFromDiscard,
|
|
applyLaydown,
|
|
applyHit,
|
|
applyDiscard,
|
|
cardPoints,
|
|
createInitialState,
|
|
discardTop,
|
|
getHitTargets,
|
|
runAssignedValues,
|
|
startNextRound,
|
|
} from './Phase10Logic.js';
|
|
import { chooseAction, findLaydown } from './Phase10AI.js';
|
|
|
|
// ── Layout constants ────────────────────────────────────────────────────────
|
|
const CX = GAME_WIDTH / 2;
|
|
const CY = GAME_HEIGHT / 2;
|
|
|
|
const CARD_W = 90;
|
|
const CARD_H = 126;
|
|
const CARD_R = 8;
|
|
const HAND_SPREAD = 96;
|
|
const GROUP_GAP = 14;
|
|
const LAIDOWN_CARD_W = 60;
|
|
const LAIDOWN_CARD_H = 84;
|
|
|
|
const D = {
|
|
felt: -1, board: 0, pile: 5, card: 10, highlight: 20,
|
|
ui: 30, portrait: 35, chip: 40, banner: 60,
|
|
playbookPanel: 70, playbookTab: 71, modal: 80,
|
|
};
|
|
|
|
// Suit color tinting.
|
|
const SUIT = {
|
|
red: { fill: 0xfbe7e2, stroke: 0xc92a2a, num: '#c92a2a' },
|
|
blue: { fill: 0xe2ecfb, stroke: 0x2b7fbf, num: '#1c63a3' },
|
|
yellow: { fill: 0xfbf3d4, stroke: 0xc69a1f, num: '#a17a10' },
|
|
green: { fill: 0xdff3e3, stroke: 0x2f9e44, num: '#1c6b30' },
|
|
};
|
|
const WILD_FILL = 0xf2ead8;
|
|
const WILD_STRIPE = 0xc8a84b;
|
|
const SKIP_FILL = 0x1e1a12;
|
|
const SKIP_GLYPH = 0xe06c75;
|
|
|
|
// Seat slot mapping. Slot index in SLOTS_USED matches seat index.
|
|
const SLOTS_USED = {
|
|
2: ['bottom', 'top'],
|
|
3: ['bottom', 'left', 'right'],
|
|
4: ['bottom', 'left', 'top', 'right'],
|
|
};
|
|
|
|
function slotLayout(slot) {
|
|
switch (slot) {
|
|
case 'bottom':
|
|
return {
|
|
rotation: 0,
|
|
handStart: { x: 460, y: 990 },
|
|
handAxis: 'x',
|
|
handFaceUp: true,
|
|
laidStart: { x: 200, y: 820 },
|
|
laidAxis: 'x',
|
|
portrait: { x: 120, y: 990, r: 56 },
|
|
nameLabel: { x: 120, y: 1056 },
|
|
chip: { x: 240, y: 990 },
|
|
rotateCards: 0,
|
|
};
|
|
case 'top':
|
|
return {
|
|
rotation: 180,
|
|
handStart: { x: 460, y: 60 },
|
|
handAxis: 'x',
|
|
handFaceUp: false,
|
|
laidStart: { x: 200, y: 200 },
|
|
laidAxis: 'x',
|
|
portrait: { x: 120, y: 70, r: 50 },
|
|
nameLabel: { x: 120, y: 134 },
|
|
chip: { x: 240, y: 70 },
|
|
rotateCards: 180,
|
|
};
|
|
case 'left':
|
|
return {
|
|
rotation: 90,
|
|
handStart: { x: 60, y: 900 },
|
|
handAxis: 'y-up',
|
|
handFaceUp: false,
|
|
laidStart: { x: 200, y: 480 },
|
|
laidAxis: 'y',
|
|
portrait: { x: 100, y: 130, r: 50 },
|
|
nameLabel: { x: 100, y: 196 },
|
|
chip: { x: 220, y: 130 },
|
|
rotateCards: 90,
|
|
};
|
|
case 'right':
|
|
return {
|
|
rotation: 270,
|
|
handStart: { x: 1860, y: 900 },
|
|
handAxis: 'y-up',
|
|
handFaceUp: false,
|
|
laidStart: { x: 1720, y: 480 },
|
|
laidAxis: 'y',
|
|
portrait: { x: 1820, y: 130, r: 50 },
|
|
nameLabel: { x: 1820, y: 196 },
|
|
chip: { x: 1700, y: 130 },
|
|
rotateCards: 270,
|
|
};
|
|
default:
|
|
throw new Error(`Unknown slot: ${slot}`);
|
|
}
|
|
}
|
|
|
|
const DRAW_POS = { x: CX - 80, y: CY };
|
|
const DISCARD_POS = { x: CX + 80, y: CY };
|
|
|
|
// ── Scene ───────────────────────────────────────────────────────────────────
|
|
export default class Phase10Game extends Phaser.Scene {
|
|
constructor() { super('Phase10Game'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data.game;
|
|
this.opponents = data.opponents ?? [];
|
|
this.playfield = data.playfield ?? null;
|
|
this.cardBack = data.cardBack ?? null;
|
|
|
|
this.gs = null;
|
|
this.animating = false;
|
|
this.matchOver = false;
|
|
|
|
this.cardObjs = new Map();
|
|
this.transientObjs = [];
|
|
this.highlightObjs = [];
|
|
this.opponentPortraits = [];
|
|
|
|
this.selectedHandIdx = null;
|
|
this.slotForSeat = [];
|
|
|
|
this.seatChips = []; // seat → { container, phaseText, scoreText, doneRibbon }
|
|
this.playbookOpen = false;
|
|
this.playbookPanel = null;
|
|
this.layDownBtn = null;
|
|
}
|
|
|
|
create() {
|
|
this.buildPlayfield();
|
|
this.assignSeats();
|
|
this.buildSeatAreas();
|
|
this.buildCenter();
|
|
this.buildPlaybook();
|
|
this.buildHUD();
|
|
this.startNewMatch();
|
|
}
|
|
|
|
// ── Setup ────────────────────────────────────────────────────────────────
|
|
|
|
buildPlayfield() {
|
|
const pf = this.playfield;
|
|
if (pf?.key && this.textures.exists(pf.key)) {
|
|
this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
|
|
} else {
|
|
const color = pf?.fallbackColor
|
|
? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d;
|
|
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
|
|
}
|
|
}
|
|
|
|
assignSeats() {
|
|
const playerCount = 1 + this.opponents.length;
|
|
const slots = SLOTS_USED[playerCount];
|
|
if (!slots) throw new Error(`Phase 10 needs 2..4 players, got ${playerCount}`);
|
|
this.slotForSeat = slots.slice();
|
|
}
|
|
|
|
buildSeatAreas() {
|
|
const playerCount = this.slotForSeat.length;
|
|
for (let seat = 0; seat < playerCount; seat++) {
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
|
|
// Portrait & name
|
|
if (seat === 0) {
|
|
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'Phase10Game');
|
|
this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
} else {
|
|
const opp = this.opponents[seat - 1];
|
|
if (opp) {
|
|
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait);
|
|
this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
}
|
|
}
|
|
|
|
// Phase chip + score badge anchored next to portrait.
|
|
this.seatChips[seat] = this.makeSeatChip(seat, layout.chip.x, layout.chip.y);
|
|
}
|
|
}
|
|
|
|
makeSeatChip(seat, x, y) {
|
|
const container = this.add.container(x, y).setDepth(D.chip);
|
|
const bg = this.add.graphics();
|
|
bg.fillStyle(COLORS.panel, 0.92);
|
|
bg.fillRoundedRect(0, -32, 280, 64, 10);
|
|
bg.lineStyle(2, COLORS.accent, 1);
|
|
bg.strokeRoundedRect(0, -32, 280, 64, 10);
|
|
container.add(bg);
|
|
|
|
const phaseText = this.add.text(14, -22, '', {
|
|
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
|
|
});
|
|
const goalText = this.add.text(14, 0, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.textHex, wordWrap: { width: 200 },
|
|
});
|
|
const scoreText = this.add.text(264, -22, '', {
|
|
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
|
|
}).setOrigin(1, 0);
|
|
const scoreLabel = this.add.text(264, 4, 'pts', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
|
|
}).setOrigin(1, 0);
|
|
|
|
const ribbon = this.add.text(140, 18, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '12px',
|
|
color: COLORS.textDarkHex, backgroundColor: COLORS.goldHex,
|
|
padding: { x: 6, y: 2 },
|
|
}).setOrigin(0.5);
|
|
ribbon.setVisible(false);
|
|
|
|
container.add([phaseText, goalText, scoreText, scoreLabel, ribbon]);
|
|
return { container, phaseText, goalText, scoreText, ribbon };
|
|
}
|
|
|
|
buildCenter() {
|
|
// Draw pile placeholder
|
|
const drawRect = this.add.rectangle(DRAW_POS.x, DRAW_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
|
.setStrokeStyle(2, COLORS.accent).setDepth(D.pile)
|
|
.setInteractive({ useHandCursor: true });
|
|
drawRect.on('pointerdown', () => this.onDrawDeckClick());
|
|
this.add.text(DRAW_POS.x, DRAW_POS.y - CARD_H/2 - 18, 'DRAW', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
this.drawPileText = this.add.text(DRAW_POS.x, DRAW_POS.y + CARD_H/2 + 16, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
this.drawPileRect = drawRect;
|
|
|
|
// Discard pile placeholder
|
|
const discRect = this.add.rectangle(DISCARD_POS.x, DISCARD_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
|
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile)
|
|
.setInteractive({ useHandCursor: true });
|
|
discRect.on('pointerdown', () => this.onDiscardPileClick());
|
|
this.add.text(DISCARD_POS.x, DISCARD_POS.y - CARD_H/2 - 18, 'DISCARD', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
this.discardPileRect = discRect;
|
|
}
|
|
|
|
buildPlaybook() {
|
|
// Vertical "ribbon" tab on the right edge.
|
|
const tabX = GAME_WIDTH - 22;
|
|
const tabY = CY;
|
|
const tab = this.add.container(tabX, tabY).setDepth(D.playbookTab);
|
|
const tabBg = this.add.graphics();
|
|
const drawTab = (color) => {
|
|
tabBg.clear();
|
|
tabBg.fillStyle(color, 1);
|
|
tabBg.fillRoundedRect(-18, -110, 36, 220, 8);
|
|
tabBg.lineStyle(2, COLORS.accent, 1);
|
|
tabBg.strokeRoundedRect(-18, -110, 36, 220, 8);
|
|
};
|
|
drawTab(COLORS.panel);
|
|
const tabText = this.add.text(0, 0, 'PHASES', {
|
|
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5);
|
|
tabText.setRotation(-Math.PI / 2);
|
|
tab.add([tabBg, tabText]);
|
|
tab.setSize(36, 220);
|
|
tab.setInteractive(new Phaser.Geom.Rectangle(-18, -110, 36, 220), Phaser.Geom.Rectangle.Contains);
|
|
tab.input.cursor = 'pointer';
|
|
tab.on('pointerover', () => drawTab(COLORS.accent));
|
|
tab.on('pointerout', () => drawTab(this.playbookOpen ? COLORS.accent : COLORS.panel));
|
|
tab.on('pointerdown', () => this.togglePlaybook());
|
|
this.playbookTab = tab;
|
|
|
|
// Off-screen panel container — slides in from the right when opened.
|
|
const panelW = 480;
|
|
const panelH = 880;
|
|
const panelX = GAME_WIDTH + panelW / 2; // start fully off-screen
|
|
const panel = this.add.container(panelX, CY).setDepth(D.playbookPanel);
|
|
const pbg = this.add.graphics();
|
|
pbg.fillStyle(COLORS.panel, 0.97);
|
|
pbg.fillRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16);
|
|
pbg.lineStyle(3, COLORS.accent, 1);
|
|
pbg.strokeRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16);
|
|
panel.add(pbg);
|
|
|
|
const header = this.add.text(0, -panelH / 2 + 30, 'PHASE PLAYBOOK', {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5);
|
|
panel.add(header);
|
|
const subhead = this.add.text(0, -panelH / 2 + 64, 'race to clear all ten', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5);
|
|
panel.add(subhead);
|
|
|
|
// 10 phase rows
|
|
this.playbookRows = [];
|
|
const rowH = 70;
|
|
const rowY0 = -panelH / 2 + 100;
|
|
for (let i = 0; i < PHASES.length; i++) {
|
|
const phase = PHASES[i];
|
|
const y = rowY0 + i * rowH;
|
|
const rowBg = this.add.graphics();
|
|
const drawRowBg = (highlight) => {
|
|
rowBg.clear();
|
|
rowBg.fillStyle(highlight ? COLORS.gold : COLORS.bg, highlight ? 0.2 : 0.6);
|
|
rowBg.fillRoundedRect(-panelW / 2 + 16, y - rowH / 2 + 4, panelW - 32, rowH - 8, 8);
|
|
if (highlight) {
|
|
rowBg.lineStyle(2, COLORS.gold, 0.8);
|
|
rowBg.strokeRoundedRect(-panelW / 2 + 16, y - rowH / 2 + 4, panelW - 32, rowH - 8, 8);
|
|
}
|
|
};
|
|
drawRowBg(false);
|
|
panel.add(rowBg);
|
|
|
|
const num = this.add.text(-panelW / 2 + 36, y - 14, `${phase.num}`, {
|
|
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
|
|
}).setOrigin(0, 0.5);
|
|
const goal = this.add.text(-panelW / 2 + 84, y - 14, phase.short, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
|
|
}).setOrigin(0, 0.5);
|
|
const dots = this.add.container(-panelW / 2 + 84, y + 16);
|
|
panel.add([num, goal, dots]);
|
|
|
|
this.playbookRows.push({ phase: phase.num, drawRowBg, dots });
|
|
}
|
|
|
|
panel.setSize(panelW, panelH);
|
|
this.playbookPanel = panel;
|
|
this.playbookPanelW = panelW;
|
|
this.playbookClosedX = panelX;
|
|
this.playbookOpenX = GAME_WIDTH - panelW / 2 - 40;
|
|
}
|
|
|
|
togglePlaybook() {
|
|
if (!this.playbookPanel) return;
|
|
const open = !this.playbookOpen;
|
|
this.playbookOpen = open;
|
|
this.tweens.add({
|
|
targets: this.playbookPanel,
|
|
x: open ? this.playbookOpenX : this.playbookClosedX,
|
|
duration: 260,
|
|
ease: 'Cubic.easeOut',
|
|
});
|
|
}
|
|
|
|
refreshPlaybook() {
|
|
if (!this.gs || !this.playbookRows) return;
|
|
for (const row of this.playbookRows) {
|
|
const isLocalCurrent = this.gs.players[0]?.phase === row.phase;
|
|
row.drawRowBg(isLocalCurrent);
|
|
row.dots.removeAll(true);
|
|
// Per-player chip showing where each seat is relative to this phase.
|
|
const dotR = 9;
|
|
const seatColors = [COLORS.gold, 0x4dabf7, 0xa78bfa, 0xff8a5b];
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
const p = this.gs.players[s];
|
|
const x = s * 26;
|
|
const onThisPhase = p.phase === row.phase && p.clearedAt == null;
|
|
const passed = p.phase > row.phase || (p.clearedAt != null && row.phase <= 10);
|
|
const color = seatColors[s % seatColors.length];
|
|
|
|
const dot = this.add.graphics();
|
|
if (passed) {
|
|
// dim filled dot
|
|
dot.fillStyle(color, 0.35);
|
|
dot.fillCircle(x, 0, dotR);
|
|
} else if (onThisPhase) {
|
|
dot.fillStyle(color, 1);
|
|
dot.fillCircle(x, 0, dotR);
|
|
dot.lineStyle(2, COLORS.text, 0.95);
|
|
dot.strokeCircle(x, 0, dotR);
|
|
} else {
|
|
dot.lineStyle(2, color, 0.6);
|
|
dot.strokeCircle(x, 0, dotR);
|
|
}
|
|
row.dots.add(dot);
|
|
// Seat initial label
|
|
const label = this.add.text(x, 0, s === 0 ? 'Y' : `${s + 1}`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '10px',
|
|
color: passed ? COLORS.mutedHex : COLORS.textDarkHex, fontStyle: 'bold',
|
|
}).setOrigin(0.5);
|
|
if (!onThisPhase) label.setColor(COLORS.mutedHex);
|
|
row.dots.add(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
buildHUD() {
|
|
this.statusText = this.add.text(CX, 36, '', {
|
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.ui);
|
|
|
|
new Button(this, 110, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), {
|
|
variant: 'ghost', width: 110, height: 40, fontSize: 18,
|
|
}).setDepth(D.ui);
|
|
new Button(this, 110, GAME_HEIGHT - 100, 'New', () => this.startNewMatch(), {
|
|
variant: 'ghost', width: 110, height: 40, fontSize: 18,
|
|
}).setDepth(D.ui);
|
|
|
|
// "Lay Down" button — hidden until the local player can lay down.
|
|
this.layDownBtn = new Button(this, CX, CY + 200, 'Lay Down Phase', () => this.onLayDownClick(), {
|
|
width: 280, height: 56, fontSize: 22,
|
|
}).setDepth(D.ui);
|
|
this.layDownBtn.setVisible(false);
|
|
}
|
|
|
|
// ── Match lifecycle ──────────────────────────────────────────────────────
|
|
|
|
startNewMatch() {
|
|
if (this.animating) return;
|
|
this.matchOver = false;
|
|
this.clearAllCardObjs();
|
|
this.clearHighlights();
|
|
this.selectedHandIdx = null;
|
|
|
|
const playerCount = this.slotForSeat.length;
|
|
this.gs = createInitialState({ playerCount });
|
|
this.renderAll();
|
|
this.setStatus('Your turn — draw from deck or discard.');
|
|
this.maybeStartAITurn();
|
|
}
|
|
|
|
// ── Card sprite factory ─────────────────────────────────────────────────
|
|
|
|
makeCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) {
|
|
const c = this.add.container(x, y).setDepth(D.card);
|
|
c.setRotation((rotation * Math.PI) / 180);
|
|
c.setScale(scale);
|
|
this.renderCardFace(c, card, faceUp);
|
|
c.card = card;
|
|
return c;
|
|
}
|
|
|
|
renderCardFace(container, card, faceUp) {
|
|
container.removeAll(true);
|
|
const x = -CARD_W / 2, y = -CARD_H / 2;
|
|
const g = this.add.graphics();
|
|
|
|
if (!faceUp) {
|
|
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
|
|
g.destroy();
|
|
container.add(
|
|
this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
|
|
.setDisplaySize(CARD_W, CARD_H)
|
|
.setOrigin(0.5)
|
|
);
|
|
} else {
|
|
this.drawCardBackGfx(g, x, y);
|
|
container.add(g);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (card.value === 'wild') {
|
|
g.fillStyle(WILD_FILL, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(3, WILD_STRIPE, 1);
|
|
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(2, WILD_STRIPE, 0.5);
|
|
for (let s = -CARD_H; s < CARD_W; s += 14) {
|
|
const t0 = Math.max(0, -s / CARD_H);
|
|
const t1 = Math.min(1, (CARD_W - s) / CARD_H);
|
|
if (t0 >= t1) continue;
|
|
g.beginPath();
|
|
g.moveTo(x + s + t0 * CARD_H, y + CARD_H * (1 - t0));
|
|
g.lineTo(x + s + t1 * CARD_H, y + CARD_H * (1 - t1));
|
|
g.strokePath();
|
|
}
|
|
container.add(g);
|
|
container.add(this.add.text(0, 0, 'W', {
|
|
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textDarkHex,
|
|
}).setOrigin(0.5));
|
|
return;
|
|
}
|
|
|
|
if (card.value === 'skip') {
|
|
g.fillStyle(SKIP_FILL, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(3, SKIP_GLYPH, 1);
|
|
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
// ⊘ glyph
|
|
g.lineStyle(5, SKIP_GLYPH, 1);
|
|
g.strokeCircle(0, 0, 26);
|
|
g.beginPath();
|
|
g.moveTo(-18, -18); g.lineTo(18, 18);
|
|
g.strokePath();
|
|
container.add(g);
|
|
container.add(this.add.text(0, 36, 'SKIP', {
|
|
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.dangerHex,
|
|
}).setOrigin(0.5));
|
|
return;
|
|
}
|
|
|
|
// Numbered colored card
|
|
const suit = SUIT[card.color] ?? SUIT.red;
|
|
g.fillStyle(suit.fill, 1);
|
|
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
|
g.lineStyle(4, suit.stroke, 1);
|
|
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
|
|
container.add(g);
|
|
|
|
const numStyle = (sz) => ({ fontFamily: 'Righteous', fontSize: `${sz}px`, color: suit.num });
|
|
container.add(this.add.text(x + 8, y + 6, `${card.value}`, numStyle(20)));
|
|
container.add(this.add.text(0, 0, `${card.value}`, numStyle(46)).setOrigin(0.5));
|
|
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, `${card.value}`, numStyle(20)).setOrigin(1, 1));
|
|
}
|
|
|
|
drawCardBackGfx(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, COLORS.accent, 0.6);
|
|
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
|
|
g.lineStyle(1, 0xffffff, 0.15);
|
|
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
|
|
}
|
|
|
|
clearAllCardObjs() {
|
|
for (const c of this.cardObjs.values()) c.destroy();
|
|
this.cardObjs.clear();
|
|
for (const o of this.transientObjs) o.destroy();
|
|
this.transientObjs = [];
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────
|
|
|
|
renderAll() {
|
|
this.clearAllCardObjs();
|
|
this.renderCenter();
|
|
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
|
this.renderSeat(seat);
|
|
}
|
|
this.renderSeatChips();
|
|
this.renderTurnIndicator();
|
|
this.refreshPlaybook();
|
|
this.refreshLayDownBtn();
|
|
}
|
|
|
|
renderCenter() {
|
|
const remaining = this.gs.drawPile.length;
|
|
if (remaining > 0) {
|
|
const c = this.makeCardSprite({ value: 'back', id: -1 }, DRAW_POS.x, DRAW_POS.y, { faceUp: false });
|
|
c.card = null;
|
|
this.cardObjs.set('draw', c);
|
|
}
|
|
this.drawPileText.setText(`${remaining}`);
|
|
|
|
const top = discardTop(this.gs);
|
|
if (top) {
|
|
const c = this.makeCardSprite(top, DISCARD_POS.x, DISCARD_POS.y, { faceUp: true });
|
|
this.cardObjs.set(`discard-top-${top.id}`, c);
|
|
}
|
|
}
|
|
|
|
renderSeat(seat) {
|
|
const player = this.gs.players[seat];
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
|
|
// Hand
|
|
for (let i = 0; i < player.hand.length; i++) {
|
|
const card = player.hand[i];
|
|
let x, y;
|
|
if (layout.handAxis === 'x') {
|
|
x = layout.handStart.x + i * HAND_SPREAD;
|
|
y = layout.handStart.y;
|
|
} else {
|
|
x = layout.handStart.x;
|
|
y = layout.handStart.y - i * HAND_SPREAD;
|
|
}
|
|
const c = this.makeCardSprite(card, x, y, {
|
|
faceUp: layout.handFaceUp, rotation: layout.rotateCards,
|
|
});
|
|
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
|
|
if (seat === 0) {
|
|
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W/2, -CARD_H/2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
|
|
c.input.cursor = 'pointer';
|
|
c.on('pointerdown', () => this.onHandClick(i));
|
|
}
|
|
}
|
|
|
|
// Laid-down phase
|
|
if (player.laidDown) this.renderLaidDown(seat, player.laidDown);
|
|
}
|
|
|
|
renderLaidDown(seat, laid) {
|
|
const slot = this.slotForSeat[seat];
|
|
const layout = slotLayout(slot);
|
|
|
|
let cursorX = layout.laidStart.x;
|
|
let cursorY = layout.laidStart.y;
|
|
|
|
for (let gi = 0; gi < laid.length; gi++) {
|
|
const group = laid[gi];
|
|
const N = group.cards.length;
|
|
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
|
|
|
|
// Determine run values for labelling
|
|
const runVals = group.kind === 'run' ? runAssignedValues(group) : null;
|
|
|
|
for (let ci = 0; ci < N; ci++) {
|
|
const card = group.cards[ci];
|
|
let x, y;
|
|
if (layout.laidAxis === 'x') {
|
|
x = cursorX + ci * (LAIDOWN_CARD_W * 0.75) + LAIDOWN_CARD_W / 2;
|
|
y = cursorY;
|
|
} else {
|
|
x = cursorX;
|
|
y = cursorY + ci * (LAIDOWN_CARD_H * 0.5) + LAIDOWN_CARD_H / 2;
|
|
}
|
|
const c = this.makeCardSprite(card, x, y, {
|
|
faceUp: true,
|
|
rotation: layout.rotateCards,
|
|
scale: LAIDOWN_CARD_W / CARD_W,
|
|
});
|
|
// If wild in a run, overlay its assigned value as a small chip.
|
|
if (card.value === 'wild' && runVals) {
|
|
const chip = this.add.text(x, y, `${runVals[ci]}`, {
|
|
fontFamily: 'Righteous', fontSize: '18px',
|
|
color: COLORS.textDarkHex, backgroundColor: COLORS.goldHex,
|
|
padding: { x: 4, y: 1 },
|
|
}).setOrigin(0.5).setDepth(D.card + 1);
|
|
this.transientObjs.push(chip);
|
|
}
|
|
this.cardObjs.set(`laid-${seat}-${gi}-${ci}-${card.id}`, c);
|
|
}
|
|
|
|
// Advance cursor for next group
|
|
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
|
|
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
|
|
}
|
|
}
|
|
|
|
renderSeatChips() {
|
|
for (let s = 0; s < this.gs.players.length; s++) {
|
|
const p = this.gs.players[s];
|
|
const chip = this.seatChips[s];
|
|
if (!chip) continue;
|
|
if (p.clearedAt != null) {
|
|
chip.phaseText.setText('Phase 10 ✓');
|
|
chip.goalText.setText('cleared');
|
|
} else {
|
|
const ph = getPhase(p.phase);
|
|
chip.phaseText.setText(`Phase ${p.phase}`);
|
|
chip.goalText.setText(ph ? ph.short : '');
|
|
}
|
|
chip.scoreText.setText(`${p.score}`);
|
|
chip.ribbon.setVisible(!!p.laidDown);
|
|
chip.ribbon.setText('LAID DOWN');
|
|
}
|
|
}
|
|
|
|
renderTurnIndicator() {
|
|
const seat = this.gs.currentPlayer;
|
|
const slot = this.slotForSeat[seat];
|
|
const lay = slotLayout(slot);
|
|
if (!this.turnGlow) {
|
|
this.turnGlow = this.add.circle(0, 0, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1);
|
|
}
|
|
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
|
|
}
|
|
|
|
refreshLayDownBtn() {
|
|
if (!this.layDownBtn) return;
|
|
const isLocal = this.isLocalTurn();
|
|
const localPlayer = this.gs.players[0];
|
|
if (!isLocal || !this.gs.drawnThisTurn || localPlayer.laidDown) {
|
|
this.layDownBtn.setVisible(false);
|
|
return;
|
|
}
|
|
const layout = findLaydown(localPlayer.hand, localPlayer.phase);
|
|
this.layDownBtn.setVisible(!!layout);
|
|
}
|
|
|
|
// ── Local input ─────────────────────────────────────────────────────────
|
|
|
|
isLocalTurn() {
|
|
return this.gs && !this.matchOver && this.gs.roundPhase === 'play' && this.gs.currentPlayer === 0;
|
|
}
|
|
|
|
onDrawDeckClick() {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
if (this.gs.drawnThisTurn) return;
|
|
const next = applyDrawFromDeck(this.gs);
|
|
if (next === this.gs) return;
|
|
this.gs = next;
|
|
this.renderAll();
|
|
this.setStatus('Drew from deck. Lay down, hit, or discard.');
|
|
}
|
|
|
|
onDiscardPileClick() {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
// If we have a card selected and we've already drawn, discarding ends turn.
|
|
if (this.gs.drawnThisTurn && this.selectedHandIdx != null) {
|
|
const handIdx = this.selectedHandIdx;
|
|
this.selectedHandIdx = null;
|
|
this.clearHighlights();
|
|
this.commitDiscard(handIdx);
|
|
return;
|
|
}
|
|
// Otherwise, attempt draw from discard.
|
|
if (!this.gs.drawnThisTurn) {
|
|
const top = discardTop(this.gs);
|
|
if (!top) { this.setStatus('Discard pile is empty.'); return; }
|
|
if (top.value === 'skip') { this.setStatus("Can't draw a Skip from the discard pile."); return; }
|
|
const next = applyDrawFromDiscard(this.gs);
|
|
if (next === this.gs) return;
|
|
this.gs = next;
|
|
this.renderAll();
|
|
this.setStatus('Took from discard. Lay down, hit, or discard.');
|
|
}
|
|
}
|
|
|
|
onHandClick(handIdx) {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
if (!this.gs.drawnThisTurn) {
|
|
this.setStatus('Draw a card first.');
|
|
return;
|
|
}
|
|
this.selectedHandIdx = handIdx;
|
|
this.highlightTargetsForCard();
|
|
const card = this.gs.players[0].hand[handIdx];
|
|
if (card.value === 'skip') {
|
|
this.setStatus('Click the discard pile to discard. Choose a target to skip.');
|
|
} else if (this.gs.players[0].laidDown) {
|
|
this.setStatus('Click a laid-down group to hit, or the discard pile to end turn.');
|
|
} else {
|
|
this.setStatus('Click the discard pile to end your turn.');
|
|
}
|
|
}
|
|
|
|
onLayDownClick() {
|
|
if (!this.isLocalTurn() || this.animating) return;
|
|
const player = this.gs.players[0];
|
|
if (player.laidDown) return;
|
|
const layout = findLaydown(player.hand, player.phase);
|
|
if (!layout) {
|
|
this.setStatus("You don't have the cards for this phase yet.");
|
|
return;
|
|
}
|
|
const groups = layout.map((g) => ({ kind: g.kind, cardIds: g.cards.map((c) => c.id) }));
|
|
const next = applyLaydown(this.gs, groups);
|
|
if (next === this.gs) {
|
|
this.setStatus("Couldn't lay down — invalid grouping.");
|
|
return;
|
|
}
|
|
this.gs = next;
|
|
this.selectedHandIdx = null;
|
|
this.clearHighlights();
|
|
this.renderAll();
|
|
this.setStatus('Laid down! Now hit or discard.');
|
|
}
|
|
|
|
highlightTargetsForCard() {
|
|
this.clearHighlights();
|
|
if (this.selectedHandIdx == null) return;
|
|
const card = this.gs.players[0].hand[this.selectedHandIdx];
|
|
|
|
// Discard pile target — always available (with a Skip-target picker if skip).
|
|
{
|
|
const h = this.add.rectangle(DISCARD_POS.x, DISCARD_POS.y, CARD_W + 18, CARD_H + 18, 0x4dabf7, 0.18)
|
|
.setStrokeStyle(3, 0x4dabf7, 0.9).setDepth(D.highlight);
|
|
this.highlightObjs.push(h);
|
|
}
|
|
|
|
// Hit targets — only if we've laid down.
|
|
if (this.gs.players[0].laidDown) {
|
|
const targets = getHitTargets(this.gs, card);
|
|
for (const t of targets) {
|
|
const rect = this.hitTargetHighlight(t);
|
|
if (rect) this.highlightObjs.push(rect);
|
|
}
|
|
}
|
|
}
|
|
|
|
hitTargetHighlight(target) {
|
|
const slot = this.slotForSeat[target.targetSeat];
|
|
const layout = slotLayout(slot);
|
|
const player = this.gs.players[target.targetSeat];
|
|
const laid = player.laidDown;
|
|
if (!laid) return null;
|
|
|
|
// Replicate cursor walk from renderLaidDown to find this group's centroid.
|
|
let cursorX = layout.laidStart.x;
|
|
let cursorY = layout.laidStart.y;
|
|
let groupCenter = null;
|
|
let lowX = 0, highX = 0;
|
|
for (let gi = 0; gi < laid.length; gi++) {
|
|
const group = laid[gi];
|
|
const N = group.cards.length;
|
|
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
|
|
if (gi === target.groupIdx) {
|
|
if (layout.laidAxis === 'x') {
|
|
const cx = cursorX + groupW / 2;
|
|
groupCenter = { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8 };
|
|
lowX = cursorX - 8;
|
|
highX = cursorX + groupW + 8;
|
|
} else {
|
|
const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5);
|
|
groupCenter = { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6 };
|
|
}
|
|
break;
|
|
}
|
|
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
|
|
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
|
|
}
|
|
if (!groupCenter) return null;
|
|
|
|
// For runs with position, draw a small marker on the relevant end too.
|
|
const seatColor = 0xffd700;
|
|
const rect = this.add.rectangle(groupCenter.x, groupCenter.y, groupCenter.w, groupCenter.h, seatColor, 0.12)
|
|
.setStrokeStyle(2, seatColor, 0.9).setDepth(D.highlight)
|
|
.setInteractive({ useHandCursor: true });
|
|
rect.on('pointerdown', () => this.commitHit(target));
|
|
if (target.position && layout.laidAxis === 'x') {
|
|
const tip = this.add.text(target.position === 'low' ? lowX : highX, groupCenter.y, target.position === 'low' ? '◀' : '▶', {
|
|
fontFamily: 'Righteous', fontSize: '20px', color: '#ffd700',
|
|
}).setOrigin(0.5).setDepth(D.highlight);
|
|
this.transientObjs.push(tip);
|
|
}
|
|
return rect;
|
|
}
|
|
|
|
clearHighlights() {
|
|
for (const h of this.highlightObjs) h.destroy();
|
|
this.highlightObjs = [];
|
|
}
|
|
|
|
// ── Action commit (local) ───────────────────────────────────────────────
|
|
|
|
commitDiscard(handIdx) {
|
|
const card = this.gs.players[0].hand[handIdx];
|
|
if (card.value === 'skip') {
|
|
this.openSkipTargetModal(handIdx);
|
|
return;
|
|
}
|
|
const next = applyDiscard(this.gs, handIdx);
|
|
if (next === this.gs) { this.setStatus("Can't discard right now."); return; }
|
|
this.gs = next;
|
|
this.renderAll();
|
|
this.afterTurnTransition();
|
|
}
|
|
|
|
openSkipTargetModal(handIdx) {
|
|
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
|
.setInteractive().setDepth(D.modal);
|
|
const w = 560, h = 280;
|
|
const panel = this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
|
|
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
|
|
const title = this.add.text(CX, CY - h/2 + 40, 'Skip which player?', {
|
|
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5).setDepth(D.modal);
|
|
|
|
const buttons = [];
|
|
const valid = [];
|
|
for (let s = 1; s < this.gs.players.length; s++) {
|
|
if (this.gs.players[s].skipped) continue;
|
|
valid.push(s);
|
|
}
|
|
if (valid.length === 0) {
|
|
// No valid target; just close and reset.
|
|
overlay.destroy(); panel.destroy(); title.destroy();
|
|
this.setStatus('No one to skip. Pick a different card to discard.');
|
|
this.selectedHandIdx = null;
|
|
this.clearHighlights();
|
|
return;
|
|
}
|
|
const startX = CX - ((valid.length - 1) * 180) / 2;
|
|
valid.forEach((s, i) => {
|
|
const opp = this.opponents[s - 1];
|
|
const name = opp?.name ?? `Player ${s + 1}`;
|
|
const btn = new Button(this, startX + i * 180, CY + 10, name, () => {
|
|
overlay.destroy(); panel.destroy(); title.destroy();
|
|
for (const b of buttons) b.destroy();
|
|
cancelBtn.destroy();
|
|
const next = applyDiscard(this.gs, handIdx, s);
|
|
if (next === this.gs) { this.setStatus("Can't skip that player."); return; }
|
|
this.gs = next;
|
|
this.renderAll();
|
|
this.afterTurnTransition();
|
|
}, { width: 160, height: 56, fontSize: 18 }).setDepth(D.modal);
|
|
buttons.push(btn);
|
|
});
|
|
const cancelBtn = new Button(this, CX, CY + h/2 - 40, 'Cancel', () => {
|
|
overlay.destroy(); panel.destroy(); title.destroy();
|
|
for (const b of buttons) b.destroy();
|
|
cancelBtn.destroy();
|
|
}, { variant: 'ghost', width: 160, height: 40, fontSize: 16 }).setDepth(D.modal);
|
|
}
|
|
|
|
commitHit(target) {
|
|
if (this.selectedHandIdx == null) return;
|
|
const handIdx = this.selectedHandIdx;
|
|
this.selectedHandIdx = null;
|
|
this.clearHighlights();
|
|
const next = applyHit(this.gs, handIdx, target.targetSeat, target.groupIdx, target.position);
|
|
if (next === this.gs) { this.setStatus("Can't hit there."); return; }
|
|
this.gs = next;
|
|
this.renderAll();
|
|
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
|
|
this.handleRoundEnd();
|
|
return;
|
|
}
|
|
if (this.gs.players[0].hand.length === 0) {
|
|
this.handleRoundEnd();
|
|
return;
|
|
}
|
|
this.setStatus('Hit! Hit again or discard.');
|
|
}
|
|
|
|
afterTurnTransition() {
|
|
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
|
|
this.handleRoundEnd();
|
|
return;
|
|
}
|
|
this.maybeStartAITurn();
|
|
}
|
|
|
|
maybeStartAITurn() {
|
|
if (this.gs.currentPlayer === 0) {
|
|
this.setStatus('Your turn — draw from deck or discard.');
|
|
return;
|
|
}
|
|
this.runAIStep();
|
|
}
|
|
|
|
// ── AI loop ─────────────────────────────────────────────────────────────
|
|
|
|
runAIStep() {
|
|
if (this.matchOver) return;
|
|
if (this.gs.roundPhase !== 'play') { this.handleRoundEnd(); return; }
|
|
if (this.gs.currentPlayer === 0) {
|
|
this.setStatus('Your turn — draw from deck or discard.');
|
|
return;
|
|
}
|
|
const seat = this.gs.currentPlayer;
|
|
const name = this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
|
|
this.setStatus(`${name}'s turn…`);
|
|
this.time.delayedCall(550, () => this.applyAIAction());
|
|
}
|
|
|
|
applyAIAction() {
|
|
if (this.matchOver) return;
|
|
if (this.gs.roundPhase !== 'play') { this.handleRoundEnd(); return; }
|
|
const action = chooseAction(this.gs);
|
|
if (!action) { this.runAIStep(); return; }
|
|
const apply = () => {
|
|
let next;
|
|
if (action.type === 'drawDeck') next = applyDrawFromDeck(this.gs);
|
|
else if (action.type === 'drawDiscard') next = applyDrawFromDiscard(this.gs);
|
|
else if (action.type === 'laydown') next = applyLaydown(this.gs, action.groups);
|
|
else if (action.type === 'hit') next = applyHit(this.gs, action.handIdx, action.targetSeat, action.groupIdx, action.position);
|
|
else if (action.type === 'discard') next = applyDiscard(this.gs, action.handIdx, action.skipTargetSeat ?? null);
|
|
else next = this.gs;
|
|
if (next === this.gs) {
|
|
// Defensive — avoid infinite loop. Force discard the first card.
|
|
next = applyDiscard(this.gs, 0);
|
|
}
|
|
this.gs = next;
|
|
this.renderAll();
|
|
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
|
|
this.handleRoundEnd();
|
|
return;
|
|
}
|
|
if (this.gs.currentPlayer === 0) {
|
|
this.setStatus('Your turn — draw from deck or discard.');
|
|
return;
|
|
}
|
|
// Continue AI loop for the same player until they discard.
|
|
this.time.delayedCall(360, () => this.applyAIAction());
|
|
};
|
|
apply();
|
|
}
|
|
|
|
// ── Round / match end ───────────────────────────────────────────────────
|
|
|
|
handleRoundEnd() {
|
|
if (this.gs.roundPhase === 'gameOver') {
|
|
this.matchOver = true;
|
|
this.recordHistory();
|
|
this.showMatchEndPanel();
|
|
return;
|
|
}
|
|
// roundOver
|
|
this.showRoundSummaryPanel();
|
|
}
|
|
|
|
showRoundSummaryPanel() {
|
|
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
|
.setInteractive().setDepth(D.modal);
|
|
const w = 720, h = 460;
|
|
const bg = this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
|
|
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
|
|
const items = [overlay, bg];
|
|
|
|
const summary = this.gs.lastRoundSummary;
|
|
const winnerName = this.nameForSeat(summary.winnerSeat);
|
|
const title = this.add.text(CX, CY - h/2 + 40, `Round ${summary.roundNum} — ${winnerName} went out`, {
|
|
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
|
}).setOrigin(0.5).setDepth(D.modal);
|
|
items.push(title);
|
|
|
|
summary.rows.forEach((row, i) => {
|
|
const y = CY - h/2 + 100 + i * 50;
|
|
const name = this.nameForSeat(row.seat);
|
|
const status = row.cleared10 ? 'cleared phase 10!' : row.advanced ? `→ phase ${row.phaseNext}` : `still phase ${row.phaseNext}`;
|
|
const t = this.add.text(CX - w/2 + 36, y, `${name}`, {
|
|
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex,
|
|
}).setDepth(D.modal);
|
|
const sc = this.add.text(CX - 100, y, `+${row.pointsThisRound} (total ${row.totalScore})`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
|
}).setDepth(D.modal);
|
|
const ph = this.add.text(CX + 160, y, status, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '18px',
|
|
color: row.advanced ? COLORS.goldHex : COLORS.dangerHex,
|
|
}).setDepth(D.modal);
|
|
items.push(t, sc, ph);
|
|
});
|
|
|
|
const tieNote = this.gs.matchTied ? 'Tie among phase 10 finishers — extra round!' : null;
|
|
if (tieNote) {
|
|
items.push(this.add.text(CX, CY + h/2 - 90, tieNote, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.dangerHex,
|
|
}).setOrigin(0.5).setDepth(D.modal));
|
|
}
|
|
|
|
const btn = new Button(this, CX, CY + h/2 - 50, 'Next round', () => {
|
|
for (const o of items) o.destroy();
|
|
btn.destroy();
|
|
this.gs = startNextRound(this.gs);
|
|
this.renderAll();
|
|
this.maybeStartAITurn();
|
|
}, { width: 240, height: 48, fontSize: 20 }).setDepth(D.modal);
|
|
}
|
|
|
|
showMatchEndPanel() {
|
|
const winner = this.gs.matchWinner;
|
|
const youWon = winner === 0;
|
|
const name = this.nameForSeat(winner);
|
|
const msg = youWon ? `You win!` : `${name} wins!`;
|
|
this.setStatus(msg);
|
|
|
|
for (let s = 1; s < this.gs.players.length; s++) {
|
|
const p = this.opponentPortraits[s];
|
|
if (!p) continue;
|
|
if (winner === s) p.playEmotion?.('win');
|
|
else p.playEmotion?.('loss');
|
|
}
|
|
|
|
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
|
.setInteractive().setDepth(D.modal);
|
|
const w = 720, h = 360;
|
|
this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
|
|
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
|
|
this.add.text(CX, CY - h/2 + 56, msg, {
|
|
fontFamily: 'Righteous', fontSize: '42px',
|
|
color: youWon ? COLORS.goldHex : COLORS.textHex,
|
|
}).setOrigin(0.5).setDepth(D.modal);
|
|
|
|
const me = this.gs.players[0];
|
|
this.add.text(CX, CY - 10, `Final score: ${me.score} (lower is better)`, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
|
}).setOrigin(0.5).setDepth(D.modal);
|
|
|
|
new Button(this, CX - 130, CY + h / 2 - 60, 'Play again', () => {
|
|
overlay.destroy();
|
|
this.scene.restart({
|
|
game: this.gameDef, opponents: this.opponents,
|
|
playfield: this.playfield, cardBack: this.cardBack,
|
|
});
|
|
}, { width: 220, fontSize: 22 }).setDepth(D.modal);
|
|
new Button(this, CX + 130, CY + h / 2 - 60, 'Leave',
|
|
() => this.scene.start('GameMenu'),
|
|
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal);
|
|
}
|
|
|
|
async recordHistory() {
|
|
try {
|
|
const me = this.gs.players[0];
|
|
const youWon = this.gs.matchWinner === 0;
|
|
const opponentScores = [];
|
|
for (let s = 1; s < this.gs.players.length; s++) opponentScores.push(this.gs.players[s].score);
|
|
await api.post('/history/single-player', {
|
|
slug: 'phase10',
|
|
score: me.score, // lower is better, but the schema is generic — record raw score
|
|
opponentScores,
|
|
result: youWon ? 'win' : 'loss',
|
|
});
|
|
} catch (err) {
|
|
console.warn('[phase10] failed to record history', err);
|
|
}
|
|
}
|
|
|
|
// ── HUD helpers ─────────────────────────────────────────────────────────
|
|
|
|
setStatus(s) {
|
|
if (this.statusText) this.statusText.setText(s);
|
|
}
|
|
|
|
nameForSeat(seat) {
|
|
if (seat === 0) return auth.user?.username ?? 'You';
|
|
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
|
|
}
|
|
}
|