fertig-classic-games/src/games/ginrummy/GinRummyGame.js

1133 lines
42 KiB
JavaScript

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
import {
THEME, CARD_W, CARD_H, CARD_R, HAND_SPREAD, AI_SPREAD,
WIN_SCORE, MAX_DEADWOOD_TO_KNOCK,
seatPositions, pilePositions,
sortBySuit, sortByRank,
allCandidateMelds, bestMeldGroups, canLayoff, ginDeadwoodValue,
} from './GinRummyData.js';
import { GinRummyLogic } from './GinRummyLogic.js';
import {
chooseDrawSource, chooseDiscard, shouldKnock, findLayoffs, thinkDelay,
} from './GinRummyAI.js';
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
const PLAYER_HAND_Y = 920;
const D = {
felt: -2, rail: -1, pile: 2, card: 10, glow: 9, cardText: 11,
ui: 30, toast: 50, overlay: 60, overlayUI: 62,
};
const SUIT_RED = '#c92a2a';
const SUIT_BLK = '#1a1208';
export default class GinRummyGame extends Phaser.Scene {
constructor() { super('GinRummyGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'ginrummy', name: 'Gin Rummy' };
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.nPlayers = 1 + this.opponents.length;
this.logic = new GinRummyLogic(this.nPlayers);
this.humanCards = []; // card containers for seat 0 (hand order matches logic.players[0].hand)
this.aiCardObjs = []; // aiCardObjs[seat] = array of containers
this.pileObjs = {}; // { stock, discard }
this.revealObjs = []; // containers during knock reveal / layoff
this.selectedCard = null; // { key, container } during discard phase
this.layoffSelected = null; // card key selected during layoff
this.humanMode = 'idle'; // 'idle'|'draw'|'discard'|'layoff'
this.busy = false;
// Drag state (Phase 10 pattern)
this.potentialDrag = null;
this.dragState = null;
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) {}
this._seatPos = seatPositions(this.nPlayers);
this._pilePos = pilePositions();
this.buildBackdrop();
this.buildScorePanel();
this.buildPortraits();
this.buildPiles();
this.buildActionBar();
this.buildSortButtons();
this.setupDragHandlers();
this.logic.newGame();
this.startRound();
}
// ── Backdrop ───────────────────────────────────────────────────────────────
buildBackdrop() {
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 if (pf?.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
} else {
const g = this.add.graphics().setDepth(D.felt);
g.fillGradientStyle(THEME.feltTop, THEME.feltTop, THEME.feltBottom, THEME.feltBottom, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
// Table rail border
const r = this.add.graphics().setDepth(D.rail);
r.fillStyle(THEME.tableRail, 1);
r.fillRoundedRect(30, 30, GAME_WIDTH - 60, GAME_HEIGHT - 60, 40);
r.fillStyle(THEME.feltTop, 1);
r.fillRoundedRect(50, 50, GAME_WIDTH - 100, GAME_HEIGHT - 100, 30);
r.lineStyle(3, THEME.gold, 0.4);
r.strokeRoundedRect(50, 50, GAME_WIDTH - 100, GAME_HEIGHT - 100, 30);
// Table title
this.add.text(CX, 30, 'GIN RUMMY', {
fontFamily: 'Righteous', fontSize: '20px', color: THEME.goldHex,
}).setOrigin(0.5, 0.5).setDepth(D.ui).setAlpha(0.7);
}
// ── Portraits ──────────────────────────────────────────────────────────────
buildPortraits() {
const R = 36;
const n = 10; // HAND_SIZE — used to compute spread extents for portrait placement
this.turnRings = [];
for (let seat = 0; seat < this.nPlayers; seat++) {
const sp = this._seatPos[seat];
let px, py;
if (sp.axis === 'h') {
// Horizontal spread: portrait sits to the left of the hand
const handY = seat === 0 ? PLAYER_HAND_Y : sp.y;
const spread = seat === 0 ? HAND_SPREAD : AI_SPREAD;
const leftmostX = sp.x + (0 - (n - 1) / 2) * spread;
px = leftmostX - R - 16 - (seat === 0 ? 100 : 0);
py = handY + (seat === 0 ? 50 : 0);
} else {
// Vertical spread: portrait sits above the hand
const topmostY = sp.y + (0 - (n - 1) / 2) * AI_SPREAD;
px = sp.x;
py = topmostY - R - 16;
}
if (seat === 0) {
createPlayerPortrait(this, px, py, R, D.ui, 'GinRummyGame');
} else {
createOpponentPortrait(this, this.opponents[seat - 1], px, py, R, D.ui);
}
// Turn ring — bright yellow pulsing stroke, hidden until it's this seat's turn
const ring = this.add.graphics().setDepth(D.ui + 1).setAlpha(0);
ring.lineStyle(4, 0xffee00, 1);
ring.strokeCircle(px, py, R + 5);
this.turnRings.push(ring);
}
}
setTurnRing(seat) {
this.turnRings?.forEach((ring, i) => {
if (i !== seat) {
this.tweens.killTweensOf(ring);
ring.setAlpha(0);
}
});
const ring = this.turnRings?.[seat];
if (!ring) return;
ring.setAlpha(1);
this.tweens.killTweensOf(ring);
this.tweens.add({
targets: ring,
alpha: { from: 1, to: 0.25 },
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
// ── Score panel ────────────────────────────────────────────────────────────
buildScorePanel() {
const px = GAME_WIDTH - 370, py = 120;
const panelH = 60 + this.nPlayers * 68;
const g = this.add.graphics().setDepth(D.ui - 1);
g.fillStyle(0x000000, 0.35);
g.fillRoundedRect(px - 10, py - 10, 160, panelH, 12);
g.lineStyle(1.5, THEME.gold, 0.4);
g.strokeRoundedRect(px - 10, py - 10, 160, panelH, 12);
this.add.text(px + 70, py, 'SCORES', {
fontFamily: 'Righteous', fontSize: '16px', color: THEME.goldHex,
}).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.8);
this.scoreTexts = [];
const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')];
for (let s = 0; s < this.nPlayers; s++) {
const ry = py + 36 + s * 68;
const col = s === 0 ? '#f07a6d' : THEME.ivoryHex;
this.add.text(px, ry, names[s], {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: THEME.mutedHex,
}).setDepth(D.ui);
this.scoreTexts.push(this.add.text(px + 140, ry + 24, '0', {
fontFamily: 'Righteous', fontSize: '36px', color: col,
}).setOrigin(1, 0).setDepth(D.ui));
}
// Round indicator
this.roundText = this.add.text(px + 70, py + 36 + this.nPlayers * 68 + 8, 'Round 1', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex,
}).setOrigin(0.5, 0).setDepth(D.ui);
}
refreshScores() {
for (let s = 0; s < this.nPlayers; s++) {
this.scoreTexts[s].setText(String(this.logic.players[s].score));
}
this.roundText.setText(`Round ${this.logic.round}`);
}
// ── Pile placeholders ──────────────────────────────────────────────────────
buildPiles() {
const { stock, discard } = this._pilePos;
this._drawCardBack(stock.x, stock.y, 0.6).setDepth(D.pile - 1);
this._drawCardBack(discard.x, discard.y, 0.6).setDepth(D.pile - 1);
this.add.text(stock.x, stock.y + CARD_H / 2 + 14, 'STOCK', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }).setOrigin(0.5, 0).setDepth(D.ui);
this.add.text(discard.x, discard.y + CARD_H / 2 + 14, 'DISCARD', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex }).setOrigin(0.5, 0).setDepth(D.ui);
// Stock count text
this.stockCountText = this.add.text(stock.x, stock.y - CARD_H / 2 - 14, '', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex,
}).setOrigin(0.5, 1).setDepth(D.ui);
}
_drawCardBack(x, y, alpha = 1) {
const g = this.add.graphics();
g.fillStyle(THEME.cardBack, alpha);
g.fillRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, CARD_R);
g.lineStyle(2, THEME.cardBackHi, alpha * 0.5);
g.strokeRoundedRect(x - CARD_W / 2 + 4, y - CARD_H / 2 + 4, CARD_W - 8, CARD_H - 8, CARD_R - 2);
return g;
}
// ── Action bar ─────────────────────────────────────────────────────────────
buildActionBar() {
this.statusText = this.add.text(CX, 1000, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: THEME.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.knockBtn = new Button(this, CX - 80, PLAYER_HAND_Y - CARD_H / 2 - 34, 'Knock', () => this.onKnock(), {
width: 130, height: 46, fontSize: 20,
}).setDepth(D.ui).setVisible(false);
this.ginBtn = new Button(this, CX + 80, 1008, 'Gin!', () => this.onGin(), {
width: 130, height: 46, fontSize: 20, variant: 'accent',
}).setDepth(D.ui).setVisible(false);
this.layoffDoneBtn = new Button(this, CX, 1008, 'Done Laying Off', () => this.onLayoffDone(), {
width: 200, height: 46, fontSize: 18,
}).setDepth(D.ui).setVisible(false);
new Button(this, GAME_WIDTH - 80, 1054, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 130, height: 40, fontSize: 16,
}).setDepth(D.ui);
}
// ── Sort buttons ───────────────────────────────────────────────────────────
buildSortButtons() {
new Button(this, 80, 1008, 'By Suit', () => this.sortHandBySuit(), {
variant: 'ghost', width: 110, height: 40, fontSize: 16,
}).setDepth(D.ui);
new Button(this, 200, 1008, 'By Rank', () => this.sortHandByRank(), {
variant: 'ghost', width: 110, height: 40, fontSize: 16,
}).setDepth(D.ui);
}
sortHandBySuit() {
this.logic.players[0].hand = sortBySuit(this.logic.players[0].hand);
this.renderHand(0);
this.updateActionButtons();
}
sortHandByRank() {
this.logic.players[0].hand = sortByRank(this.logic.players[0].hand);
this.renderHand(0);
this.updateActionButtons();
}
// ── Drag-to-reorder (adapted from Phase10Game.js) ─────────────────────────
setupDragHandlers() {
this.input.on('pointermove', (pointer) => {
if (this.potentialDrag && !this.dragState) {
const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY;
if (Math.sqrt(dx * dx + dy * dy) >= 8) {
const pd = this.potentialDrag;
this.potentialDrag = null;
this.startCardDrag(pd.handIdx, pd.offsetX, pd.offsetY);
}
}
if (this.dragState) this.updateCardDrag(pointer);
});
this.input.on('pointerup', () => {
if (this.potentialDrag) {
const idx = this.potentialDrag.handIdx;
this.potentialDrag = null;
this.onHandClick(idx);
return;
}
if (this.dragState) this.endCardDrag();
});
}
onHandPointerDown(handIdx, pointer) {
if (this.humanMode !== 'discard') return;
if (this.dragState) return;
const card = this.humanCards[handIdx];
if (!card) return;
this.potentialDrag = {
handIdx, startX: pointer.x, startY: pointer.y,
offsetX: pointer.x - card.x, offsetY: pointer.y - card.y,
};
}
_humanHandX(n, i) {
return CX + (i - (n - 1) / 2) * HAND_SPREAD;
}
startCardDrag(handIdx, offsetX, offsetY) {
const card = this.humanCards[handIdx];
if (!card) return;
const n = this.logic.players[0].hand.length;
const insertIdx = Phaser.Math.Clamp(
Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1,
);
const shadow = this.add.graphics();
shadow.fillStyle(0x000000, 0.35);
shadow.fillEllipse(0, 0, CARD_W * 1.1, 26);
shadow.setPosition(card.x + 5, card.y + 14).setDepth(D.card + 9);
const slotIndicator = this.add.graphics();
slotIndicator.lineStyle(3, 0x22cc66, 0.8);
slotIndicator.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10);
slotIndicator.fillStyle(0x22cc66, 0.1);
slotIndicator.fillRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10);
slotIndicator.setPosition(this._humanHandX(n, insertIdx), PLAYER_HAND_Y).setDepth(D.card - 1);
this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx, dropTarget: null };
card.setDepth(D.card + 10);
this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' });
}
updateCardDrag(pointer) {
const ds = this.dragState;
const card = this.humanCards[ds.cardIdx];
const n = this.logic.players[0].hand.length;
card.x = pointer.x - ds.offsetX;
card.y = pointer.y - ds.offsetY;
ds.shadow.setPosition(card.x + 5, card.y + 14);
const tiltRad = Phaser.Math.Clamp((pointer.x - ds.prevX) * 0.008, -0.17, 0.17);
card.setRotation(tiltRad);
ds.prevX = pointer.x;
const newDrop = this._getDropTarget(card.x, card.y);
if (newDrop !== ds.dropTarget) {
ds.dropTarget = newDrop;
if (newDrop) {
this.tweens.killTweensOf(ds.slotIndicator);
ds.slotIndicator.setAlpha(0);
this._settleNonDraggedCards();
} else {
ds.slotIndicator.setAlpha(1);
this._updateNonDraggedCards();
}
}
if (!newDrop) {
const newInsert = Phaser.Math.Clamp(
Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1,
);
if (newInsert !== ds.insertIdx) {
ds.insertIdx = newInsert;
this.tweens.killTweensOf(ds.slotIndicator);
this.tweens.add({ targets: ds.slotIndicator, x: this._humanHandX(n, newInsert), duration: 100, ease: 'Cubic.easeOut' });
this._updateNonDraggedCards();
}
}
}
_getDropTarget(x, y) {
if (this.humanMode !== 'discard') return null;
const { discard } = this._pilePos;
if (Math.abs(x - discard.x) < 80 && Math.abs(y - discard.y) < 100) return 'discard';
return null;
}
_updateNonDraggedCards() {
const ds = this.dragState;
const n = this.humanCards.length;
for (let j = 0; j < n; j++) {
if (j === ds.cardIdx) continue;
const k = j < ds.cardIdx ? j : j - 1;
const finalPos = k < ds.insertIdx ? k : k + 1;
const targetX = this._humanHandX(n, finalPos);
const distFromGap = Math.abs(finalPos - ds.insertIdx);
const leanDir = finalPos < ds.insertIdx ? -1 : 1;
const targetRot = leanDir * Math.max(0, 2 - distFromGap) * 0.04;
const targetScale = distFromGap <= 1 ? 0.95 : 1.0;
this.tweens.killTweensOf(this.humanCards[j]);
this.tweens.add({ targets: this.humanCards[j], x: targetX, rotation: targetRot, scaleX: targetScale, scaleY: targetScale, duration: 100, ease: 'Cubic.easeOut' });
}
}
_settleNonDraggedCards() {
const ds = this.dragState;
const n = this.humanCards.length;
let k = 0;
for (let j = 0; j < n; j++) {
if (j === ds.cardIdx) continue;
this.tweens.killTweensOf(this.humanCards[j]);
this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n, k), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' });
k++;
}
}
endCardDrag() {
const ds = this.dragState;
const card = this.humanCards[ds.cardIdx];
this.dragState = null;
ds.shadow.destroy();
ds.slotIndicator.destroy();
if (ds.dropTarget === 'discard') {
// Discard this card
const n = this.humanCards.length;
this._settleNonDraggedCardsExcept(ds.cardIdx);
const { discard } = this._pilePos;
this.tweens.killTweensOf(card);
this.tweens.add({
targets: card, x: discard.x, y: discard.y, rotation: 0, scaleX: 1, scaleY: 1,
duration: 180, ease: 'Cubic.easeOut',
onComplete: () => { card.destroy(); this.commitDiscard(ds.cardIdx); },
});
return;
}
// Reorder
const n = this.logic.players[0].hand.length;
const finalIdx = Phaser.Math.Clamp(
Math.round((card.x - (CX - (n - 1) / 2 * HAND_SPREAD)) / HAND_SPREAD), 0, n - 1,
);
this.tweens.killTweensOf(card);
this.tweens.add({ targets: card, x: this._humanHandX(n, finalIdx), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
for (let j = 0; j < n; j++) {
if (j === ds.cardIdx) continue;
const k = j < ds.cardIdx ? j : j - 1;
const finalPos = k < finalIdx ? k : k + 1;
this.tweens.killTweensOf(this.humanCards[j]);
this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n, finalPos), rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' });
}
const hand = this.logic.players[0].hand;
const [moved] = hand.splice(ds.cardIdx, 1);
hand.splice(finalIdx, 0, moved);
const [movedObj] = this.humanCards.splice(ds.cardIdx, 1);
this.humanCards.splice(finalIdx, 0, movedObj);
card.setDepth(D.card);
this.time.delayedCall(250, () => {
this.updateActionButtons();
this._applyMeldGlows();
});
}
_settleNonDraggedCardsExcept(exceptIdx) {
const n = this.humanCards.length;
let k = 0;
for (let j = 0; j < n; j++) {
if (j === exceptIdx) continue;
this.tweens.killTweensOf(this.humanCards[j]);
this.tweens.add({ targets: this.humanCards[j], x: this._humanHandX(n - 1, k), y: PLAYER_HAND_Y, rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' });
k++;
}
}
// ── Card rendering ─────────────────────────────────────────────────────────
drawFace(container, card, faceUp) {
const x = -CARD_W / 2, y = -CARD_H / 2;
const g = this.add.graphics();
if (!faceUp) {
g.fillStyle(THEME.cardBack, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(2.5, THEME.cardBackHi, 0.6); g.strokeRoundedRect(x + 5, y + 5, CARD_W - 10, CARD_H - 10, CARD_R - 2);
g.lineStyle(1, THEME.cardBackHi, 0.25); g.strokeRoundedRect(x + 9, y + 9, CARD_W - 18, CARD_H - 18, CARD_R - 3);
container.add(g);
container.add(this.add.text(0, 0, '♦', { fontFamily: 'serif', fontSize: '32px', color: '#7050a0' }).setOrigin(0.5).setAlpha(0.4));
return;
}
g.fillStyle(THEME.cardFace, 1); g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(1.5, 0xc8a060, 0.4); g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
const col = card.isRed ? SUIT_RED : SUIT_BLK;
container.add(this.add.text(x + 7, y + 4, card.label, { fontFamily: 'Righteous', fontSize: '21px', color: col }));
container.add(this.add.text(x + 8, y + 29, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '19px', color: col }));
container.add(this.add.text(0, 4, card.suitSymbol, { fontFamily: 'sans-serif', fontSize: '40px', color: col }).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 4, card.label, { fontFamily: 'Righteous', fontSize: '21px', color: col }).setOrigin(1, 1));
}
makeCard(card, x, y, { faceUp = true, depth = D.card } = {}) {
const c = this.add.container(x, y).setDepth(depth);
c.cardRef = card;
this.drawFace(c, card, faceUp);
return c;
}
setCardInteractive(container, handler) {
container.setSize(CARD_W, CARD_H);
container.setInteractive({ useHandCursor: true });
container.on('pointerdown', handler);
}
// ── Round lifecycle ────────────────────────────────────────────────────────
startRound() {
this.busy = false;
this.humanMode = 'idle';
this.selectedCard = null;
this.layoffSelected = null;
this.knockBtn.setVisible(false);
this.ginBtn.setVisible(false);
this.layoffDoneBtn.setVisible(false);
this.clearReveal();
playSound(this, SFX.CARD_SHUFFLE);
this.refreshScores();
this.renderAll();
this.advance();
}
clearCards() {
for (const c of this.humanCards) c.destroy();
this.humanCards = [];
for (const arr of this.aiCardObjs) { for (const c of arr) c?.destroy(); }
this.aiCardObjs = Array.from({ length: this.nPlayers }, () => []);
this.clearPileObjs();
}
clearPileObjs() {
this.pileObjs.stock?.destroy();
this.pileObjs.discard?.destroy();
this.pileObjs.stock = null;
this.pileObjs.discard = null;
}
clearReveal() {
for (const o of this.revealObjs) o?.destroy();
this.revealObjs = [];
}
renderAll() {
this.clearCards();
this.renderPiles();
for (let s = 0; s < this.nPlayers; s++) this.renderHand(s);
}
renderPiles() {
const { stock, discard } = this._pilePos;
if (this.logic.stockCount > 0) {
// Must be a Container (not raw Graphics) so setSize/setInteractive work correctly
this.pileObjs.stock = this.makeCard(null, stock.x, stock.y, { faceUp: false, depth: D.pile });
}
this.stockCountText?.setText(`${this.logic.stockCount}`);
if (this.logic.discardTop) {
const c = this.makeCard(this.logic.discardTop, discard.x, discard.y, { depth: D.pile });
this.pileObjs.discard = c;
}
}
renderHand(seat) {
const player = this.logic.players[seat];
const sp = this._seatPos[seat];
const faceUp = seat === 0;
const spread = faceUp ? HAND_SPREAD : AI_SPREAD;
const n = player.hand.length;
if (faceUp) {
this.humanCards = [];
for (let i = 0; i < n; i++) {
const x = this._humanHandX(n, i);
const c = this.makeCard(player.hand[i], x, PLAYER_HAND_Y);
c.baseY = PLAYER_HAND_Y;
this.humanCards.push(c);
}
this._applyMeldGlows();
} else {
this.aiCardObjs[seat] = [];
for (let i = 0; i < n; i++) {
let x = sp.x, y = sp.y;
if (sp.axis === 'h') x = sp.x + (i - (n - 1) / 2) * spread;
else y = sp.y + (i - (n - 1) / 2) * spread;
const c = this.makeCard(null, x, y, { faceUp: false, depth: D.card + i });
this.aiCardObjs[seat].push(c);
}
// Name + count label
const name = seat === 0 ? 'You' : (this.opponents[seat - 1]?.name?.split(' ')[0] ?? `AI ${seat}`);
const lbl = this.add.text(sp.nameX, sp.nameY, `${name} (${n})`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: THEME.mutedHex,
}).setOrigin(...sp.nameAnchor).setDepth(D.ui);
this.aiCardObjs[seat].push(lbl);
}
}
_applyMeldGlows() {
// Remove old glows
this.humanCards.forEach(c => {
const old = c.getByName?.('glow');
if (old) { c.remove(old, true); }
});
const hand = this.logic.players[0].hand;
const melds = allCandidateMelds(hand);
const inMeld = new Set(melds.flat().map(c => c.key));
this.humanCards.forEach((container, i) => {
const card = hand[i];
if (!card || !inMeld.has(card.key)) return;
const glow = this.add.graphics();
glow.lineStyle(3, THEME.meldGlow, 0.7);
glow.strokeRoundedRect(-CARD_W / 2 - 3, -CARD_H / 2 - 3, CARD_W + 6, CARD_H + 6, CARD_R + 2);
glow.setName('glow');
container.addAt(glow, 0);
});
}
// ── Turn flow ──────────────────────────────────────────────────────────────
advance() {
if (this.busy) return;
const p = this.logic.phase;
if (p === 'gameover') { this.showGameOver(this.logic.winner); return; }
if (p === 'roundover') { this.showRoundScores(); return; }
const cur = this.logic.currentPlayer;
this.setTurnRing(cur);
if (cur === 0) {
this.beginHumanTurn();
} else {
this.busy = true;
this.runAiTurn(cur);
}
}
// ── Human turn ─────────────────────────────────────────────────────────────
beginHumanTurn() {
if (this.logic.phase === 'draw') {
this.humanMode = 'draw';
this.setStatus('Draw from the stock pile or take the discard');
this.knockBtn.setVisible(false);
this.ginBtn.setVisible(false);
// Make stock clickable
if (this.pileObjs.stock) {
this.pileObjs.stock.setSize(CARD_W, CARD_H);
this.pileObjs.stock.setInteractive({ useHandCursor: true });
this.pileObjs.stock.on('pointerdown', () => this.onDrawStock());
this.pileObjs.stock.on('pointerover', () => { this.pileObjs.stock?.setAlpha(0.8); });
this.pileObjs.stock.on('pointerout', () => { this.pileObjs.stock?.setAlpha(1); });
}
// Make discard pile clickable
if (this.pileObjs.discard) {
this.pileObjs.discard.setSize(CARD_W, CARD_H);
this.pileObjs.discard.setInteractive({ useHandCursor: true });
this.pileObjs.discard.on('pointerdown', () => this.onDrawDiscard());
this.pileObjs.discard.on('pointerover', () => { this.pileObjs.discard?.setAlpha(0.8); });
this.pileObjs.discard.on('pointerout', () => { this.pileObjs.discard?.setAlpha(1); });
}
} else if (this.logic.phase === 'discard') {
this.humanMode = 'discard';
this.setStatus('Select a card to discard, or Knock / Gin');
this.updateActionButtons();
this._setupHandInteraction();
}
}
_setupHandInteraction() {
const hand = this.logic.players[0].hand;
this.humanCards.forEach((c, i) => {
c.setSize(CARD_W, CARD_H);
c.setInteractive({ useHandCursor: true });
c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer));
c.on('pointerover', () => { if (!this.dragState) c.setAlpha(0.85); });
c.on('pointerout', () => { c.setAlpha(1); });
});
}
updateActionButtons() {
if (this.humanMode !== 'discard') {
this.knockBtn.setVisible(false);
this.ginBtn.setVisible(false);
return;
}
const hand = this.logic.players[0].hand;
let canKn = false, canGin = false;
for (const c of hand) {
const rest = hand.filter(x => x.key !== c.key);
const { deadwood } = bestMeldGroups(rest);
if (deadwood === 0) { canGin = true; canKn = true; break; }
if (deadwood <= MAX_DEADWOOD_TO_KNOCK) canKn = true;
}
this.knockBtn.setVisible(canKn && !canGin);
this.ginBtn.setVisible(canGin);
}
onHandClick(handIdx) {
if (this.humanMode !== 'discard') return;
if (this.selectedCard) {
// Deselect previous
const prev = this.humanCards.find(c => c.cardRef?.key === this.selectedCard);
if (prev) this.tweens.add({ targets: prev, y: PLAYER_HAND_Y, duration: 120 });
}
const card = this.logic.players[0].hand[handIdx];
if (this.selectedCard === card.key) {
this.selectedCard = null;
this.setStatus('Select a card to discard, or Knock / Gin');
} else {
this.selectedCard = card.key;
const obj = this.humanCards[handIdx];
this.tweens.add({ targets: obj, y: PLAYER_HAND_Y - 24, duration: 120 });
this.setStatus('Click the discard pile to discard this card');
}
}
onDrawStock() {
if (this.humanMode !== 'draw') return;
if (!this.logic.drawStock(0)) return;
playSound(this, SFX.CARD_DEAL);
this.renderAll();
this.beginHumanTurn();
}
onDrawDiscard() {
if (this.humanMode !== 'draw') return;
if (!this.logic.drawDiscard(0)) return;
playSound(this, SFX.CARD_DEAL);
this.renderAll();
this.beginHumanTurn();
}
commitDiscard(handIdx) {
const hand = this.logic.players[0].hand;
const key = hand[handIdx]?.key;
if (!key) return;
if (!this.logic.discardCard(0, key)) return;
playSound(this, SFX.CARD_PLACE);
this.humanMode = 'idle';
this.selectedCard = null;
this.humanCards.splice(handIdx, 1);
this.renderPiles();
this.renderHand(0);
this.advance();
}
onKnock() {
if (this.humanMode !== 'discard') return;
const hand = this.logic.players[0].hand;
let bestDiscard = null, bestDW = Infinity, bestMelds = [];
for (const c of hand) {
const rest = hand.filter(x => x.key !== c.key);
const { deadwood, melds } = bestMeldGroups(rest);
if (deadwood < bestDW) { bestDW = deadwood; bestDiscard = c; bestMelds = melds; }
}
if (!bestDiscard || bestDW > MAX_DEADWOOD_TO_KNOCK) return;
if (!this.logic.knock(0, bestDiscard.key, bestMelds)) return;
playSound(this, SFX.CARD_PLACE);
this.humanMode = 'idle';
this.renderAll();
this.doKnockReveal(false);
}
onGin() {
if (this.humanMode !== 'discard') return;
const hand = this.logic.players[0].hand;
let bestDiscard = null, bestMelds = [];
for (const c of hand) {
const rest = hand.filter(x => x.key !== c.key);
const { deadwood, melds } = bestMeldGroups(rest);
if (deadwood === 0) { bestDiscard = c; bestMelds = melds; break; }
}
if (!bestDiscard) return;
if (!this.logic.gin(0, bestDiscard.key, bestMelds)) return;
playSound(this, SFX.CARD_SHOW);
this.humanMode = 'idle';
this.renderAll();
this.doKnockReveal(true);
}
// ── AI turn ────────────────────────────────────────────────────────────────
async runAiTurn(seat) {
const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r));
const skill = this.opponents[seat - 1]?.skill ?? 3;
const hand = this.logic.players[seat].hand;
const discardTop = this.logic.discardTop;
// Draw
const src = chooseDrawSource(hand, discardTop, skill);
await delay(thinkDelay(skill));
if (src === 'discard' && discardTop) {
this.logic.drawDiscard(seat);
} else {
this.logic.drawStock(seat);
}
playSound(this, SFX.CARD_DEAL);
this.renderHand(seat);
this.renderPiles();
await delay(thinkDelay(skill) * 0.6);
// Decide knock/gin/discard
const handNow = this.logic.players[seat].hand;
const { melds, deadwood } = bestMeldGroups(handNow);
if (deadwood === 0) {
// Gin — find best discard
let bestDiscard = null, bestMelds2 = [];
for (const c of handNow) {
const rest = handNow.filter(x => x.key !== c.key);
const { deadwood: dw2, melds: m2 } = bestMeldGroups(rest);
if (dw2 === 0) { bestDiscard = c; bestMelds2 = m2; break; }
}
if (bestDiscard && this.logic.gin(seat, bestDiscard.key, bestMelds2)) {
playSound(this, SFX.CARD_SHOW);
this.busy = false;
this.renderAll();
this.doKnockReveal(true);
return;
}
}
if (shouldKnock(handNow, skill)) {
let bestDiscard = null, bestDW = Infinity, bestMelds2 = [];
for (const c of handNow) {
const rest = handNow.filter(x => x.key !== c.key);
const { deadwood: dw2, melds: m2 } = bestMeldGroups(rest);
if (dw2 < bestDW) { bestDW = dw2; bestDiscard = c; bestMelds2 = m2; }
}
if (bestDiscard && bestDW <= MAX_DEADWOOD_TO_KNOCK && this.logic.knock(seat, bestDiscard.key, bestMelds2)) {
playSound(this, SFX.CARD_PLACE);
this.busy = false;
this.renderAll();
this.doKnockReveal(false);
return;
}
}
// Normal discard
const discardCard = chooseDiscard(handNow, skill);
if (discardCard) {
this.logic.discardCard(seat, discardCard.key);
playSound(this, SFX.CARD_PLACE);
}
this.renderAll();
this.busy = false;
this.advance();
}
// ── Knock / Gin reveal ─────────────────────────────────────────────────────
async doKnockReveal(isGin) {
const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r));
this.busy = true;
const k = this.logic.knocker;
const kMelds = this.logic.knockerMelds;
const kName = k === 0 ? 'You' : (this.opponents[k - 1]?.name?.split(' ')[0] ?? `AI ${k}`);
const verb = isGin ? 'Gin!' : 'Knock!';
this.setStatus(`${kName} called ${verb}`);
playSound(this, SFX.CARD_SHOW);
await delay(600);
// Reveal knocker's melds in center
this.clearReveal();
this._renderRevealZone(k, kMelds, isGin);
await delay(1200);
if (isGin) {
// No layoffs — go straight to scoring
this.showRoundScores();
return;
}
// Layoff phase: each opponent of knocker in order
const order = [];
let s = (k + 1) % this.nPlayers;
while (s !== k) { order.push(s); s = (s + 1) % this.nPlayers; }
for (const seat of order) {
if (seat === 0) {
await this.runHumanLayoff();
} else {
await this.runAiLayoffAuto(seat);
}
}
this.showRoundScores();
}
_renderRevealZone(knockerSeat, meldGroups, isGin) {
const revealY = 420;
const revealStartX = CX - 400;
let cx = revealStartX;
// Meld groups
for (let mi = 0; mi < meldGroups.length; mi++) {
const meld = meldGroups[mi];
const groupLabel = this.add.text(cx + meld.length * 44 / 2, revealY - 70,
mi === 0 ? 'MELDS' : '', { fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex })
.setOrigin(0.5, 0).setDepth(D.overlay);
this.revealObjs.push(groupLabel);
for (let j = 0; j < meld.length; j++) {
const c = this.makeCard(meld[j], cx + j * 44, revealY, { depth: D.overlay + j });
c.setScale(0.55);
// Green outline glow for meld cards
const glow = this.add.graphics();
glow.lineStyle(3, THEME.meldGlow, 0.8);
glow.strokeRoundedRect(-CARD_W / 2 * 0.55 - 3, -CARD_H / 2 * 0.55 - 3, CARD_W * 0.55 + 6, CARD_H * 0.55 + 6, 6);
c.addAt(glow, 0);
this.revealObjs.push(c);
}
cx += meld.length * 44 + 20;
}
// Deadwood cards from knocker's hand
const kHand = this.logic.players[knockerSeat].hand;
const meldedKeys = new Set(meldGroups.flat().map(c => c.key));
const deadwood = kHand.filter(c => !meldedKeys.has(c.key));
if (deadwood.length > 0) {
const dwLabel = this.add.text(cx, revealY - 70, isGin ? '' : 'DEADWOOD',
{ fontFamily: '"Julius Sans One"', fontSize: '13px', color: THEME.mutedHex })
.setOrigin(0, 0).setDepth(D.overlay);
this.revealObjs.push(dwLabel);
for (let j = 0; j < deadwood.length; j++) {
const c = this.makeCard(deadwood[j], cx + j * 44, revealY, { depth: D.overlay + j });
c.setScale(0.55);
const dwVal = this.add.text(cx + j * 44, revealY + 36, String(ginDeadwoodValue(deadwood[j])),
{ fontFamily: 'Righteous', fontSize: '11px', color: THEME.goldHex })
.setOrigin(0.5).setDepth(D.overlay + 10);
this.revealObjs.push(c, dwVal);
}
}
}
// ── Human layoff ───────────────────────────────────────────────────────────
runHumanLayoff() {
return new Promise((resolve) => {
this._layoffResolve = resolve;
this.humanMode = 'layoff';
this.layoffSelected = null;
this.setStatus('Lay off cards on the knocker\'s melds, then click Done');
this.knockBtn.setVisible(false);
this.ginBtn.setVisible(false);
this.layoffDoneBtn.setVisible(true);
this._setupLayoffHandInteraction();
});
}
_setupLayoffHandInteraction() {
this.humanCards.forEach((c, i) => {
c.setSize(CARD_W, CARD_H);
c.setInteractive({ useHandCursor: true });
c.on('pointerdown', () => this.onLayoffCardClick(i));
});
this._refreshLayoffHighlights();
}
onLayoffCardClick(handIdx) {
if (this.humanMode !== 'layoff') return;
const card = this.logic.players[0].hand[handIdx];
// Check which melds it can go on
const validMelds = [];
for (let mi = 0; mi < this.logic.knockerMelds.length; mi++) {
if (canLayoff(card, this.logic.knockerMelds[mi])) validMelds.push(mi);
}
if (validMelds.length === 0) return;
// Auto-apply the layoff (take first valid meld)
this.logic.layoff(0, [{ cardKey: card.key, meldIdx: validMelds[0] }]);
playSound(this, SFX.CARD_PLACE);
this.renderHand(0);
this._setupLayoffHandInteraction();
this._refreshLayoffHighlights();
}
_refreshLayoffHighlights() {
const hand = this.logic.players[0].hand;
this.humanCards.forEach((c, i) => {
if (!hand[i]) return;
const card = hand[i];
const canLay = this.logic.knockerMelds.some(m => canLayoff(card, m));
c.setAlpha(canLay ? 1.0 : 0.55);
});
}
onLayoffDone() {
if (this.humanMode !== 'layoff') return;
this.logic.passLayoff(0);
this.layoffDoneBtn.setVisible(false);
this.humanMode = 'idle';
const resolve = this._layoffResolve;
this._layoffResolve = null;
resolve?.();
}
// ── AI layoff (auto) ───────────────────────────────────────────────────────
async runAiLayoffAuto(seat) {
const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r));
await delay(700);
const skill = this.opponents[seat - 1]?.skill ?? 3;
const hand = this.logic.players[seat].hand;
const layoffs = findLayoffs(hand, this.logic.knockerMelds, skill);
if (layoffs.length > 0) {
this.logic.layoff(seat, layoffs);
playSound(this, SFX.CARD_PLACE);
}
this.logic.passLayoff(seat);
await delay(400);
}
// ── Status text ────────────────────────────────────────────────────────────
setStatus(text) {
this.statusText?.setText(text);
}
// ── Round scores overlay ───────────────────────────────────────────────────
async showRoundScores() {
this.busy = true;
const delay = (ms) => new Promise(r => this.time.delayedCall(ms, r));
const ovBg = this.add.graphics().setDepth(D.overlay);
ovBg.fillStyle(0x000000, 0.62);
ovBg.fillRoundedRect(CX - 320, CY - 220, 640, 440, 22);
ovBg.lineStyle(2, THEME.gold, 0.6);
ovBg.strokeRoundedRect(CX - 320, CY - 220, 640, 440, 22);
const title = this.add.text(CX, CY - 185, 'Round Results', {
fontFamily: 'Righteous', fontSize: '32px', color: THEME.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const k = this.logic.knocker;
const kName = k === 0 ? 'You' : (this.opponents[k - 1]?.name?.split(' ')[0] ?? `AI ${k}`);
const scores = this.logic.roundScores;
const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')];
const textObjs = [title];
for (let s = 0; s < this.nPlayers; s++) {
const ry = CY - 120 + s * 68;
const delta = scores[s];
const col = delta > 0 ? '#55dd88' : delta < 0 ? '#ff6666' : THEME.ivoryHex;
const t1 = this.add.text(CX - 200, ry, names[s], {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: THEME.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.overlayUI);
const t2 = this.add.text(CX + 200, ry, delta > 0 ? `+${delta}` : String(delta), {
fontFamily: 'Righteous', fontSize: '28px', color: col,
}).setOrigin(1, 0.5).setDepth(D.overlayUI);
textObjs.push(t1, t2);
}
await delay(3800);
for (const o of textObjs) o?.destroy();
ovBg.destroy();
if (this.logic.phase === 'gameover') {
this.showGameOver(this.logic.winner);
} else {
this.clearReveal();
this.logic.newRound();
this.startRound();
}
}
// ── Game over ──────────────────────────────────────────────────────────────
showGameOver(winnerSeat) {
const names = ['You', ...this.opponents.map(o => o.name?.split(' ')[0] ?? 'AI')];
const winner = names[winnerSeat ?? 0];
const youWon = winnerSeat === 0;
const ovBg = this.add.graphics().setDepth(D.overlay);
ovBg.fillStyle(0x000000, 0.75);
ovBg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
this.add.text(CX, CY - 100, youWon ? 'You Win!' : `${winner} Wins!`, {
fontFamily: 'Righteous', fontSize: '72px', color: youWon ? '#55dd88' : THEME.ivoryHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const finalScores = this.logic.players.map((p, s) => `${names[s]}: ${p.score}`).join(' ');
this.add.text(CX, CY + 20, finalScores, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: THEME.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
new Button(this, CX - 100, CY + 130, 'Play Again', () => {
this.logic.newGame();
this.clearReveal();
this.startRound();
}, { width: 180, height: 56, fontSize: 22 }).setDepth(D.overlayUI);
new Button(this, CX + 100, CY + 130, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 180, height: 56, fontSize: 22,
}).setDepth(D.overlayUI);
}
// ── Utility ────────────────────────────────────────────────────────────────
delay(ms) {
return new Promise(r => this.time.delayedCall(ms, r));
}
}