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:
parent
7e022e8e9b
commit
307f3b7123
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue