feat(games): implement configurable match size for Go Fish and enhance Nerts UI

- Go Fish: Add support for 2-card and 4-card match variants.
  - Update `GoFishGame` and `GoFishLogic` to use a configurable `matchSize` (default 4).
  - Add UI toggle in `OpponentSelectScene` to select match variant.
  - Update labels and logic to reflect 'Books' (4-card) vs 'Pairs' (2-card).
- Nerts: Improve AI and UI responsiveness.
  - Add 'Last Move' timer panel with Shuffle (60s) and Resign (90s) buttons.
  - Implement foundation cooldowns to prevent rapid-fire AI moves.
  - Add dynamic layout for foundations and opponent panels based on player count.
  - Show opponent Nerts, stock, and waste cards in opponent panels.
  - Add `reshuffleAllStocks` utility in `NertsLogic`.
This commit is contained in:
Brian Fertig 2026-05-24 11:02:06 -06:00
parent 7e022e8e9b
commit 307f3b7123
5 changed files with 261 additions and 32 deletions

View File

@ -125,6 +125,7 @@ export default class GoFishGame extends Phaser.Scene {
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.matchVariant = data.matchVariant ?? 4;
this.gs = null;
this.animating = false;
@ -215,7 +216,8 @@ export default class GoFishGame extends Phaser.Scene {
bg.fillRoundedRect(-90, -22, 180, 44, 10);
bg.lineStyle(2, COLORS.accent, 1);
bg.strokeRoundedRect(-90, -22, 180, 44, 10);
const label = this.add.text(-78, 0, 'PAIRS', {
const chipLabel = this.matchVariant === 4 ? 'BOOKS' : 'PAIRS';
const label = this.add.text(-78, 0, chipLabel, {
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
}).setOrigin(0, 0.5);
const count = this.add.text(78, 0, '0', {
@ -280,6 +282,14 @@ export default class GoFishGame extends Phaser.Scene {
buildMatchesPanel() {
const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const maxPerRank = this.matchVariant === 4 ? 1 : 2;
const totalPossible = 13 * maxPerRank;
const panelTitle = this.matchVariant === 4 ? 'Books so far' : 'Matches so far';
const remainingPrefix = this.matchVariant === 4 ? 'Remaining Books: ' : 'Remaining Matches: ';
this._matchMaxPerRank = maxPerRank;
this._matchTotalPossible = totalPossible;
this._matchRemainingPrefix = remainingPrefix;
const px = 10, py = 10;
const padX = 9, padY = 8;
const titleH = 18;
@ -293,7 +303,7 @@ export default class GoFishGame extends Phaser.Scene {
bg.lineStyle(1, COLORS.accent, 0.35);
bg.strokeRoundedRect(px, py, panelW, panelH, 7);
this.add.text(px + padX, py + padY, 'Matches so far', {
this.add.text(px + padX, py + padY, panelTitle, {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex,
}).setDepth(D.ui);
@ -302,7 +312,7 @@ export default class GoFishGame extends Phaser.Scene {
const t = this.add.text(
px + padX,
py + padY + titleH + 5 + i * rowH,
`${label.padEnd(2)} = 0 / 2`,
`${label.padEnd(2)} = 0 / ${maxPerRank}`,
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex }
).setDepth(D.ui);
return { rank, text: t };
@ -311,7 +321,7 @@ export default class GoFishGame extends Phaser.Scene {
this._matchRemainingText = this.add.text(
px + padX,
py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5,
'Remaining Matches: 26',
`${remainingPrefix}${totalPossible}`,
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex }
).setDepth(D.ui);
}
@ -329,10 +339,10 @@ export default class GoFishGame extends Phaser.Scene {
const scored = tally.get(rank) ?? 0;
totalScored += scored;
const label = rank === 'T' ? '10' : rank;
text.setText(`${label.padEnd(2)} = ${scored} / 2`);
text.setColor(scored === 2 ? COLORS.mutedHex : COLORS.textHex);
text.setText(`${label.padEnd(2)} = ${scored} / ${this._matchMaxPerRank}`);
text.setColor(scored === this._matchMaxPerRank ? COLORS.mutedHex : COLORS.textHex);
}
this._matchRemainingText.setText(`Remaining Matches: ${26 - totalScored}`);
this._matchRemainingText.setText(`${this._matchRemainingPrefix}${this._matchTotalPossible - totalScored}`);
}
// ── Match lifecycle ────────────────────────────────────────────────────────
@ -345,7 +355,7 @@ export default class GoFishGame extends Phaser.Scene {
this.hideBanner();
const playerCount = this.slotForSeat.length;
const finalState = createInitialState({ playerCount });
const finalState = createInitialState({ playerCount, matchSize: this.matchVariant });
this.aiMemory = [];
for (let s = 0; s < playerCount; s++) {
this.aiMemory[s] = createMemory(playerCount);
@ -436,10 +446,11 @@ export default class GoFishGame extends Phaser.Scene {
const { seat, pairedCards } = pairs[idx];
const name = this.opponentName(seat);
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
const pairCount = pairedCards.length / 2;
const label = pairCount === 1
? `${name} dealt a starting pair of ${rank}s!`
: `${name} dealt ${pairCount} starting pairs!`;
const unit = this.matchVariant === 4 ? 'book' : 'pair';
const bookCount = pairedCards.length / this.matchVariant;
const label = bookCount === 1
? `${name} dealt a starting ${unit} of ${rank}s!`
: `${name} dealt ${bookCount} starting ${unit}s!`;
this.showBanner(label);
this.animatePairedCards(seat, pairedCards, () => {
this.hideBanner();
@ -1300,7 +1311,8 @@ export default class GoFishGame extends Phaser.Scene {
const target = this.opponentName(last.targetSeat);
const rank = last.rank === 'T' ? '10' : last.rank;
if (last.result === 'catch') {
const tail = last.newPairs > 0 ? ` +${last.newPairs} pair${last.newPairs > 1 ? 's' : ''}!` : '';
const unit = this.matchVariant === 4 ? 'book' : 'pair';
const tail = last.newPairs > 0 ? ` +${last.newPairs} ${unit}${last.newPairs > 1 ? 's' : ''}!` : '';
return `${asker} asked ${target} for ${rank}s — caught ${last.cardsTransferred.length}!${tail}`;
}
if (last.result === 'lucky') {
@ -1342,9 +1354,10 @@ export default class GoFishGame extends Phaser.Scene {
.map((p) => ({ seat: p.seat, pairs: p.pairs }))
.sort((a, b) => b.pairs - a.pairs);
const winners = this.gs.winnerSeats.map((s) => this.opponentName(s)).join(', ');
const unit = this.matchVariant === 4 ? 'book' : 'pair';
const lines = [`Game over — winner: ${winners}`];
for (const r of rows) {
lines.push(`${this.opponentName(r.seat)}: ${r.pairs} pair${r.pairs === 1 ? '' : 's'}`);
lines.push(`${this.opponentName(r.seat)}: ${r.pairs} ${unit}${r.pairs === 1 ? '' : 's'}`);
}
playSound(this, SFX.CASINO_WIN);
new Modal(this, lines.join('\n'), {}).setDepth(D.modal);

View File

@ -74,10 +74,11 @@ export function cloneState(state) {
winnerSeats: state.winnerSeats.slice(),
seed: state.seed,
turnCount: state.turnCount,
matchSize: state.matchSize,
};
}
export function createInitialState({ playerCount = 4, seed } = {}) {
export function createInitialState({ playerCount = 4, matchSize = 4, seed } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Go Fish supports 2..4 players, got ${playerCount}`);
}
@ -104,6 +105,7 @@ export function createInitialState({ playerCount = 4, seed } = {}) {
seed: seed ?? null,
turnCount: 0,
initialDealPairs: [],
matchSize,
};
// Any starting pairs are scored immediately.
for (const p of state.players) {
@ -245,6 +247,7 @@ export function applyFishPick(state, cardId) {
* holds clones of each removed card for animation purposes.
*/
function collectPairs(player, state) {
const matchSize = state?.matchSize ?? 4;
let collected = 0;
const pairedCards = [];
while (true) {
@ -254,12 +257,12 @@ function collectPairs(player, state) {
const list = byRank.get(c.rank) ?? [];
list.push(c);
byRank.set(c.rank, list);
if (list.length >= 2) { foundRank = c.rank; break; }
if (list.length >= matchSize) { foundRank = c.rank; break; }
}
if (!foundRank) break;
const pair = byRank.get(foundRank);
pairedCards.push(cloneCard(pair[0]), cloneCard(pair[1]));
const idsToRemove = new Set([pair[0].id, pair[1].id]);
const group = byRank.get(foundRank).slice(0, matchSize);
pairedCards.push(...group.map(cloneCard));
const idsToRemove = new Set(group.map((c) => c.id));
player.hand = player.hand.filter((c) => !idsToRemove.has(c.id));
player.pairs += 1;
player.pairedRanks.push(foundRank);

View File

@ -20,6 +20,7 @@ import {
wasteTop,
workTop,
validRunFromIndex,
reshuffleAllStocks,
} from './NertsLogic.js';
import { chooseAction, nextThinkDelay } from './NertsAI.js';
@ -46,11 +47,12 @@ const FOUND_GAP_X = 18;
const FOUND_ROW_Y = [286, 286 + CARD_H + 24];
// ── Local tableau layout ─────────────────────────────────────────────────────
const NERTS_POS = { x: 340, y: 840 };
const WORK_TOP_Y = 540;
const WORK_X = [690, 870, 1050, 1230];
const STOCK_POS = { x: 1470, y: 840 };
const WASTE_POS = { x: 1600, y: 840 };
// x values are recomputed in buildFoundations() based on player count; NERTS x mirrors STOCK x
let NERTS_POS = { x: 340, y: 640 - CARD_H - 16 };
let WORK_TOP_Y = 540;
const WORK_X = [540, 720, 900, 1080];
let STOCK_POS = { x: 1470, y: 640 };
let WASTE_POS = { x: 1600, y: 640 };
const LOCAL_PORTRAIT = { x: 130, y: 820, r: 58 };
// ── Opponent panel layout ─────────────────────────────────────────────────────
@ -85,6 +87,13 @@ export default class NertsGame extends Phaser.Scene {
this.oppDynamic = []; // seat → { nertsText, scoreText }
this.opponentPortraits = [];
this.aiTimers = [];
this.foundationCooldowns = []; // idx → Phaser time when slot becomes AI-playable again
this.lastMoveSeconds = 0;
this.lastMoveTimer = null;
this.lastMoveCountText = null;
this.shuffleBtn = null;
this.resignBtn = null;
this.potentialDrag = null;
this.dragState = null;
@ -104,6 +113,7 @@ export default class NertsGame extends Phaser.Scene {
this.buildLocalArea();
this.buildOpponentPanels();
this.buildHUD();
this.buildLastMovePanel();
this.setupDragHandlers();
this.events.once('shutdown', () => this.stopAITimers());
@ -125,6 +135,19 @@ export default class NertsGame extends Phaser.Scene {
buildFoundations() {
const total = 4 * this.playerCount;
const countInRow0 = Math.min(FOUND_PER_ROW, total);
const rowW = countInRow0 * CARD_W + (countInRow0 - 1) * FOUND_GAP_X;
const PAD = 14;
STOCK_POS.x = CX + rowW / 2 + PAD + CARD_W / 2 - 150;
NERTS_POS.x = STOCK_POS.x;
WASTE_POS.x = STOCK_POS.x + CARD_W + FOUND_GAP_X;
const yOffset = this.opponents.length >= 2 ? 100 : 0;
WORK_TOP_Y = 540 + yOffset;
STOCK_POS.y = 640 + yOffset;
WASTE_POS.y = 640 + yOffset;
NERTS_POS.y = 640 - CARD_H - 16 + yOffset;
for (let idx = 0; idx < total; idx++) {
const row = Math.floor(idx / FOUND_PER_ROW);
const col = idx % FOUND_PER_ROW;
@ -209,7 +232,7 @@ export default class NertsGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
this.oppDynamic[seat] = { nertsText, scoreText };
this.oppDynamic[seat] = { nertsText, scoreText, panelPos: { x: pos.x, y: pos.y }, nertsCard: null, stockCard: null, wasteCards: [] };
}
}
@ -226,19 +249,83 @@ export default class NertsGame extends Phaser.Scene {
}
buildHUD() {
this.statusText = this.add.text(CX, 36, `Nerts — first to ${this.targetScore} points`, {
this.statusText = this.add.text(24, 36, `Nerts — first to ${this.targetScore} points`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
}).setOrigin(0, 0.5).setDepth(D.ui);
new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 50, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 130, height: 40, fontSize: 18 }).setDepth(D.ui);
}
buildLastMovePanel() {
this.add.rectangle(130, 460, 180, 200, 0x000000, 0.55).setDepth(D.ui).setOrigin(0.5);
this.add.text(130, 390, 'Last Move', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.lastMoveCountText = this.add.text(130, 440, '00', {
fontFamily: 'Righteous', fontSize: '64px', color: '#ffffff',
}).setOrigin(0.5).setDepth(D.ui);
this.add.text(130, 500, 'seconds', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.shuffleBtn = new Button(this, 130, 545, 'Shuffle Stock',
() => this.onShuffleStock(),
{ width: 160, height: 38, fontSize: 15 }
).setDepth(D.ui).setVisible(false);
this.resignBtn = new Button(this, 130, 591, 'Resign',
() => this.onResign(),
{ width: 160, height: 38, fontSize: 15, variant: 'ghost' }
).setDepth(D.ui).setVisible(false);
}
startLastMoveTimer() {
if (this.lastMoveTimer) { this.lastMoveTimer.remove(false); this.lastMoveTimer = null; }
this.lastMoveSeconds = 0;
this.lastMoveCountText?.setText('00');
this.shuffleBtn?.setVisible(false);
this.lastMoveTimer = this.time.addEvent({
delay: 1000, loop: true, callback: this.onLastMoveTick, callbackScope: this,
});
}
onLastMoveTick() {
if (this.roundEnding) return;
this.lastMoveSeconds += 1;
const s = this.lastMoveSeconds;
this.lastMoveCountText?.setText(String(s).padStart(2, '0'));
const color = s > 60 ? '#ff2222' : s > 45 ? '#ff8800' : s > 30 ? '#ffee00' : '#ffffff';
this.lastMoveCountText?.setColor(color);
if (s >= 60) this.shuffleBtn?.setVisible(true);
if (s >= 90) this.resignBtn?.setVisible(true);
}
resetLastMoveTimer() {
this.lastMoveSeconds = 0;
this.lastMoveCountText?.setText('00');
this.lastMoveCountText?.setColor('#ffffff');
this.shuffleBtn?.setVisible(false);
this.resignBtn?.setVisible(false);
}
onResign() {
if (!this.isPlayable()) return;
this.finishRound();
}
onShuffleStock() {
if (!this.isPlayable()) return;
reshuffleAllStocks(this.gs);
playSound(this, SFX.CARD_SHUFFLE);
this.resetLastMoveTimer();
this.renderAll();
}
// ── Round lifecycle ────────────────────────────────────────────────────────
startRound() {
this.roundEnding = false;
this.foundationCooldowns = [];
this.gs = createInitialState({
playerCount: this.playerCount,
targetScore: this.targetScore,
@ -247,6 +334,7 @@ export default class NertsGame extends Phaser.Scene {
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.startAITimers();
this.startLastMoveTimer();
}
startAITimers() {
@ -277,11 +365,18 @@ export default class NertsGame extends Phaser.Scene {
this.scheduleAITick(seat);
}
markFoundationCooldown(idx) {
this.foundationCooldowns[idx] = this.time.now + 2000;
}
applyAIAction(seat, action) {
if (action.kind === 'foundation') {
if (this.time.now < (this.foundationCooldowns[action.dest] ?? 0)) return;
const card = this.actionCard(seat, action);
const log = playToFoundation(this.gs, seat, action.source, action.dest);
if (log) {
this.markFoundationCooldown(action.dest);
this.resetLastMoveTimer();
const dest = this.foundationPos[action.dest];
const origin = this.oppPanelPos[seat];
if (card && dest && origin) this.spawnFly(card, origin.x, origin.y, dest.x, dest.y, seat);
@ -291,9 +386,11 @@ export default class NertsGame extends Phaser.Scene {
}
}
} else if (action.kind === 'work') {
playToWork(this.gs, seat, action.source, action.dest);
const wlog = playToWork(this.gs, seat, action.source, action.dest);
if (wlog && action.source.type === 'nerts') this.resetLastMoveTimer();
} else if (action.kind === 'flip') {
flipStock(this.gs, seat);
playSound(this, SFX.CARD_SHOW);
}
this.renderFoundations();
this.renderOpponents();
@ -319,6 +416,9 @@ export default class NertsGame extends Phaser.Scene {
finishRound() {
this.roundEnding = true;
this.stopAITimers();
if (this.lastMoveTimer) { this.lastMoveTimer.remove(false); this.lastMoveTimer = null; }
this.shuffleBtn?.setVisible(false);
this.resignBtn?.setVisible(false);
this._clearDrag();
const summary = endRound(this.gs);
@ -410,12 +510,15 @@ export default class NertsGame extends Phaser.Scene {
this.foundationCardObjs = [];
for (let idx = 0; idx < this.gs.foundations.length; idx++) {
const slot = this.gs.foundations[idx];
const complete = slot && slot.cards.length > 0 && slot.cards[slot.cards.length - 1].rank === 'K';
this.foundationSlotRects[idx]?.setFillStyle(complete ? 0x111111 : 0x000000, complete ? 0.5 : 0.22);
if (!slot || slot.cards.length === 0) continue;
const top = slot.cards[slot.cards.length - 1];
const pos = this.foundationPos[idx];
const c = this.makeCardSprite(top, pos.x, pos.y, {
faceUp: true, rim: SEAT_COLORS[top.owner], store: false,
});
if (complete) c.setAlpha(0.4);
this.foundationCardObjs.push(c);
}
}
@ -426,6 +529,45 @@ export default class NertsGame extends Phaser.Scene {
if (!dyn) continue;
dyn.nertsText.setText(`Nerts: ${this.gs.players[seat].nerts.length}`);
dyn.scoreText.setText(`Score: ${this.gs.players[seat].totalScore}`);
if (dyn.nertsCard) { dyn.nertsCard.destroy(); dyn.nertsCard = null; }
if (dyn.stockCard) { dyn.stockCard.destroy(); dyn.stockCard = null; }
for (const wc of dyn.wasteCards) wc.destroy();
dyn.wasteCards = [];
const { x: px, y: py } = dyn.panelPos;
const cardX = px + PANEL_W / 2 + 20 - CARD_W * 0.35;
const cardY = py + PANEL_H / 2 + 20 - CARD_H * 0.35;
// Nerts top card (scale 0.7)
const top = nertsTop(this.gs, seat);
if (top) {
dyn.nertsCard = this.makeCardSprite(top, cardX, cardY, {
faceUp: true, rim: SEAT_COLORS[seat] ?? null, store: false,
}).setScale(0.7).setDepth(D.panel + 2);
}
// Stock + waste above the nerts card (scale 0.55)
const SMALL = 0.55;
const smallHalfW = CARD_W * SMALL / 2;
const smallHalfH = CARD_H * SMALL / 2;
const stockX = cardX + CARD_W * 0.35 - smallHalfW;
const stockY = cardY - CARD_H * 0.35 - smallHalfH - 6;
if (this.gs.players[seat].stockDraw.length > 0) {
dyn.stockCard = this.makeCardSprite({ id: `opp-stock-${seat}` }, stockX, stockY, {
faceUp: false, store: false,
}).setScale(SMALL).setDepth(D.panel + 2);
}
const wasteShown = this.gs.players[seat].stockWaste.slice(-3);
const wasteBaseX = stockX + smallHalfW * 2 + 4;
wasteShown.forEach((card, i) => {
const wc = this.makeCardSprite(card, wasteBaseX + i * WASTE_FAN * SMALL, stockY, {
faceUp: true, rim: SEAT_COLORS[seat] ?? null, store: false,
}).setScale(SMALL).setDepth(D.panel + 2 + i);
dyn.wasteCards.push(wc);
});
}
}
@ -516,7 +658,7 @@ export default class NertsGame extends Phaser.Scene {
if (!this.isPlayable() || this.dragState) return;
const log = flipStock(this.gs, 0);
if (log) {
playSound(this, SFX.CARD_SHOW);
playSound(this, log.type === 'recycle' ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
this.renderLocal();
}
}
@ -595,7 +737,7 @@ export default class NertsGame extends Phaser.Scene {
return { type: 'foundation', idx: f };
}
}
if (y > 470) {
if (y > WORK_TOP_Y - 70) {
for (let i = 0; i < WORK_PILE_COUNT; i++) {
if (Math.abs(x - WORK_X[i]) < CARD_W * 0.7) return { type: 'work', idx: i };
}
@ -646,8 +788,10 @@ export default class NertsGame extends Phaser.Scene {
if (target.type === 'foundation') {
if ((source.count ?? 1) > 1) return false; // foundations take single cards only
log = playToFoundation(this.gs, 0, source, target.idx);
if (log) { this.markFoundationCooldown(target.idx); this.resetLastMoveTimer(); }
} else {
log = playToWork(this.gs, 0, source, target.idx);
if (log && source.type === 'nerts') this.resetLastMoveTimer();
}
if (!log) return false;
playSound(this, SFX.CARD_PLACE);
@ -671,6 +815,8 @@ export default class NertsGame extends Phaser.Scene {
for (let f = 0; f < this.gs.foundations.length; f++) {
if (canPlayOnFoundation(this.gs, card, f)) {
if (playToFoundation(this.gs, 0, source, f)) {
this.markFoundationCooldown(f);
this.resetLastMoveTimer();
playSound(this, SFX.CARD_PLACE);
this.afterLocalMove();
}

View File

@ -279,13 +279,16 @@ export function playToWork(state, seat, source, dstIdx) {
return log;
}
/** Flip up to STOCK_FLIP cards from draw to waste, recycling the waste if needed. */
/** Flip up to STOCK_FLIP cards from draw to waste.
* If draw is empty, recycles waste back to draw and returns without dealing
* the player must click again to actually draw the next three cards. */
export function flipStock(state, seat) {
const p = state.players[seat];
if (p.stockDraw.length === 0) {
if (p.stockWaste.length === 0) return null;
p.stockDraw = p.stockWaste.reverse();
p.stockWaste = [];
return { type: 'recycle', seat };
}
const n = Math.min(STOCK_FLIP, p.stockDraw.length);
for (let i = 0; i < n; i++) p.stockWaste.push(p.stockDraw.pop());
@ -343,3 +346,15 @@ export function endRound(state) {
return summary;
}
export function reshuffleAllStocks(state) {
for (const p of state.players) {
const combined = [...p.stockDraw, ...p.stockWaste];
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
p.stockDraw = combined;
p.stockWaste = [];
}
}

View File

@ -32,6 +32,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selectedCardBack = null;
this.cardBackTiles = [];
this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4;
this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
}
@ -96,8 +97,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1;
const defaultCount = this.gameDef.slug === 'nerts' ? 1 : max;
this._initializing = true;
this.cards.slice(0, max).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this.cards.slice(0, defaultCount).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this._initializing = false;
this.events.once('shutdown', () => {
@ -108,6 +110,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (isCatan) this.buildTilePlacementSection(340, 1013);
const isGoFish = this.gameDef.slug === 'gofish';
if (isGoFish) this.buildMatchVariantSection(340, 1013);
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -520,6 +525,52 @@ export default class OpponentSelectScene extends Phaser.Scene {
});
}
// ── Go Fish: match variant toggle ─────────────────────────────────────────
buildMatchVariantSection(centerX, centerY) {
const options = [
{ id: 2, label: '2-Card' },
{ id: 4, label: '4-Card' },
];
const pillW = 150, pillH = 40, pillGap = 12;
const totalW = options.length * pillW + (options.length - 1) * pillGap;
const labelY = centerY - 28;
const pillY = centerY + 10;
const labelText = this.add.text(centerX, labelY, 'Match Variant', {
fontFamily: '"Julius Sans One"',
fontSize: '20px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
this.children.moveBelow(labelBg, labelText);
this._matchVariantBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedMatchVariant === opt.id;
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._matchVariantBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedMatchVariant ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedMatchVariant = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedMatchVariant !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedMatchVariant !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._matchVariantBtns.push({ bg, id: opt.id });
});
}
// ── Generic option section builder ─────────────────────────────────────────
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
@ -640,6 +691,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
playfield: this.selectedPlayfield,
cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant,
});
}
}