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); } } }