fertig-classic-games/public/src/games/nerts/NertsGame.js

933 lines
35 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 { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
WORK_PILE_COUNT,
DEFAULT_TARGET_SCORE,
createInitialState,
canPlayOnFoundation,
playToFoundation,
playToWork,
flipStock,
endRound,
allStuck,
nertsTop,
wasteTop,
workTop,
validRunFromIndex,
reshuffleAllStocks,
} from './NertsLogic.js';
import { chooseAction, nextThinkDelay } from './NertsAI.js';
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
const CARD_W = 84;
const CARD_H = 118;
const CARD_R = 8;
const FAN_Y = 26; // vertical fan offset for work piles
const WASTE_FAN = 30; // horizontal fan for the waste's visible cards
const D = {
felt: -1, pile: 5, card: 10, drag: 40, ui: 30, fly: 50, panel: 28, modal: 80,
};
// Per-seat owner colors — used as a rim on foundation cards so contributions read.
const SEAT_COLORS = [0xf2c14e, 0x4dabf7, 0xe06c75, 0x69db7c];
const SEAT_COLOR_HEX = ['#f2c14e', '#4dabf7', '#e06c75', '#69db7c'];
// ── Foundation layout ──────────────────────────────────────────────────────
const FOUND_PER_ROW = 8;
const FOUND_GAP_X = 18;
const FOUND_ROW_Y = [286, 286 + CARD_H + 24];
// ── Local tableau layout ─────────────────────────────────────────────────────
// 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 ─────────────────────────────────────────────────────
const PANEL_W = 320;
const PANEL_H = 160;
const PANEL_POSITIONS = {
1: [{ x: 960, y: 120 }],
2: [{ x: 620, y: 120 }, { x: 1300, y: 120 }],
3: [{ x: 430, y: 120 }, { x: 960, y: 120 }, { x: 1490, y: 120 }],
};
export default class NertsGame extends Phaser.Scene {
constructor() { super('NertsGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.targetScore = data.game?.targetScore ?? DEFAULT_TARGET_SCORE;
this.playerCount = 1 + this.opponents.length;
this.gs = null;
this.totals = new Array(this.playerCount).fill(0);
this.localCardObjs = new Map(); // card.id → container (local tableau)
this.localExtraObjs = []; // non-keyed local sprites (face-down backs)
this.foundationCardObjs = []; // foundation top-card sprites
this.foundationPos = []; // idx → {x,y}
this.foundationSlotRects = [];
this.oppPanelPos = []; // seat → {x,y} (portrait pos, for fly origin)
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;
this.dropHighlight = null;
this.roundEnding = false;
this.panelObjs = [];
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* music optional */ }
this.buildPlayfield();
this.buildFoundations();
this.buildLocalArea();
this.buildOpponentPanels();
this.buildHUD();
this.buildLastMovePanel();
this.setupDragHandlers();
this.events.once('shutdown', () => this.stopAITimers());
this.startRound();
}
// ── Static layout ────────────────────────────────────────────────────────
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);
}
}
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;
const countInRow = Math.min(FOUND_PER_ROW, total - row * FOUND_PER_ROW);
const rowW = countInRow * CARD_W + (countInRow - 1) * FOUND_GAP_X;
const startX = CX - rowW / 2 + CARD_W / 2;
const x = startX + col * (CARD_W + FOUND_GAP_X);
const y = FOUND_ROW_Y[row];
this.foundationPos[idx] = { x, y };
const r = this.add.rectangle(x, y, CARD_W + 4, CARD_H + 4, 0x000000, 0.22)
.setStrokeStyle(2, COLORS.muted, 0.5).setDepth(D.pile);
this.foundationSlotRects[idx] = r;
}
this.add.text(CX, FOUND_ROW_Y[0] - CARD_H / 2 - 26, 'FOUNDATIONS — play Aces here, build up by suit', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
}
buildLocalArea() {
// Nerts pile
this.add.rectangle(NERTS_POS.x, NERTS_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
.setStrokeStyle(3, COLORS.accent).setDepth(D.pile);
this.add.text(NERTS_POS.x, NERTS_POS.y - CARD_H / 2 - 40, 'NERTS', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
this.localNertsText = this.add.text(NERTS_POS.x, NERTS_POS.y - CARD_H / 2 - 18, '13 left', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
// Work pile placeholders
for (let i = 0; i < WORK_PILE_COUNT; i++) {
this.add.rectangle(WORK_X[i], WORK_TOP_Y, CARD_W + 4, CARD_H + 4, 0x000000, 0.2)
.setStrokeStyle(1, COLORS.muted, 0.5).setDepth(D.pile);
}
// Stock + waste
const stockRect = this.add.rectangle(STOCK_POS.x, STOCK_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile).setInteractive({ useHandCursor: true });
stockRect.on('pointerdown', () => this.onStockClick());
this.add.text(STOCK_POS.x, STOCK_POS.y - CARD_H / 2 - 18, 'STOCK', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.localStockText = this.add.text(STOCK_POS.x, STOCK_POS.y + CARD_H / 2 + 16, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
// Local portrait + name + score
createPlayerPortrait(this, LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y, LOCAL_PORTRAIT.r, D.ui, 'NertsGame');
this.add.text(LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y + LOCAL_PORTRAIT.r + 16, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.localScoreText = this.add.text(LOCAL_PORTRAIT.x, LOCAL_PORTRAIT.y + LOCAL_PORTRAIT.r + 44, 'Score: 0', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
}
buildOpponentPanels() {
const positions = PANEL_POSITIONS[this.opponents.length] ?? [];
for (let i = 0; i < this.opponents.length; i++) {
const seat = i + 1;
const opp = this.opponents[i];
const pos = positions[i] ?? { x: 960, y: 120 };
this.add.rectangle(pos.x, pos.y, PANEL_W, PANEL_H, COLORS.panel, 0.82)
.setStrokeStyle(2, SEAT_COLORS[seat] ?? COLORS.muted).setDepth(D.panel);
const portX = pos.x - PANEL_W / 2 + 56;
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, portX, pos.y, 46, D.panel + 1);
this.oppPanelPos[seat] = { x: portX, y: pos.y };
const textX = portX + 64;
this.add.text(textX, pos.y - 56, opp.name ?? `Player ${seat + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
this.buildSkillPips(textX, pos.y - 26, opp.skill ?? 3);
const nertsText = this.add.text(textX, pos.y + 8, 'Nerts: 13', {
fontFamily: 'Righteous', fontSize: '24px', color: SEAT_COLOR_HEX[seat] ?? COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
const scoreText = this.add.text(textX, pos.y + 40, 'Score: 0', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
this.oppDynamic[seat] = { nertsText, scoreText, panelPos: { x: pos.x, y: pos.y }, nertsCard: null, stockCard: null, wasteCards: [] };
}
}
buildSkillPips(x, y, skill) {
this.add.text(x, y, 'SKILL', {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
const pipX = x + 52;
for (let i = 0; i < 5; i++) {
const filled = i < skill;
this.add.circle(pipX + i * 18, y, 6, filled ? COLORS.accent : COLORS.muted, filled ? 1 : 0.35)
.setStrokeStyle(1, COLORS.accent, filled ? 1 : 0.4).setDepth(D.panel + 1);
}
}
buildHUD() {
this.statusText = this.add.text(24, 36, `Nerts — first to ${this.targetScore} points`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).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,
totals: this.totals,
});
playSound(this, SFX.CARD_SHUFFLE);
this.renderAll();
this.startAITimers();
this.startLastMoveTimer();
}
startAITimers() {
this.stopAITimers();
for (let seat = 1; seat < this.playerCount; seat++) {
this.scheduleAITick(seat);
}
}
scheduleAITick(seat) {
const skill = this.opponents[seat - 1]?.skill ?? 3;
this.aiTimers[seat] = this.time.delayedCall(nextThinkDelay(skill), () => this.aiTick(seat));
}
stopAITimers() {
for (const t of this.aiTimers) { if (t) t.remove(false); }
this.aiTimers = [];
}
aiTick(seat) {
if (this.roundEnding || !this.gs || this.gs.phase !== 'playing') return;
const skill = this.opponents[seat - 1]?.skill ?? 3;
const action = chooseAction(this.gs, seat, skill);
if (action) this.applyAIAction(seat, action);
if (this.checkEnd()) return;
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);
playSound(this, SFX.CARD_PLACE);
if (action.source.type === 'nerts' && Math.random() < 0.5) {
this.opponentPortraits[seat]?.playEmotion('happy');
}
}
} else if (action.kind === 'work') {
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();
}
actionCard(seat, action) {
const s = action.source;
if (s.type === 'nerts') return nertsTop(this.gs, seat);
if (s.type === 'waste') return wasteTop(this.gs, seat);
if (s.type === 'work') return workTop(this.gs, seat, s.idx);
return null;
}
checkEnd() {
if (this.roundEnding) return true;
if (this.gs.nertsCaller !== null || allStuck(this.gs)) {
this.finishRound();
return true;
}
return false;
}
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);
this.totals = this.gs.players.map((p) => p.totalScore);
this.renderAll();
const winner = this.gs.winner;
if (this.gs.phase === 'matchover') {
const youWon = this.gs.matchWinner === 0;
this.recordHistory(youWon);
}
// Portrait reactions
for (let s = 1; s < this.playerCount; s++) {
this.opponentPortraits[s]?.playEmotion?.(winner === s ? 'happy' : 'upset');
}
playSound(this, winner === 0 ? SFX.CASINO_WIN : SFX.CARD_PLACE);
this.showRoundPanel(summary);
}
// ── Rendering ────────────────────────────────────────────────────────────
renderAll() {
this.renderLocal();
this.renderFoundations();
this.renderOpponents();
}
clearLocalCards() {
for (const c of this.localCardObjs.values()) c.destroy();
this.localCardObjs.clear();
for (const o of this.localExtraObjs) o.destroy();
this.localExtraObjs = [];
}
renderLocal() {
this.clearLocalCards();
const p = this.gs.players[0];
// Nerts pile: a back beneath (if >1) and the face-up top.
if (p.nerts.length > 1) {
this.localExtraObjs.push(
this.makeCardSprite({ id: 'nerts-back' }, NERTS_POS.x, NERTS_POS.y, { faceUp: false, store: false })
);
}
const nt = p.nerts[p.nerts.length - 1];
if (nt) {
const c = this.makeCardSprite(nt, NERTS_POS.x, NERTS_POS.y, { faceUp: true });
this.makeDraggable(c, { kind: 'nerts' });
}
this.localNertsText.setText(`${p.nerts.length} left`);
// Work piles: fan downward; cards starting a valid run are draggable.
for (let i = 0; i < WORK_PILE_COUNT; i++) {
const pile = p.work[i];
for (let k = 0; k < pile.length; k++) {
const card = pile[k];
const x = WORK_X[i];
const y = WORK_TOP_Y + k * FAN_Y;
const c = this.makeCardSprite(card, x, y, { faceUp: true });
if (validRunFromIndex(pile, k)) this.makeDraggable(c, { kind: 'work', idx: i, k });
}
}
// Stock (face-down) + count.
if (p.stockDraw.length > 0) {
this.localExtraObjs.push(
this.makeCardSprite({ id: 'stock-back' }, STOCK_POS.x, STOCK_POS.y, { faceUp: false, store: false })
);
}
this.localStockText.setText(
p.stockDraw.length > 0 ? `${p.stockDraw.length} (click to flip 3)` : 'click to recycle'
);
// Waste: show up to the last 3, fanned right; top is draggable.
const wasteShown = p.stockWaste.slice(-3);
wasteShown.forEach((card, i) => {
const x = WASTE_POS.x + i * WASTE_FAN;
const isTop = i === wasteShown.length - 1;
const c = this.makeCardSprite(card, x, WASTE_POS.y, { faceUp: true });
if (isTop) this.makeDraggable(c, { kind: 'waste' });
});
this.localScoreText.setText(`Score: ${this.gs.players[0].totalScore}`);
}
renderFoundations() {
for (const c of this.foundationCardObjs) c.destroy();
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);
}
}
renderOpponents() {
for (let seat = 1; seat < this.playerCount; seat++) {
const dyn = this.oppDynamic[seat];
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);
});
}
}
// ── Card sprites ─────────────────────────────────────────────────────────
makeCardSprite(card, x, y, { faceUp = true, rim = null, store = true } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
this.renderCardFace(c, card, faceUp, rim);
c.card = card;
c.homeX = x;
c.homeY = y;
if (store && card && card.id !== undefined) this.localCardObjs.set(card.id, c);
return c;
}
renderCardFace(container, card, faceUp, rim) {
container.removeAll(true);
const x = -CARD_W / 2, y = -CARD_H / 2;
if (!faceUp) {
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
container.add(this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
.setDisplaySize(CARD_W, CARD_H).setOrigin(0.5));
} else {
const g = this.add.graphics();
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);
container.add(g);
}
return;
}
const g = this.add.graphics();
g.fillStyle(0xfbf6e7, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(rim ? 4 : 2, rim ?? 0xcc803a, rim ? 1 : 0.5);
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
container.add(g);
const colorHex = card.isRed ? '#c0392b' : '#1a1208';
const label = card.label;
const sym = card.suitSymbol;
container.add(this.add.text(x + 7, y + 5, label, {
fontFamily: 'Righteous', fontSize: '20px', color: colorHex,
}));
container.add(this.add.text(x + 8, y + 28, sym, {
fontFamily: 'sans-serif', fontSize: '18px', color: colorHex,
}));
container.add(this.add.text(0, 4, sym, {
fontFamily: 'sans-serif', fontSize: '40px', color: colorHex,
}).setOrigin(0.5));
container.add(this.add.text(x + CARD_W - 7, y + CARD_H - 5, label, {
fontFamily: 'Righteous', fontSize: '20px', color: colorHex,
}).setOrigin(1, 1));
}
makeDraggable(container, descriptor) {
container.setInteractive(
new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H),
Phaser.Geom.Rectangle.Contains
);
container.input.cursor = 'grab';
container.on('pointerdown', (pointer) => this.onCardDown(descriptor, container, pointer));
}
/** A short throwaway sprite that flies from->to for AI foundation plays. */
spawnFly(card, fromX, fromY, toX, toY, ownerSeat) {
const c = this.add.container(fromX, fromY).setDepth(D.fly);
this.renderCardFace(c, card, true, SEAT_COLORS[ownerSeat]);
c.setScale(0.7);
this.tweens.add({
targets: c, x: toX, y: toY, scale: 1, duration: 300, ease: 'Cubic.easeOut',
onComplete: () => c.destroy(),
});
}
// ── Local input: stock / drag-drop ───────────────────────────────────────
isPlayable() {
return this.gs && this.gs.phase === 'playing' && !this.roundEnding;
}
onStockClick() {
if (!this.isPlayable() || this.dragState) return;
const log = flipStock(this.gs, 0);
if (log) {
playSound(this, log.type === 'recycle' ? SFX.CARD_SHUFFLE : SFX.CARD_SHOW);
this.renderLocal();
}
}
onCardDown(descriptor, container, pointer) {
if (!this.isPlayable() || this.dragState) return;
const sprites = this.dragSpritesFor(descriptor);
if (sprites.length === 0) return;
this.potentialDrag = {
descriptor,
sprites: sprites.map((obj) => ({ obj, offX: obj.x - pointer.x, offY: obj.y - pointer.y })),
startX: pointer.x, startY: pointer.y,
};
}
dragSpritesFor(descriptor) {
if (descriptor.kind === 'nerts') {
const c = nertsTop(this.gs, 0);
return c ? [this.localCardObjs.get(c.id)].filter(Boolean) : [];
}
if (descriptor.kind === 'waste') {
const c = wasteTop(this.gs, 0);
return c ? [this.localCardObjs.get(c.id)].filter(Boolean) : [];
}
if (descriptor.kind === 'work') {
const pile = this.gs.players[0].work[descriptor.idx];
return pile.slice(descriptor.k).map((card) => this.localCardObjs.get(card.id)).filter(Boolean);
}
return [];
}
setupDragHandlers() {
this.input.on('pointermove', (pointer) => {
if (!pointer.isDown) return;
if (this.dragState) {
this.updateDrag(pointer);
} else if (this.potentialDrag) {
const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY;
if (dx * dx + dy * dy > 64) this.promoteDrag();
}
});
this.input.on('pointerup', () => {
if (this.dragState) this.endDrag();
else if (this.potentialDrag) {
const pd = this.potentialDrag;
this.potentialDrag = null;
this.onCardClick(pd.descriptor); // tap = try auto-play to a foundation
}
});
}
promoteDrag() {
const pd = this.potentialDrag;
this.potentialDrag = null;
pd.sprites.forEach(({ obj }, i) => {
obj.setDepth(D.drag + i);
this.tweens.add({ targets: obj, scaleX: 1.06, scaleY: 1.06, duration: 90 });
});
this.dragState = pd;
}
updateDrag(pointer) {
for (const { obj, offX, offY } of this.dragState.sprites) {
obj.x = pointer.x + offX;
obj.y = pointer.y + offY;
}
const primary = this.dragState.sprites[0].obj;
this.updateDropHighlight(this.getDropTargetAt(primary.x, primary.y));
}
getDropTargetAt(x, y) {
for (let f = 0; f < this.foundationPos.length; f++) {
const pos = this.foundationPos[f];
if (Math.abs(x - pos.x) < CARD_W * 0.7 && Math.abs(y - pos.y) < CARD_H * 0.7) {
return { type: 'foundation', idx: f };
}
}
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 };
}
}
return null;
}
updateDropHighlight(target) {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
if (!target) return;
const pos = target.type === 'foundation' ? this.foundationPos[target.idx]
: { x: WORK_X[target.idx], y: WORK_TOP_Y };
const color = target.type === 'foundation' ? 0xffd700 : 0x4dabf7;
this.dropHighlight = this.add.rectangle(pos.x, pos.y, CARD_W + 16, CARD_H + 16, color, 0.18)
.setStrokeStyle(3, color, 0.9).setDepth(D.card - 1);
}
endDrag() {
const ds = this.dragState;
this.dragState = null;
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
const primary = ds.sprites[0].obj;
const target = this.getDropTargetAt(primary.x, primary.y);
const committed = target ? this.commitDrop(ds.descriptor, target) : false;
// On success the sprites are torn down by the re-render; only animate them
// back home when the drop was rejected.
if (!committed) {
ds.sprites.forEach(({ obj }) => {
this.tweens.add({
targets: obj, x: obj.homeX, y: obj.homeY, scaleX: 1, scaleY: 1,
duration: 240, ease: 'Back.easeOut',
});
});
}
}
sourceFor(descriptor) {
if (descriptor.kind === 'nerts') return { type: 'nerts' };
if (descriptor.kind === 'waste') return { type: 'waste' };
const pile = this.gs.players[0].work[descriptor.idx];
return { type: 'work', idx: descriptor.idx, count: pile.length - descriptor.k };
}
commitDrop(descriptor, target) {
const source = this.sourceFor(descriptor);
let log = null;
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);
this.afterLocalMove();
return true;
}
onCardClick(descriptor) {
if (!this.isPlayable()) return;
// Tap = try to play the single top card onto the first legal foundation.
if (descriptor.kind === 'work') {
const pile = this.gs.players[0].work[descriptor.idx];
if (descriptor.k !== pile.length - 1) return; // only the visible top can quick-play
}
const source = this.sourceFor(descriptor);
if ((source.count ?? 1) > 1) return;
const card = descriptor.kind === 'nerts' ? nertsTop(this.gs, 0)
: descriptor.kind === 'waste' ? wasteTop(this.gs, 0)
: workTop(this.gs, 0, descriptor.idx);
if (!card) return;
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();
}
return;
}
}
}
afterLocalMove() {
this.renderLocal();
this.renderFoundations();
this.renderOpponents();
this.checkEnd();
}
_clearDrag() {
if (this.dropHighlight) { this.dropHighlight.destroy(); this.dropHighlight = null; }
this.dragState = null;
this.potentialDrag = null;
}
// ── Round / match summary panel ──────────────────────────────────────────
showRoundPanel(summary) {
const matchOver = this.gs.phase === 'matchover';
const winner = this.gs.winner;
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68)
.setInteractive().setDepth(D.modal);
this.panelObjs.push(overlay);
const panelW = 760;
const panelH = 120 + this.playerCount * 52 + 90;
const panel = this.add.rectangle(CX, CY, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
this.panelObjs.push(panel);
const title = matchOver
? (this.gs.matchWinner === 0 ? 'You win the match!'
: this.gs.matchWinner === -1 ? "It's a tie!"
: `${this.nameForSeat(this.gs.matchWinner)} wins the match!`)
: (winner === 0 ? 'Nerts! You won the round' : `${this.nameForSeat(winner)} called Nerts!`);
const t = this.add.text(CX, CY - panelH / 2 + 44, title, {
fontFamily: 'Righteous', fontSize: '38px',
color: (matchOver ? this.gs.matchWinner : winner) === 0 ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal + 1);
this.panelObjs.push(t);
// Scoreboard rows
const rowTop = CY - panelH / 2 + 96;
summary.forEach((s, i) => {
const y = rowTop + i * 52;
const name = this.nameForSeat(s.seat);
const deltaSign = s.roundScore >= 0 ? '+' : '';
const line = `${name}: ${s.founded} on foundations, ${s.nertsLeft} left in Nerts (${deltaSign}${s.roundScore})`;
this.panelObjs.push(this.add.text(CX - panelW / 2 + 50, y, line, {
fontFamily: '"Julius Sans One"', fontSize: '20px',
color: s.seat === winner ? COLORS.accentHex : COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.modal + 1));
this.panelObjs.push(this.add.text(CX + panelW / 2 - 50, y, `Total: ${s.totalScore}`, {
fontFamily: 'Righteous', fontSize: '22px', color: SEAT_COLOR_HEX[s.seat] ?? COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 1));
});
const btnY = CY + panelH / 2 - 50;
if (matchOver) {
const b1 = new Button(this, CX - 130, btnY, 'Play again', () => this.restartMatch(),
{ width: 220, fontSize: 22 }).setDepth(D.modal + 1);
this.panelObjs.push(b1);
} else {
const b1 = new Button(this, CX - 130, btnY, 'Next round', () => this.nextRound(),
{ width: 220, fontSize: 22, bg: COLORS.accent, textColor: COLORS.textDarkHex }).setDepth(D.modal + 1);
this.panelObjs.push(b1);
}
const b2 = new Button(this, CX + 130, btnY, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal + 1);
this.panelObjs.push(b2);
}
clearPanel() {
for (const o of this.panelObjs) o.destroy();
this.panelObjs = [];
}
nextRound() {
this.clearPanel();
this.startRound();
}
restartMatch() {
this.totals = new Array(this.playerCount).fill(0);
this.clearPanel();
this.startRound();
}
nameForSeat(seat) {
if (seat === 0) return auth.user?.username ?? 'You';
if (seat < 0) return 'Nobody';
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
}
async recordHistory(youWon) {
try {
await api.post('/history/single-player', {
slug: 'nerts',
score: this.totals[0],
opponentScores: this.totals.slice(1),
result: youWon ? 'win' : 'loss',
});
} catch (err) {
console.warn('[nerts] failed to record history', err);
}
}
}