diff --git a/public/src/games/uno/UnoAI.js b/public/src/games/uno/UnoAI.js new file mode 100644 index 0000000..cb148c1 --- /dev/null +++ b/public/src/games/uno/UnoAI.js @@ -0,0 +1,133 @@ +// Uno AI — pure decision function. Mirrors the gofish/GoFishAI.js shape: +// one stateless function per decision, no scene state. +// +// Returns one of: +// { kind: 'play', cardId, chosenColor? } +// { kind: 'draw' } +// { kind: 'playDrawn' } — after a draw, when the drawn card is playable +// { kind: 'passDrawn' } — keep the drawn card, end turn +// { kind: 'chooseColor', color } — phase === 'choosingColor' +// { kind: 'challenge', doChallenge: bool } — phase === 'challengeWindow' + +import { UNO_COLORS } from './UnoDeck.js'; +import { legalPlays, isLegalPlay } from './UnoLogic.js'; + +const KIND_PRIORITY_LATE = { wild4: 9, draw2: 8, skip: 7, reverse: 6, wild: 5, number: 1 }; +const KIND_PRIORITY_EARLY = { number: 5, reverse: 4, skip: 3, draw2: 2, wild: 1, wild4: 0 }; + +function colorTally(hand) { + const tally = { r: 0, y: 0, g: 0, b: 0 }; + for (const c of hand) if (c.color !== 'w') tally[c.color] += 1; + return tally; +} + +/** Pick the color the AI has the most of (ties broken by deck order). */ +function bestColor(hand) { + const t = colorTally(hand); + let best = UNO_COLORS[0]; + for (const c of UNO_COLORS) if (t[c] > t[best]) best = c; + return best; +} + +/** + * Score a candidate play. Higher is better. Considers: + * - Punish dangerous opponents (next opponent with small hand → favor action cards) + * - Save high-value cards for late game otherwise + * - Prefer playing cards whose color matches the rest of our hand + * - Never voluntarily play Wild +4 when a non-wild play exists + */ +function scoreCandidate(state, seat, card, hand) { + const N = state.players.length; + const nextSeat = ((seat + state.direction) % N + N) % N; + const nextHandSize = state.players[nextSeat].hand.length; + const dangerous = nextHandSize <= 2; + const ourHandSize = hand.length; + const earlyGame = ourHandSize >= 5; + + let score; + if (dangerous) { + score = (KIND_PRIORITY_LATE[card.kind] ?? 0) * 10; + } else if (earlyGame) { + score = (KIND_PRIORITY_EARLY[card.kind] ?? 0) * 10; + } else { + // Mid-game: neutral preference for non-wild plays. + score = card.kind === 'number' ? 30 : (card.kind === 'wild4' ? 5 : 25); + } + + // Bonus: playing this card leaves us with more of our dominant color in hand. + if (card.color !== 'w') { + const tally = colorTally(hand); + score += tally[card.color]; // staying in our strongest color is good + } + + // Penalty: blow a Wild +4 unless it's a strong move (dangerous opponent + // OR we're stuck with this Wild +4 long-term). + if (card.kind === 'wild4' && !dangerous) score -= 25; + + // Small randomization for variety. + score += Math.random() * 0.5; + return score; +} + +/** + * Pick the single best action for the current phase. + */ +export function chooseAction(state, seat) { + if (state.phase === 'choosingColor') { + return { kind: 'chooseColor', color: bestColor(state.players[seat].hand) }; + } + + if (state.phase === 'challengeWindow') { + // We are the challenger. Without perfect information we challenge only + // when there's reasonable suspicion. Heuristics: + // - The +4 player has many cards (likely had a color match). + // - Our own hand is small (the gamble is lower-risk). + const pw = state.pendingWild4; + if (!pw) return { kind: 'challenge', doChallenge: false }; + const playerHandSize = state.players[pw.playerSeat].hand.length; + const ourHandSize = state.players[seat].hand.length; + const suspicious = playerHandSize >= 4; + const lowRisk = ourHandSize <= 3; + const doChallenge = suspicious && (lowRisk || Math.random() < 0.4); + return { kind: 'challenge', doChallenge }; + } + + if (state.phase === 'mustPlayDrawn') { + // We just drew. Pass unless the drawn card is genuinely playable and + // playing it is better than holding it for later. + const me = state.players[seat]; + const drawn = state.pendingDrawn; + if (!drawn) return { kind: 'passDrawn' }; + if (!isLegalPlay(state, drawn, me.hand)) return { kind: 'passDrawn' }; + // Always play numbers / actions when we can. For wilds, play them only if + // we have no other play (we already drew, so we're not blowing tempo). + return { kind: 'playDrawn' }; + } + + if (state.phase !== 'play') return { kind: 'draw' }; + + const me = state.players[seat]; + const legalIds = legalPlays(state, seat); + if (legalIds.length === 0) return { kind: 'draw' }; + + let bestCard = null; + let bestScore = -Infinity; + for (const id of legalIds) { + const card = me.hand.find((c) => c.id === id); + if (!card) continue; + const s = scoreCandidate(state, seat, card, me.hand); + if (s > bestScore) { + bestScore = s; + bestCard = card; + } + } + + if (!bestCard) return { kind: 'draw' }; + const result = { kind: 'play', cardId: bestCard.id }; + if (bestCard.color === 'w') { + // Pre-compute the color we'll choose so the scene can animate atomically. + // The engine still calls applyChooseColor separately. + result.chosenColor = bestColor(me.hand.filter((c) => c.id !== bestCard.id)); + } + return result; +} diff --git a/public/src/games/uno/UnoDeck.js b/public/src/games/uno/UnoDeck.js new file mode 100644 index 0000000..fc53f91 --- /dev/null +++ b/public/src/games/uno/UnoDeck.js @@ -0,0 +1,82 @@ +// Uno deck. 108 cards: +// - For each of 4 colors (r/y/g/b): one 0, two each of 1–9, two Skip, +// two Reverse, two Draw 2 = 25 × 4 = 100. +// - Plus 4 Wild and 4 Wild Draw 4 = 108. + +export const UNO_COLORS = ['r', 'y', 'g', 'b']; // wild cards use 'w' +export const UNO_KINDS = ['number', 'skip', 'reverse', 'draw2', 'wild', 'wild4']; + +export class UnoCard { + constructor({ color, kind, value = null }) { + this.color = color; // 'r' | 'y' | 'g' | 'b' | 'w' + this.kind = kind; // see UNO_KINDS + this.value = value; // 0..9 for kind='number', else null + this.chosenColor = null; // set on wilds after they are played + this.id = null; // assigned by buildDeck() + } + + /** Mattel scoring (reserved for future multi-round play). */ + get points() { + if (this.kind === 'number') return this.value; + if (this.kind === 'wild' || this.kind === 'wild4') return 50; + return 20; + } + + /** Short human label used on the card face. */ + get label() { + switch (this.kind) { + case 'number': return String(this.value); + case 'skip': return '⊘'; + case 'reverse': return '⇄'; + case 'draw2': return '+2'; + case 'wild': return '★'; + case 'wild4': return '+4'; + default: return ''; + } + } + + /** True if this is a Wild or Wild Draw 4 (color === 'w'). */ + get isWild() { + return this.color === 'w'; + } + + /** + * The color the card matches against when on top of the discard pile. + * For wilds, this is whatever color the player chose; until chosen, null. + */ + get effectiveColor() { + return this.isWild ? this.chosenColor : this.color; + } +} + +export function buildDeck() { + const cards = []; + let id = 0; + const push = (spec) => { + const c = new UnoCard(spec); + c.id = id++; + cards.push(c); + }; + + for (const color of UNO_COLORS) { + push({ color, kind: 'number', value: 0 }); + for (let v = 1; v <= 9; v++) { + push({ color, kind: 'number', value: v }); + push({ color, kind: 'number', value: v }); + } + for (let i = 0; i < 2; i++) push({ color, kind: 'skip' }); + for (let i = 0; i < 2; i++) push({ color, kind: 'reverse' }); + for (let i = 0; i < 2; i++) push({ color, kind: 'draw2' }); + } + for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild' }); + for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild4' }); + + return cards; +} + +export function cloneCard(c) { + const out = new UnoCard({ color: c.color, kind: c.kind, value: c.value }); + out.id = c.id; + out.chosenColor = c.chosenColor; + return out; +} diff --git a/public/src/games/uno/UnoGame.js b/public/src/games/uno/UnoGame.js new file mode 100644 index 0000000..badb327 --- /dev/null +++ b/public/src/games/uno/UnoGame.js @@ -0,0 +1,1365 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { Modal } from '../../ui/Modal.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { auth } from '../../services/auth.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { + createInitialState, + legalPlays, + isLegalPlay, + applyPlay, + applyChooseColor, + applyDraw, + applyPlayDrawn, + applyPassAfterDraw, + applyChallengeWild4, + isGameOver, +} from './UnoLogic.js'; +import { chooseAction } from './UnoAI.js'; + +// ── Layout constants ──────────────────────────────────────────────────────── +const CX = GAME_WIDTH / 2; +const CY = GAME_HEIGHT / 2; + +const CARD_W = 88; +const CARD_H = 132; +const CARD_R = 10; +const HAND_SPREAD_MAX = 70; // max px between adjacent hand cards (local) +const HAND_FACEUP_MAX_W = 1300; // local hand band max width + +const DRAW_OFFSET = -110; // x offset of draw pile from center +const DISCARD_OFFSET = 110; // x offset of discard pile from center + +const D = { + felt: -1, board: 0, card: 10, highlight: 20, + ui: 30, portrait: 35, chip: 40, banner: 60, modal: 80, modalUI: 82, +}; + +const SLOTS_USED = { + 2: ['bottom', 'top'], + 3: ['bottom', 'left', 'right'], + 4: ['bottom', 'left', 'top', 'right'], +}; + +const UNO_COLOR_HEX = { + r: 0xd32f2f, + y: 0xf9a825, + g: 0x2e7d32, + b: 0x1565c0, + w: 0x1a1a1a, // wild card body +}; +const UNO_COLOR_HEXTXT = { + r: '#d32f2f', y: '#f9a825', g: '#2e7d32', b: '#1565c0', w: '#1a1a1a', +}; +const UNO_COLOR_NAMES = { r: 'Red', y: 'Yellow', g: 'Green', b: 'Blue' }; + +function slotLayout(slot, playerCount) { + switch (slot) { + case 'bottom': { + const bchipY = GAME_HEIGHT - 110 - CARD_H / 2 - 30; + const bpr = 56, bpx = CX - 100 - 12 - bpr, bpy = bchipY + 22 - bpr; + return { + handCenter: { x: CX, y: GAME_HEIGHT - 110 }, + handAxis: 'x', + handFaceUp: true, + portrait: { x: bpx, y: bpy, r: bpr }, + nameLabel: { x: bpx, y: bpy - bpr - 14 }, + chip: { x: CX, y: bchipY }, + chipRotation: 0, + rotateCards: 0, + }; + } + case 'top': { + const tchipY = 110 + CARD_H / 2 + 30; + const tpr = 50, tpx = CX - 100 - 12 - tpr, tpy = tchipY - 22 + tpr; + return { + handCenter: { x: CX, y: 110 }, + handAxis: 'x', + handFaceUp: false, + portrait: { x: tpx, y: tpy, r: tpr }, + nameLabel: { x: tpx, y: tpy + tpr + 14 }, + chip: { x: CX, y: tchipY }, + chipRotation: 0, + rotateCards: 180, + }; + } + case 'left': { + const lchipX = 110 + CARD_H / 2 + 10 + 22; + const lpr = 50, lpx = lchipX - 22 + lpr, lpy = CY - 90 - 12 - lpr; + return { + handCenter: { x: 110, y: CY }, + handAxis: 'y', + handFaceUp: false, + portrait: { x: lpx, y: lpy, r: lpr }, + nameLabel: { x: lpx, y: lpy - lpr - 14 }, + chip: { x: lchipX, y: CY }, + chipRotation: Math.PI / 2, + rotateCards: 90, + }; + } + case 'right': { + const rchipX = GAME_WIDTH - 110 - CARD_H / 2 - 10 - 22; + const rpr = 50, rpx = rchipX + 22 - rpr, rpy = CY - 90 - 12 - rpr; + return { + handCenter: { x: GAME_WIDTH - 110, y: CY }, + handAxis: 'y', + handFaceUp: false, + portrait: { x: rpx, y: rpy, r: rpr }, + nameLabel: { x: rpx, y: rpy - rpr - 14 }, + chip: { x: rchipX, y: CY }, + chipRotation: -Math.PI / 2, + rotateCards: 270, + }; + } + default: + throw new Error(`Unknown slot: ${slot}`); + } +} + +const DRAW_POS = { x: CX + DRAW_OFFSET, y: CY }; +const DISCARD_POS = { x: CX + DISCARD_OFFSET, y: CY }; + +// ── Scene ─────────────────────────────────────────────────────────────────── +export default class UnoGame extends Phaser.Scene { + constructor() { super('UnoGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; + this.cardBack = data.cardBack ?? null; + + this.gs = null; + this.animating = false; + this.gameOver = false; + + this.cardObjs = new Map(); + this.transientObjs = []; + this.opponentPortraits = []; + this.seatChips = []; + this.slotForSeat = []; + this.bannerText = null; + this.directionArrow = null; + this.colorSwatch = null; + this.pendingWildPlayCardId = null; // local: card chosen but awaiting color + } + + create() { + new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); + this.buildPlayfield(); + this.assignSeats(); + this.buildSeatAreas(); + this.buildCenter(); + this.buildHUD(); + this.startNewMatch(); + } + + 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); + } + } + + assignSeats() { + const playerCount = 1 + this.opponents.length; + const slots = SLOTS_USED[playerCount]; + if (!slots) throw new Error(`Uno needs 2..4 players, got ${playerCount}`); + this.slotForSeat = slots.slice(); + } + + buildSeatAreas() { + const N = this.slotForSeat.length; + for (let seat = 0; seat < N; seat++) { + const slot = this.slotForSeat[seat]; + const layout = slotLayout(slot, N); + + if (seat === 0) { + createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'UnoGame'); + this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + } else { + const opp = this.opponents[seat - 1]; + if (opp) { + this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait); + this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + } + } + + // Hand-count chip (number of cards held). + this.seatChips[seat] = this.makeSeatChip(layout.chip.x, layout.chip.y); + this.seatChips[seat].container.setRotation(layout.chipRotation); + } + } + + makeSeatChip(x, y) { + const container = this.add.container(x, y).setDepth(D.chip); + const bg = this.add.graphics(); + bg.fillStyle(COLORS.panel, 0.92); + bg.fillRoundedRect(-100, -22, 200, 44, 10); + bg.lineStyle(2, COLORS.accent, 1); + bg.strokeRoundedRect(-100, -22, 200, 44, 10); + const label = this.add.text(-88, 0, 'CARDS', { + fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex, + }).setOrigin(0, 0.5); + const count = this.add.text(88, 0, '0', { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex, + }).setOrigin(1, 0.5); + container.add([bg, label, count]); + return { container, count }; + } + + buildCenter() { + // Direction arrow ring (drawn around the center piles). + this.directionArrow = this.add.graphics().setDepth(D.board); + this.drawDirectionArrow(1); + + // Current-color swatch (shown between the piles). + this.colorSwatch = this.add.graphics().setDepth(D.board + 1); + + // Banner text (last action, hints). + this.bannerBg = this.add.graphics().setDepth(D.banner - 1).setVisible(false); + this.bannerText = this.add.text(CX, CY + CARD_H + 90, '', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, align: 'center', + wordWrap: { width: 900 }, + }).setOrigin(0.5).setDepth(D.banner); + } + + drawDirectionArrow(direction) { + const g = this.directionArrow; + g.clear(); + const radius = 180; + g.lineStyle(4, 0xffe066, 0.35); + // Top semicircle arrow + g.beginPath(); + if (direction === 1) { + g.arc(CX, CY, radius, Math.PI, 0, false); + } else { + g.arc(CX, CY, radius, 0, Math.PI, true); + } + g.strokePath(); + // Arrowhead at the end of the arc (top of arrow ring). + const tipAngle = direction === 1 ? 0 : Math.PI; + const tipX = CX + radius * Math.cos(tipAngle); + const tipY = CY + radius * Math.sin(tipAngle); + const headLen = 18; + const sign = direction === 1 ? 1 : -1; + g.lineStyle(4, 0xffe066, 0.85); + g.beginPath(); + g.moveTo(tipX, tipY); + g.lineTo(tipX - headLen * sign, tipY - headLen); + g.moveTo(tipX, tipY); + g.lineTo(tipX - headLen * sign, tipY + headLen); + g.strokePath(); + } + + updateColorSwatch() { + const g = this.colorSwatch; + g.clear(); + if (!this.gs || !this.gs.currentColor) return; + const color = UNO_COLOR_HEX[this.gs.currentColor]; + g.fillStyle(color, 1); + g.fillCircle(CX, CY, 22); + g.lineStyle(3, 0xffffff, 0.9); + g.strokeCircle(CX, CY, 22); + } + + buildHUD() { + const bchipY = GAME_HEIGHT - 110 - CARD_H / 2 - 30; + this.statusBg = this.add.graphics().setDepth(D.ui - 1); + this.statusText = this.add.text(CX - 100, bchipY - 22 - 14, '', { + fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'left', + }).setOrigin(0, 1).setDepth(D.ui); + + new Button(this, 80, GAME_HEIGHT - 30, 'Leave', () => this.scene.start('GameMenu'), { + variant: 'ghost', width: 120, height: 40, fontSize: 18, + }).setDepth(D.ui); + new Button(this, 80, GAME_HEIGHT - 75, 'New', () => this.startNewMatch(), { + variant: 'ghost', width: 120, height: 40, fontSize: 18, + }).setDepth(D.ui); + } + + // ── Match lifecycle ──────────────────────────────────────────────────────── + + startNewMatch() { + if (this.animating) return; + this.gameOver = false; + this.pendingWildPlayCardId = null; + this.closeAnyModal(); + this.clearAllCardObjs(); + this.hideBanner(); + + const playerCount = this.slotForSeat.length; + const finalState = createInitialState({ playerCount }); + + playSound(this, SFX.CARD_SHUFFLE); + this.animating = true; + + // Visualize the deck as the deal source. + const deck = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, { faceUp: false }); + this.transientObjs.push(deck); + for (const chip of this.seatChips) { if (chip) chip.count.setText('0'); } + + // Round-robin deal sequence. + const hands = finalState.players.map((p) => [...p.hand]); + const maxCards = Math.max(...hands.map((h) => h.length)); + const sequence = []; + for (let round = 0; round < maxCards; round++) { + for (let seat = 0; seat < playerCount; seat++) { + if (round < hands[seat].length) { + sequence.push({ seat, card: hands[seat][round], handIndex: round }); + } + } + } + + const STAGGER = 80; + const DURATION = 200; + + sequence.forEach(({ seat, card, handIndex }, idx) => { + this.time.delayedCall(idx * STAGGER, () => { + const layout = slotLayout(this.slotForSeat[seat], playerCount); + const n = hands[seat].length; + const spread = this.handSpreadFor(seat, n); + const offset = handIndex - (n - 1) / 2; + const tx = layout.handAxis === 'x' + ? layout.handCenter.x + offset * spread + : layout.handCenter.x; + const ty = layout.handAxis === 'x' + ? layout.handCenter.y + : layout.handCenter.y + offset * spread; + + const sprite = this.makeUnoCardSprite(card, DRAW_POS.x, DRAW_POS.y, { + faceUp: false, rotation: layout.rotateCards, + }); + sprite.setDepth(D.card + 5); + this.transientObjs.push(sprite); + + if (idx % 4 === 0) playSound(this, SFX.CARD_DEAL); + + this.tweens.add({ + targets: sprite, + x: tx, y: ty, + duration: DURATION, + ease: 'Cubic.easeOut', + onComplete: () => { + if (!sprite.active) return; + if (layout.handFaceUp) this.renderUnoCardFace(sprite, card, true); + sprite.setDepth(D.card); + }, + }); + }); + }); + + const doneAt = (sequence.length - 1) * STAGGER + DURATION + 250; + this.time.delayedCall(doneAt, () => { + this.gs = finalState; + this.animating = false; + this.renderAll(); + this.updateStatus(); + this.handlePostStateChange(); + }); + } + + // ── Card sprite factory ─────────────────────────────────────────────────── + + makeUnoCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) { + const c = this.add.container(x, y).setDepth(D.card); + c.setRotation((rotation * Math.PI) / 180); + c.setScale(scale); + this.renderUnoCardFace(c, card, faceUp); + c.card = card; + return c; + } + + renderUnoCardFace(container, card, faceUp) { + container.removeAll(true); + const x = -CARD_W / 2, y = -CARD_H / 2; + const g = this.add.graphics(); + + if (!faceUp || !card) { + // Card back — red oval on dark body with "UNO" branding. + g.fillStyle(0x111111, 1); + g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(2, 0xffffff, 0.6); + g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1); + g.fillStyle(0xd32f2f, 1); + g.fillEllipse(0, 0, CARD_W * 0.78, CARD_H * 0.5); + container.add(g); + const t = this.add.text(0, 0, 'UNO', { + fontFamily: 'Righteous', fontSize: '24px', color: '#fff8e1', + }).setOrigin(0.5).setRotation(-Math.PI / 12); + const t2 = this.add.text(0, 0, 'UNO', { + fontFamily: 'Righteous', fontSize: '24px', color: '#000000', + }).setOrigin(0.5).setRotation(-Math.PI / 12).setAlpha(0.25); + t2.setPosition(1, 1); + container.add([t2, t]); + return; + } + + // Card body — white frame with colored center. + g.fillStyle(0xffffff, 1); + g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R); + g.lineStyle(3, 0x111111, 1); + g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1); + const bodyColor = UNO_COLOR_HEX[card.color] ?? UNO_COLOR_HEX.w; + g.fillStyle(bodyColor, 1); + g.fillRoundedRect(x + 8, y + 8, CARD_W - 16, CARD_H - 16, CARD_R - 2); + container.add(g); + + if (card.isWild) { + // Four quadrants of color inside the body. + const cx = 0, cy = 0; + const w = CARD_W - 16, h = CARD_H - 16; + const cardX = x + 8, cardY = y + 8; + const quadG = this.add.graphics(); + quadG.fillStyle(UNO_COLOR_HEX.r, 1); + quadG.fillRect(cardX, cardY, w / 2, h / 2); + quadG.fillStyle(UNO_COLOR_HEX.y, 1); + quadG.fillRect(cardX + w / 2, cardY, w / 2, h / 2); + quadG.fillStyle(UNO_COLOR_HEX.b, 1); + quadG.fillRect(cardX, cardY + h / 2, w / 2, h / 2); + quadG.fillStyle(UNO_COLOR_HEX.g, 1); + quadG.fillRect(cardX + w / 2, cardY + h / 2, w / 2, h / 2); + container.add(quadG); + } + + // White ellipse with the label. + const ovalG = this.add.graphics(); + const ovalColor = card.isWild ? 0x000000 : 0xffffff; + ovalG.fillStyle(ovalColor, 1); + ovalG.fillEllipse(0, 0, CARD_W * 0.7, CARD_H * 0.55); + container.add(ovalG); + + // Center label. + const labelColor = card.isWild + ? '#ffffff' + : UNO_COLOR_HEXTXT[card.color] ?? COLORS.textDarkHex; + const labelText = card.label; + const isWide = labelText.length > 1; + const fontSize = isWide ? 36 : 48; + container.add( + this.add.text(0, 0, labelText, { + fontFamily: 'Righteous', + fontSize: `${fontSize}px`, + color: labelColor, + }).setOrigin(0.5), + ); + + // Corner labels (top-left, bottom-right). + const cornerColor = card.isWild ? '#ffffff' : COLORS.textHex; + const cornerStyle = (sz) => ({ + fontFamily: 'Righteous', fontSize: `${sz}px`, color: cornerColor, + }); + container.add( + this.add.text(x + 10, y + 8, labelText, cornerStyle(18)), + ); + container.add( + this.add.text(x + CARD_W - 10, y + CARD_H - 8, labelText, cornerStyle(18)) + .setOrigin(1, 1), + ); + + // If a wild's chosen color is set, indicate it via a small swatch. + if (card.isWild && card.chosenColor) { + const sw = this.add.graphics(); + sw.fillStyle(UNO_COLOR_HEX[card.chosenColor], 1); + sw.fillCircle(0, CARD_H / 2 - 18, 9); + sw.lineStyle(2, 0xffffff, 0.9); + sw.strokeCircle(0, CARD_H / 2 - 18, 9); + container.add(sw); + } + } + + clearAllCardObjs() { + for (const c of this.cardObjs.values()) c.destroy(); + this.cardObjs.clear(); + for (const o of this.transientObjs) o.destroy(); + this.transientObjs = []; + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + renderAll() { + this.clearAllCardObjs(); + this.renderDrawPile(); + this.renderDiscardPile(); + for (let seat = 0; seat < this.gs.players.length; seat++) { + this.renderSeat(seat); + } + this.renderHandCountChips(); + this.renderTurnIndicator(); + this.drawDirectionArrow(this.gs.direction); + this.updateColorSwatch(); + } + + renderDrawPile() { + const remaining = this.gs.drawPile.length; + if (remaining === 0) return; + // Render the top three card backs stacked for visual depth. + const stack = Math.min(remaining, 3); + for (let i = 0; i < stack; i++) { + const c = this.makeUnoCardSprite(null, DRAW_POS.x - i * 2, DRAW_POS.y - i * 2, { faceUp: false }); + c.setDepth(D.card - i); + this.cardObjs.set(`draw-${i}`, c); + } + // Top card is interactive on the local turn. + if (this.isLocalTurn() && this.gs.phase === 'play') { + const top = this.cardObjs.get('draw-0'); + if (top) { + top.setInteractive( + new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), + Phaser.Geom.Rectangle.Contains, + ); + top.input.cursor = 'pointer'; + top.on('pointerover', () => { + this.tweens.add({ targets: top, scaleX: 1.04, scaleY: 1.04, duration: 100 }); + top.setDepth(D.highlight); + }); + top.on('pointerout', () => { + this.tweens.add({ targets: top, scaleX: 1, scaleY: 1, duration: 100 }); + top.setDepth(D.card); + }); + top.on('pointerdown', () => this.onDrawClick()); + } + } + } + + renderDiscardPile() { + // Render just the top card (animations create extras as needed). + const top = this.gs.discardPile[this.gs.discardPile.length - 1]; + if (!top) return; + const c = this.makeUnoCardSprite(top, DISCARD_POS.x, DISCARD_POS.y, { faceUp: true }); + c.setDepth(D.card); + this.cardObjs.set('discard-top', c); + } + + handSpreadFor(seat, n) { + if (seat === 0) { + // Local player — face-up; compress when hand is large. + if (n <= 1) return HAND_SPREAD_MAX; + const maxSpread = HAND_FACEUP_MAX_W / Math.max(n - 1, 1); + return Math.min(HAND_SPREAD_MAX, maxSpread); + } + // Opponent hands — tighter fan, capped. + if (n <= 1) return 40; + const maxOppW = 600; + return Math.min(40, maxOppW / Math.max(n - 1, 1)); + } + + renderSeat(seat) { + const player = this.gs.players[seat]; + const slot = this.slotForSeat[seat]; + const layout = slotLayout(slot, this.gs.players.length); + const n = player.hand.length; + const spread = this.handSpreadFor(seat, n); + const localLegal = (seat === 0 && this.gs.phase === 'play' && this.isLocalTurn()) + ? new Set(legalPlays(this.gs, 0)) + : null; + + for (let i = 0; i < n; i++) { + const card = player.hand[i]; + const offset = i - (n - 1) / 2; + let x, y; + if (layout.handAxis === 'x') { + x = layout.handCenter.x + offset * spread; + y = layout.handCenter.y; + } else { + x = layout.handCenter.x; + y = layout.handCenter.y + offset * spread; + } + const c = this.makeUnoCardSprite(card, x, y, { + faceUp: layout.handFaceUp, + rotation: layout.rotateCards, + }); + this.cardObjs.set(`hand-${seat}-${card.id}`, c); + + if (seat === 0) { + c.setInteractive( + new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), + Phaser.Geom.Rectangle.Contains, + ); + c.input.cursor = 'pointer'; + const baseY = y; + c.on('pointerover', () => { + if (this.animating) return; + this.tweens.add({ targets: c, y: baseY - 14, duration: 120 }); + c.setDepth(D.highlight); + }); + c.on('pointerout', () => { + this.tweens.add({ targets: c, y: baseY, duration: 120 }); + c.setDepth(D.card); + }); + c.on('pointerdown', () => this.onHandCardClick(card.id)); + if (localLegal && !localLegal.has(card.id)) { + c.setAlpha(0.6); + } + } + } + } + + renderHandCountChips() { + for (let s = 0; s < this.gs.players.length; s++) { + const p = this.gs.players[s]; + const chip = this.seatChips[s]; + if (!chip) continue; + chip.count.setText(`${p.hand.length}`); + } + } + + renderTurnIndicator() { + const seat = this.gs.currentPlayer; + const slot = this.slotForSeat[seat]; + const lay = slotLayout(slot, this.gs.players.length); + if (!this.turnGlow) { + this.turnGlow = this.add.circle(0, 0, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1); + } + this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y); + } + + // ── Status / banner ──────────────────────────────────────────────────────── + + updateStatus() { + if (this.gameOver) { + this.statusText.setText(''); + this.statusBg.setVisible(false); + return; + } + if (!this.gs) return; + let msg; + switch (this.gs.phase) { + case 'choosingColor': + msg = this.isLocalTurn() || this.pendingWildPlayCardId !== null + ? 'Pick a color for your Wild card.' + : `${this.opponentName(this.gs.currentPlayer)} is picking a color…`; + break; + case 'challengeWindow': { + const challenger = this.gs.pendingWild4?.challengerSeat; + msg = challenger === 0 + ? `Challenge ${this.opponentName(this.gs.pendingWild4.playerSeat)}'s Wild +4?` + : `${this.opponentName(challenger)} is deciding whether to challenge…`; + break; + } + case 'mustPlayDrawn': { + const card = this.gs.pendingDrawn; + msg = this.isLocalTurn() + ? `Drew ${this.describeCard(card)} — play it or keep it.` + : `${this.opponentName(this.gs.currentPlayer)} drew a card…`; + break; + } + case 'play': + msg = this.isLocalTurn() + ? 'Your turn — tap a playable card or the draw pile.' + : `${this.opponentName(this.gs.currentPlayer)} is thinking…`; + break; + default: + msg = ''; + } + this.statusText.setText(msg); + this.refreshStatusBg(); + } + + refreshStatusBg() { + const t = this.statusText; + const pad = 8; + this.statusBg.clear(); + if (!t.text) { this.statusBg.setVisible(false); return; } + this.statusBg.fillStyle(0x000000, 0.55); + this.statusBg.fillRoundedRect(t.x - pad, t.y - t.height - pad, t.width + pad * 2, t.height + pad * 2, 6); + this.statusBg.setVisible(true); + } + + showBanner(text) { + this.bannerText.setText(text); + this.bannerText.setVisible(true); + this.bannerBg.clear(); + this.bannerBg.fillStyle(COLORS.panel, 0.85); + const w = Math.max(this.bannerText.width + 60, 360); + const h = this.bannerText.height + 28; + this.bannerBg.fillRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10); + this.bannerBg.lineStyle(2, COLORS.accent, 1); + this.bannerBg.strokeRoundedRect(this.bannerText.x - w / 2, this.bannerText.y - h / 2, w, h, 10); + this.bannerBg.setVisible(true); + } + + hideBanner() { + this.bannerText?.setVisible(false); + this.bannerBg?.setVisible(false); + } + + describeCard(card) { + if (!card) return ''; + if (card.isWild) return card.kind === 'wild4' ? 'a Wild +4' : 'a Wild'; + const colorName = UNO_COLOR_NAMES[card.color] ?? card.color; + switch (card.kind) { + case 'number': return `${colorName} ${card.value}`; + case 'skip': return `${colorName} Skip`; + case 'reverse': return `${colorName} Reverse`; + case 'draw2': return `${colorName} +2`; + default: return `${colorName} card`; + } + } + + // ── Turn flow ────────────────────────────────────────────────────────────── + + isLocalTurn() { + return !this.gameOver && this.gs && this.gs.currentPlayer === 0; + } + + opponentName(seat) { + if (seat === 0) return 'You'; + const opp = this.opponents[seat - 1]; + return opp?.name ?? `P${seat + 1}`; + } + + // ── Input (local player) ────────────────────────────────────────────────── + + onHandCardClick(cardId) { + if (!this.isLocalTurn() || this.animating) return; + if (this.gs.phase !== 'play') return; + const me = this.gs.players[0]; + const card = me.hand.find((c) => c.id === cardId); + if (!card) return; + if (!isLegalPlay(this.gs, card, me.hand)) { + this.shakeCardSprite(this.cardObjs.get(`hand-0-${card.id}`)); + this.showBanner(`That ${this.describeCard(card)} doesn't match.`); + this.time.delayedCall(1100, () => this.hideBanner()); + return; + } + if (card.isWild) { + this.pendingWildPlayCardId = card.id; + this.openColorPicker((color) => { + this.pendingWildPlayCardId = null; + this.executeLocalPlay(card.id, color); + }); + return; + } + this.executeLocalPlay(card.id); + } + + onDrawClick() { + if (!this.isLocalTurn() || this.animating) return; + if (this.gs.phase !== 'play') return; + this.executeLocalDraw(); + } + + // ── Execution wrappers ──────────────────────────────────────────────────── + + executeLocalPlay(cardId, chosenColor) { + this.animating = true; + const seat = 0; + const before = this.gs; + const card = before.players[seat].hand.find((c) => c.id === cardId); + if (!card) { this.animating = false; return; } + const after = applyPlay(before, seat, cardId); + if (after === before) { this.animating = false; return; } + + this.animatePlayCard(seat, card, () => { + this.gs = after; + this.renderAll(); + // Auto-call UNO when we've just dropped to 1 card. + if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); + this.handleLogEffects(before, after); + // If the played card was a Wild and we have a pre-chosen color, apply it. + if (chosenColor && this.gs.phase === 'choosingColor') { + this.time.delayedCall(150, () => this.executeChooseColor(chosenColor)); + return; + } + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + } + + executeLocalDraw() { + this.animating = true; + const seat = 0; + const before = this.gs; + const after = applyDraw(before, seat); + if (after === before) { this.animating = false; return; } + const drawnCard = after.lastAction?.kind === 'draw' ? after.lastAction.card : null; + this.animateDrawCard(seat, drawnCard, /* reveal */ true, () => { + this.gs = after; + this.renderAll(); + this.animating = false; + this.updateStatus(); + if (this.gs.phase === 'mustPlayDrawn') { + this.openPlayDrawnPrompt(); + } else { + this.handlePostStateChange(); + } + }); + } + + executeChooseColor(color) { + const before = this.gs; + const after = applyChooseColor(before, color); + if (after === before) return; + this.animating = true; + this.gs = after; + this.renderAll(); + this.showBanner(`Color set to ${UNO_COLOR_NAMES[color]}.`); + this.time.delayedCall(700, () => this.hideBanner()); + this.time.delayedCall(150, () => { + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + } + + executeChallenge(doChallenge) { + const before = this.gs; + const after = applyChallengeWild4(before, doChallenge); + if (after === before) return; + this.animating = true; + const last = after.lastAction; + const banner = this.formatChallengeBanner(last); + this.gs = after; + this.renderAll(); + if (banner) { + this.showBanner(banner); + this.time.delayedCall(1300, () => this.hideBanner()); + } + this.time.delayedCall(400, () => { + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + } + + formatChallengeBanner(last) { + if (!last || last.kind !== 'wild4Resolve') return null; + const who = this.opponentName(last.drawSeat); + if (last.result === 'accept') return `${who} draws ${last.drawCount} and is skipped.`; + if (last.result === 'challengeWin') return `Challenge succeeded — ${who} draws ${last.drawCount}.`; + if (last.result === 'challengeLose')return `Challenge failed — ${who} draws ${last.drawCount} and is skipped.`; + return null; + } + + /** + * Inspects the state transition's log entries for visual effects (skip, reverse, + * draw cards into opponents' hands) and animates each in a brief banner. + */ + handleLogEffects(before, after) { + const oldLen = before.log.length; + for (let i = oldLen; i < after.log.length; i++) { + const e = after.log[i]; + if (e.kind === 'skip') { + this.showBanner(`${this.opponentName(e.seat)} is skipped!`); + this.time.delayedCall(900, () => this.hideBanner()); + } else if (e.kind === 'reverse') { + this.showBanner('Direction reversed.'); + this.time.delayedCall(900, () => this.hideBanner()); + } else if (e.kind === 'draw2') { + this.showBanner(`${this.opponentName(e.seat)} draws 2 and is skipped.`); + this.time.delayedCall(1100, () => this.hideBanner()); + } else if (e.kind === 'win') { + // handled by endGame + } + } + } + + // ── Animations ──────────────────────────────────────────────────────────── + + animatePlayCard(seat, card, onComplete) { + const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length); + const handFaceUp = layout.handFaceUp; + const fromSprite = this.cardObjs.get(`hand-${seat}-${card.id}`); + let sprite; + if (fromSprite) { + sprite = fromSprite; + this.cardObjs.delete(`hand-${seat}-${card.id}`); + } else { + // Fallback: spawn at the hand center. + sprite = this.makeUnoCardSprite(card, layout.handCenter.x, layout.handCenter.y, { + faceUp: handFaceUp, rotation: layout.rotateCards, + }); + } + sprite.setDepth(D.banner - 4); + this.transientObjs.push(sprite); + + playSound(this, SFX.CARD_PLACE); + this.tweens.add({ + targets: sprite, + x: DISCARD_POS.x, y: DISCARD_POS.y, + rotation: 0, + duration: 360, + ease: 'Cubic.easeOut', + onComplete: () => { + if (!handFaceUp) this.renderUnoCardFace(sprite, card, true); + sprite.setDepth(D.card); + // Small bounce. + this.tweens.add({ + targets: sprite, scaleX: 1.08, scaleY: 1.08, yoyo: true, duration: 90, + onComplete: () => onComplete && onComplete(), + }); + }, + }); + } + + /** + * Animate a single card being drawn from the draw pile to `seat`'s hand. + * If `reveal` is true (local player), flip the card face-up in place midway. + */ + animateDrawCard(seat, card, reveal, onComplete) { + const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length); + const sprite = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, { + faceUp: false, rotation: layout.rotateCards, + }); + sprite.setDepth(D.banner - 4); + this.transientObjs.push(sprite); + playSound(this, SFX.CARD_DEAL); + + const finishFly = () => { + const dest = { x: layout.handCenter.x, y: layout.handCenter.y }; + this.tweens.add({ + targets: sprite, + x: dest.x, y: dest.y, + rotation: 0, + duration: 400, + ease: 'Cubic.easeIn', + onComplete: () => onComplete && onComplete(), + }); + }; + + if (reveal && card) { + this.flipCardFaceUp(sprite, card, () => { + this.time.delayedCall(650, finishFly); + }); + } else { + finishFly(); + } + } + + /** + * Animate `count` card backs flying from the draw pile into `seat`'s hand + * (for forced draws — Draw 2, Wild +4 effects). + */ + animateBatchDraw(seat, count, onComplete) { + if (count <= 0) { onComplete && onComplete(); return; } + const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length); + let remaining = count; + const fire = (i) => { + const sprite = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, { + faceUp: false, rotation: layout.rotateCards, + }); + sprite.setDepth(D.banner - 5); + this.transientObjs.push(sprite); + playSound(this, SFX.CARD_DEAL); + this.tweens.add({ + targets: sprite, + x: layout.handCenter.x, y: layout.handCenter.y, + duration: 320, + ease: 'Cubic.easeIn', + onComplete: () => { + remaining -= 1; + if (remaining === 0) onComplete && onComplete(); + }, + }); + }; + for (let i = 0; i < count; i++) { + this.time.delayedCall(i * 110, () => fire(i)); + } + } + + flipCardFaceUp(container, card, onComplete) { + this.tweens.add({ + targets: container, scaleX: 0, duration: 150, ease: 'Linear', + onComplete: () => { + container.setRotation(0); + this.renderUnoCardFace(container, card, true); + this.tweens.add({ + targets: container, scaleX: 1, duration: 150, ease: 'Linear', + onComplete: () => onComplete && onComplete(), + }); + }, + }); + } + + shakeCardSprite(sprite) { + if (!sprite) return; + const x0 = sprite.x; + this.tweens.add({ + targets: sprite, + x: { from: x0 - 6, to: x0 }, + duration: 80, yoyo: true, repeat: 2, ease: 'Sine.easeInOut', + }); + } + + showUnoCallout(seat) { + const slot = this.slotForSeat[seat]; + const layout = slotLayout(slot, this.gs.players.length); + const t = this.add.text(layout.portrait.x, layout.portrait.y - 70, 'UNO!', { + fontFamily: 'Righteous', fontSize: '64px', color: '#d32f2f', + stroke: '#ffffff', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.banner + 2).setScale(0.2); + this.transientObjs.push(t); + playSound(this, SFX.CASINO_WIN); + this.tweens.add({ + targets: t, scaleX: 1, scaleY: 1, duration: 280, ease: 'Back.easeOut', + onComplete: () => { + this.tweens.add({ + targets: t, alpha: 0, duration: 600, delay: 700, + onComplete: () => { if (t.active) t.destroy(); }, + }); + }, + }); + } + + // ── Color picker modal ──────────────────────────────────────────────────── + + openColorPicker(onPick) { + if (this.colorPicker) this.closeColorPicker(); + const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setDepth(D.modal).setInteractive(); + const title = this.add.text(CX, CY - 170, 'Choose a color', { + fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.modalUI); + + const swatchSize = 140; + const gap = 24; + const half = swatchSize / 2; + const positions = { + r: { x: CX - swatchSize / 2 - gap / 2, y: CY - half - gap / 2 }, + y: { x: CX + swatchSize / 2 + gap / 2, y: CY - half - gap / 2 }, + g: { x: CX - swatchSize / 2 - gap / 2, y: CY + half + gap / 2 }, + b: { x: CX + swatchSize / 2 + gap / 2, y: CY + half + gap / 2 }, + }; + const swatches = []; + for (const color of ['r', 'y', 'g', 'b']) { + const pos = positions[color]; + const g = this.add.graphics().setDepth(D.modalUI); + g.fillStyle(UNO_COLOR_HEX[color], 1); + g.fillRoundedRect(pos.x - half, pos.y - half, swatchSize, swatchSize, 12); + g.lineStyle(3, 0xffffff, 0.9); + g.strokeRoundedRect(pos.x - half, pos.y - half, swatchSize, swatchSize, 12); + const label = this.add.text(pos.x, pos.y, UNO_COLOR_NAMES[color], { + fontFamily: 'Righteous', fontSize: '24px', color: '#ffffff', + }).setOrigin(0.5).setDepth(D.modalUI + 1); + const hit = this.add.rectangle(pos.x, pos.y, swatchSize, swatchSize, 0xffffff, 0) + .setInteractive({ useHandCursor: true }).setDepth(D.modalUI + 2); + hit.on('pointerover', () => g.setAlpha(0.85)); + hit.on('pointerout', () => g.setAlpha(1)); + hit.on('pointerdown', () => { + this.closeColorPicker(); + onPick(color); + }); + swatches.push(g, label, hit); + } + this.colorPicker = { overlay, title, swatches }; + } + + closeColorPicker() { + if (!this.colorPicker) return; + this.colorPicker.overlay.destroy(); + this.colorPicker.title.destroy(); + for (const o of this.colorPicker.swatches) o.destroy(); + this.colorPicker = null; + } + + // ── Challenge prompt ────────────────────────────────────────────────────── + + openChallengePrompt() { + if (this.challengePrompt) this.closeChallengePrompt(); + const pw = this.gs.pendingWild4; + const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setDepth(D.modal).setInteractive(); + const panel = this.add.rectangle(CX, CY, 720, 280, COLORS.panel, 1) + .setStrokeStyle(2, COLORS.accent).setDepth(D.modalUI); + const title = this.add.text(CX, CY - 80, `${this.opponentName(pw.playerSeat)} played Wild +4`, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.modalUI + 1); + const sub = this.add.text(CX, CY - 30, 'Win the challenge: they draw 4.\nLose the challenge: you draw 6 and lose your turn.', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5).setDepth(D.modalUI + 1); + const challengeBtn = new Button(this, CX - 130, CY + 70, 'Challenge', () => { + this.closeChallengePrompt(); + this.executeChallenge(true); + }, { width: 220, height: 56, fontSize: 22 }).setDepth(D.modalUI + 1); + const acceptBtn = new Button(this, CX + 130, CY + 70, 'Accept (draw 4)', () => { + this.closeChallengePrompt(); + this.executeChallenge(false); + }, { variant: 'ghost', width: 240, height: 56, fontSize: 22 }).setDepth(D.modalUI + 1); + this.challengePrompt = { overlay, panel, title, sub, challengeBtn, acceptBtn }; + } + + closeChallengePrompt() { + if (!this.challengePrompt) return; + for (const k of Object.keys(this.challengePrompt)) this.challengePrompt[k].destroy(); + this.challengePrompt = null; + } + + // ── Play-drawn prompt ───────────────────────────────────────────────────── + + openPlayDrawnPrompt() { + if (this.drawnPrompt) this.closeDrawnPrompt(); + const card = this.gs.pendingDrawn; + const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.5) + .setDepth(D.modal).setInteractive(); + const panel = this.add.rectangle(CX, CY, 680, 280, COLORS.panel, 1) + .setStrokeStyle(2, COLORS.accent).setDepth(D.modalUI); + const title = this.add.text(CX, CY - 80, `You drew ${this.describeCard(card)}`, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.modalUI + 1); + const sub = this.add.text(CX, CY - 30, "Play it now or keep it.", { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.modalUI + 1); + const playBtn = new Button(this, CX - 130, CY + 70, 'Play', () => { + this.closeDrawnPrompt(); + this.executeLocalPlayDrawn(); + }, { width: 220, height: 56, fontSize: 22 }).setDepth(D.modalUI + 1); + const keepBtn = new Button(this, CX + 130, CY + 70, 'Keep', () => { + this.closeDrawnPrompt(); + this.executeLocalPassDrawn(); + }, { variant: 'ghost', width: 220, height: 56, fontSize: 22 }).setDepth(D.modalUI + 1); + this.drawnPrompt = { overlay, panel, title, sub, playBtn, keepBtn }; + } + + closeDrawnPrompt() { + if (!this.drawnPrompt) return; + for (const k of Object.keys(this.drawnPrompt)) this.drawnPrompt[k].destroy(); + this.drawnPrompt = null; + } + + executeLocalPlayDrawn() { + const seat = 0; + const card = this.gs.pendingDrawn; + if (!card) return; + if (card.isWild) { + this.openColorPicker((color) => { + const after = applyPlayDrawn(this.gs, seat); + if (after === this.gs) return; + this.animating = true; + this.animatePlayCard(seat, card, () => { + this.gs = after; + this.renderAll(); + if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); + if (this.gs.phase === 'choosingColor') { + this.time.delayedCall(120, () => this.executeChooseColor(color)); + } else { + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + } + }); + }); + return; + } + const before = this.gs; + const after = applyPlayDrawn(before, seat); + if (after === before) return; + this.animating = true; + this.animatePlayCard(seat, card, () => { + this.gs = after; + this.renderAll(); + if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); + this.handleLogEffects(before, after); + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + } + + executeLocalPassDrawn() { + const seat = 0; + const before = this.gs; + const after = applyPassAfterDraw(before, seat); + if (after === before) return; + this.gs = after; + this.renderAll(); + this.updateStatus(); + this.handlePostStateChange(); + } + + closeAnyModal() { + this.closeColorPicker(); + this.closeChallengePrompt(); + this.closeDrawnPrompt(); + } + + // ── AI scheduling ───────────────────────────────────────────────────────── + + handlePostStateChange() { + if (this.animating) return; + if (isGameOver(this.gs)) { this.endGame(); return; } + // If it's our turn but we just entered challengeWindow as the challenger, + // open the prompt. + if (this.gs.phase === 'challengeWindow' && this.gs.pendingWild4?.challengerSeat === 0) { + this.openChallengePrompt(); + return; + } + // If it's our turn but the engine wants a color choice (starting Wild), + // open the picker. + if (this.gs.phase === 'choosingColor' && this.gs.currentPlayer === 0 + && this.pendingWildPlayCardId === null) { + this.openColorPicker((color) => this.executeChooseColor(color)); + return; + } + if (this.isLocalTurn() && this.gs.phase === 'play') return; + if (this.isLocalTurn() && this.gs.phase === 'mustPlayDrawn') { + this.openPlayDrawnPrompt(); + return; + } + // Otherwise, schedule an AI step. + this.time.delayedCall(700, () => this.runAIStep()); + } + + runAIStep() { + if (this.gameOver || this.animating) return; + if (!this.gs) return; + if (isGameOver(this.gs)) { this.endGame(); return; } + + // Some phases require action from a non-current player (challenge window). + let actingSeat = this.gs.currentPlayer; + if (this.gs.phase === 'challengeWindow') actingSeat = this.gs.pendingWild4.challengerSeat; + if (actingSeat === 0) { + // Local needs to act — defer to local input. + if (this.gs.phase === 'challengeWindow') this.openChallengePrompt(); + else if (this.gs.phase === 'mustPlayDrawn') this.openPlayDrawnPrompt(); + else if (this.gs.phase === 'choosingColor') this.openColorPicker((c) => this.executeChooseColor(c)); + else this.updateStatus(); + return; + } + + const action = chooseAction(this.gs, actingSeat); + this.executeAIAction(actingSeat, action); + } + + executeAIAction(seat, action) { + if (!action) return; + const before = this.gs; + + if (action.kind === 'play') { + const card = before.players[seat].hand.find((c) => c.id === action.cardId); + if (!card) return; + const after = applyPlay(before, seat, action.cardId); + if (after === before) return; + this.animating = true; + this.animatePlayCard(seat, card, () => { + this.gs = after; + this.renderAll(); + if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); + this.handleLogEffects(before, after); + // If wild, the AI has a pre-chosen color. + if (action.chosenColor && this.gs.phase === 'choosingColor') { + this.time.delayedCall(450, () => { + const after2 = applyChooseColor(this.gs, action.chosenColor); + this.gs = after2; + this.renderAll(); + this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.chosenColor]}.`); + this.time.delayedCall(900, () => this.hideBanner()); + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + return; + } + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + return; + } + + if (action.kind === 'draw') { + const after = applyDraw(before, seat); + if (after === before) return; + this.animating = true; + // AI never reveals the drawn card. + this.animateDrawCard(seat, null, false, () => { + this.gs = after; + this.renderAll(); + this.animating = false; + this.updateStatus(); + // If the drawn card was playable, the AI will choose play/pass on the next step. + this.handlePostStateChange(); + }); + return; + } + + if (action.kind === 'playDrawn') { + const card = before.pendingDrawn; + if (!card) return; + const after = applyPlayDrawn(before, seat); + if (after === before) return; + this.animating = true; + this.animatePlayCard(seat, card, () => { + this.gs = after; + this.renderAll(); + if (this.gs.players[seat].hand.length === 1) this.showUnoCallout(seat); + this.handleLogEffects(before, after); + if (this.gs.phase === 'choosingColor') { + // AI just played a wild as the drawn card — pick its color now. + const color = chooseAction(this.gs, seat).color ?? 'r'; + this.time.delayedCall(450, () => { + const after2 = applyChooseColor(this.gs, color); + this.gs = after2; + this.renderAll(); + this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[color]}.`); + this.time.delayedCall(900, () => this.hideBanner()); + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + return; + } + this.animating = false; + this.updateStatus(); + this.handlePostStateChange(); + }); + return; + } + + if (action.kind === 'passDrawn') { + const after = applyPassAfterDraw(before, seat); + if (after === before) return; + this.gs = after; + this.renderAll(); + this.updateStatus(); + this.handlePostStateChange(); + return; + } + + if (action.kind === 'chooseColor') { + const after = applyChooseColor(before, action.color); + if (after === before) return; + this.gs = after; + this.renderAll(); + this.showBanner(`${this.opponentName(seat)} chose ${UNO_COLOR_NAMES[action.color]}.`); + this.time.delayedCall(900, () => this.hideBanner()); + this.updateStatus(); + this.handlePostStateChange(); + return; + } + + if (action.kind === 'challenge') { + this.executeChallenge(action.doChallenge); + return; + } + } + + // ── Game over ────────────────────────────────────────────────────────────── + + endGame() { + if (this.gameOver) return; + this.gameOver = true; + this.hideBanner(); + const winner = this.gs.winnerSeat; + const lines = [ + winner === 0 ? 'You won!' : `${this.opponentName(winner)} wins.`, + ]; + for (let s = 0; s < this.gs.players.length; s++) { + lines.push(`${this.opponentName(s)}: ${this.gs.players[s].hand.length} card${this.gs.players[s].hand.length === 1 ? '' : 's'} remaining`); + } + playSound(this, winner === 0 ? SFX.CASINO_WIN : SFX.CASINO_LOSE); + new Modal(this, lines.join('\n'), {}).setDepth(D.modal); + } +} diff --git a/public/src/games/uno/UnoLogic.js b/public/src/games/uno/UnoLogic.js new file mode 100644 index 0000000..4af42d5 --- /dev/null +++ b/public/src/games/uno/UnoLogic.js @@ -0,0 +1,446 @@ +// Uno — pure state engine. No Phaser imports. +// +// Mattel rules (official): +// - 108-card deck; 7 cards dealt to each of 2..4 players. +// - On your turn, play a card matching the top discard's color, number, or +// action; or play a Wild; or draw one card from the draw pile. If the drawn +// card is playable you may play it immediately; otherwise the turn passes. +// - Action cards: Skip (next player loses turn), Reverse (flip direction; +// in a 2-player game it acts like a Skip), Draw 2 (next player draws 2 and +// loses turn), Wild (choose color), Wild Draw 4 (choose color; next player +// draws 4 and loses turn — but may challenge first). +// - Wild Draw 4 is only legal if you have no card matching the active color. +// If challenged and the challenge succeeds, the player who played it draws +// the 4 and play continues normally; if the challenge fails, the challenger +// draws 6 instead and loses their turn. +// - No stacking, no jump-in, no 7-0 swap. +// - First player to empty their hand wins. + +import { buildDeck, cloneCard, UNO_COLORS } from './UnoDeck.js'; + +export const HAND_DEAL = 7; + +// ── PRNG (Mulberry32) — mirrors GoFishLogic for seedable shuffles. ────────── +function rng(seed) { + let a = (seed >>> 0) || 1; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffle(arr, seed) { + const rand = seed === undefined ? Math.random : rng(seed); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } +} + +// ── State helpers ─────────────────────────────────────────────────────────── +export function cloneState(state) { + return { + players: state.players.map((p) => ({ + seat: p.seat, + hand: p.hand.map(cloneCard), + })), + drawPile: state.drawPile.map(cloneCard), + discardPile: state.discardPile.map(cloneCard), + currentPlayer: state.currentPlayer, + direction: state.direction, + currentColor: state.currentColor, + phase: state.phase, + pendingWild4: state.pendingWild4 ? { ...state.pendingWild4 } : null, + pendingDrawn: state.pendingDrawn ? cloneCard(state.pendingDrawn) : null, + lastAction: state.lastAction ? { ...state.lastAction } : null, + log: state.log.map((e) => ({ ...e })), + winnerSeat: state.winnerSeat, + seed: state.seed, + turnCount: state.turnCount, + }; +} + +function topDiscard(state) { + return state.discardPile[state.discardPile.length - 1]; +} + +function nextSeat(state, fromSeat = state.currentPlayer, hops = 1) { + const N = state.players.length; + let s = fromSeat; + for (let i = 0; i < hops; i++) { + s = ((s + state.direction) % N + N) % N; + } + return s; +} + +function advanceTurn(state, hops = 1) { + state.currentPlayer = nextSeat(state, state.currentPlayer, hops); + state.turnCount += 1; +} + +/** + * If the draw pile is empty, take all but the top discard, reset wilds, shuffle, + * and make it the new draw pile. No-op if the draw pile still has cards. + */ +function reshuffleIfNeeded(state) { + if (state.drawPile.length > 0) return; + if (state.discardPile.length <= 1) return; // shouldn't happen mid-game + const top = state.discardPile.pop(); + const recycled = state.discardPile; + for (const c of recycled) { + if (c.color === 'w') c.chosenColor = null; + } + shuffle(recycled); + state.drawPile = recycled; + state.discardPile = [top]; + state.log.push({ kind: 'reshuffle', count: recycled.length }); +} + +function drawN(state, seat, n) { + const drawn = []; + for (let i = 0; i < n; i++) { + reshuffleIfNeeded(state); + if (state.drawPile.length === 0) break; // pathological — both piles dry + const c = state.drawPile.pop(); + state.players[seat].hand.push(c); + drawn.push(c); + } + return drawn; +} + +// ── Legality ──────────────────────────────────────────────────────────────── +/** + * Whether `card` may be played on top of the current discard given + * state.currentColor. Wilds are always legal except Wild +4, which is only + * legal when the player has no card matching state.currentColor (other wilds + * don't block it). + */ +export function isLegalPlay(state, card, playerHand) { + if (card.color === 'w') { + if (card.kind === 'wild4') { + // Only legal when the player has no card matching the active color + // (wilds in hand don't count as a matching color). + return !playerHand.some((c) => c.color === state.currentColor); + } + return true; + } + if (card.color === state.currentColor) return true; + const top = topDiscard(state); + // Match by kind when colors differ. For number cards: same value. + if (card.kind === 'number' && top.kind === 'number' && card.value === top.value) return true; + if (card.kind !== 'number' && card.kind === top.kind) return true; + return false; +} + +/** Returns the list of card ids the seat could legally play right now. */ +export function legalPlays(state, seat) { + if (state.phase !== 'play') return []; + if (state.currentPlayer !== seat) return []; + const hand = state.players[seat].hand; + return hand.filter((c) => isLegalPlay(state, c, hand)).map((c) => c.id); +} + +/** True if the seat could legally play any card (used before forcing a draw). */ +export function hasAnyLegalPlay(state, seat) { + return legalPlays(state, seat).length > 0; +} + +// ── Initial state ─────────────────────────────────────────────────────────── +export function createInitialState({ playerCount = 4, seed } = {}) { + if (playerCount < 2 || playerCount > 4) { + throw new Error(`Uno supports 2..4 players, got ${playerCount}`); + } + const deck = buildDeck(); + shuffle(deck, seed); + + const players = []; + for (let i = 0; i < playerCount; i++) { + players.push({ seat: i, hand: deck.splice(0, HAND_DEAL) }); + } + + // Flip top discard, re-flipping if it's a Wild +4 (Mattel rule). + let top = deck.pop(); + while (top.kind === 'wild4') { + deck.unshift(top); + shuffle(deck); + top = deck.pop(); + } + + const state = { + players, + drawPile: deck, + discardPile: [top], + currentPlayer: 0, + direction: 1, + currentColor: top.color === 'w' ? null : top.color, + phase: 'play', + pendingWild4: null, + pendingDrawn: null, + lastAction: null, + log: [{ kind: 'start', topCard: cloneCard(top) }], + winnerSeat: null, + seed: seed ?? null, + turnCount: 0, + }; + + // Apply starting-card effects (the first player has not yet acted). + if (top.kind === 'wild') { + // First player must choose a color before play begins. + state.phase = 'choosingColor'; + state.lastAction = { kind: 'startingWild' }; + } else if (top.kind === 'skip') { + state.log.push({ kind: 'startSkip', seat: state.currentPlayer }); + advanceTurn(state, 1); + } else if (top.kind === 'reverse') { + state.direction = -1; + state.log.push({ kind: 'startReverse' }); + if (playerCount === 2) { + // 2-player reverse acts as a skip — first player loses their turn. + advanceTurn(state, 1); + } else { + // Direction reversed; the "first" player becomes the one before seat 0. + state.currentPlayer = nextSeat(state, 0, 1); + } + } else if (top.kind === 'draw2') { + const drawn = drawN(state, state.currentPlayer, 2); + state.log.push({ kind: 'startDraw2', seat: state.currentPlayer, count: drawn.length }); + advanceTurn(state, 1); + } + + return state; +} + +// ── Actions ───────────────────────────────────────────────────────────────── + +/** + * Play `cardId` from `seat`'s hand. For wilds, the engine transitions to + * 'choosingColor' phase — call applyChooseColor next. Returns the same state + * (unchanged) if the action is illegal. + */ +export function applyPlay(state, seat, cardId) { + if (state.phase !== 'play') return state; + if (state.currentPlayer !== seat) return state; + const player = state.players[seat]; + const idx = player.hand.findIndex((c) => c.id === cardId); + if (idx === -1) return state; + const card = player.hand[idx]; + if (!isLegalPlay(state, card, player.hand)) return state; + + const next = cloneState(state); + const me = next.players[seat]; + // Snapshot whether the player had a color match BEFORE removing the card, + // for Wild +4 challenge resolution. + const hadColorMatch = me.hand.some( + (c) => c.id !== cardId && c.color === next.currentColor, + ); + const played = me.hand.splice(idx, 1)[0]; + played.chosenColor = null; + next.discardPile.push(played); + next.lastAction = { kind: 'play', seat, card: cloneCard(played) }; + next.log.push({ kind: 'play', seat, cardId: played.id }); + + // Win check — playing your last card wins immediately. + if (me.hand.length === 0) { + next.winnerSeat = seat; + next.phase = 'gameOver'; + next.log.push({ kind: 'win', seat }); + return next; + } + + // Resolve effects. + if (played.kind === 'wild') { + next.phase = 'choosingColor'; + return next; + } + if (played.kind === 'wild4') { + next.phase = 'choosingColor'; + next.pendingWild4 = { + playerSeat: seat, + challengerSeat: nextSeat(next, seat, 1), + hadColorMatch, + }; + return next; + } + if (played.kind === 'skip') { + next.currentColor = played.color; + const skipped = nextSeat(next, seat, 1); + next.log.push({ kind: 'skip', seat: skipped }); + advanceTurn(next, 2); + return next; + } + if (played.kind === 'reverse') { + next.currentColor = played.color; + next.direction = -next.direction; + next.log.push({ kind: 'reverse' }); + if (next.players.length === 2) { + // 2-player Reverse acts as Skip — same player goes again. With only two + // seats, flipping direction alone still hands the turn to the opponent, + // so we advance by 2 to wrap back to the same seat. + advanceTurn(next, 2); + } else { + advanceTurn(next, 1); + } + return next; + } + if (played.kind === 'draw2') { + next.currentColor = played.color; + const target = nextSeat(next, seat, 1); + const drawn = drawN(next, target, 2); + next.log.push({ kind: 'draw2', seat: target, count: drawn.length }); + advanceTurn(next, 2); + return next; + } + // Number card. + next.currentColor = played.color; + advanceTurn(next, 1); + return next; +} + +/** + * Choose the color after playing a Wild or Wild +4 (or after the starting Wild + * top-discard). Returns the same state if not currently in 'choosingColor'. + */ +export function applyChooseColor(state, color) { + if (state.phase !== 'choosingColor') return state; + if (!UNO_COLORS.includes(color)) return state; + const next = cloneState(state); + next.currentColor = color; + const top = topDiscard(next); + if (top.color === 'w') top.chosenColor = color; + next.log.push({ kind: 'chooseColor', color }); + + // Resolve the pending follow-up. + if (next.pendingWild4) { + next.phase = 'challengeWindow'; + next.lastAction = { kind: 'wild4Pending', color }; + return next; + } + + // After a regular Wild: advance turn. + // After a starting Wild (top discard was wild at game start): the same seat 0 + // gets the first play — do NOT advance. + const wasStartingWild = next.lastAction?.kind === 'startingWild'; + next.phase = 'play'; + if (!wasStartingWild) advanceTurn(next, 1); + next.lastAction = { kind: 'chooseColor', color }; + return next; +} + +/** + * The challenger either accepts the Wild +4 or challenges it. Resolves the + * draw and turn order accordingly. + */ +export function applyChallengeWild4(state, doChallenge) { + if (state.phase !== 'challengeWindow') return state; + const next = cloneState(state); + const pw = next.pendingWild4; + if (!pw) return state; + + if (!doChallenge) { + // Accept: challenger draws 4 and is skipped. + const drawn = drawN(next, pw.challengerSeat, 4); + next.log.push({ kind: 'wild4Accept', seat: pw.challengerSeat, count: drawn.length }); + next.lastAction = { + kind: 'wild4Resolve', + result: 'accept', + drawSeat: pw.challengerSeat, + drawCount: drawn.length, + }; + // Move past the player who played the +4 and past the now-skipped challenger. + next.currentPlayer = pw.playerSeat; + advanceTurn(next, 2); + next.pendingWild4 = null; + next.phase = 'play'; + return next; + } + + if (pw.hadColorMatch) { + // Successful challenge: WD4 player draws 4, play continues normally. + const drawn = drawN(next, pw.playerSeat, 4); + next.log.push({ kind: 'wild4ChallengeWin', seat: pw.playerSeat, count: drawn.length }); + next.lastAction = { + kind: 'wild4Resolve', + result: 'challengeWin', + drawSeat: pw.playerSeat, + drawCount: drawn.length, + }; + next.currentPlayer = pw.challengerSeat; + next.turnCount += 1; + } else { + // Failed challenge: challenger draws 6 and is skipped. + const drawn = drawN(next, pw.challengerSeat, 6); + next.log.push({ kind: 'wild4ChallengeLose', seat: pw.challengerSeat, count: drawn.length }); + next.lastAction = { + kind: 'wild4Resolve', + result: 'challengeLose', + drawSeat: pw.challengerSeat, + drawCount: drawn.length, + }; + next.currentPlayer = pw.playerSeat; + advanceTurn(next, 2); + } + next.pendingWild4 = null; + next.phase = 'play'; + return next; +} + +/** + * Draw a single card from the draw pile. If the drawn card is playable the + * state transitions to 'mustPlayDrawn' and the UI prompts the player to keep + * or play it. Otherwise the turn advances automatically. + */ +export function applyDraw(state, seat) { + if (state.phase !== 'play') return state; + if (state.currentPlayer !== seat) return state; + const next = cloneState(state); + const drawn = drawN(next, seat, 1); + if (drawn.length === 0) { + // Both piles dry — pathological. Just advance the turn. + advanceTurn(next, 1); + return next; + } + const card = drawn[0]; + next.lastAction = { kind: 'draw', seat, card: cloneCard(card) }; + next.log.push({ kind: 'draw', seat, count: 1 }); + if (isLegalPlay(next, card, next.players[seat].hand)) { + next.phase = 'mustPlayDrawn'; + next.pendingDrawn = cloneCard(card); + return next; + } + advanceTurn(next, 1); + return next; +} + +/** + * After drawing, the player elects to play the drawn card. Internally this is + * just applyPlay (with the same card id), but it transitions from + * 'mustPlayDrawn' back to 'play' first. + */ +export function applyPlayDrawn(state, seat) { + if (state.phase !== 'mustPlayDrawn') return state; + if (state.currentPlayer !== seat) return state; + if (!state.pendingDrawn) return state; + const cardId = state.pendingDrawn.id; + const intermediate = cloneState(state); + intermediate.phase = 'play'; + intermediate.pendingDrawn = null; + return applyPlay(intermediate, seat, cardId); +} + +/** After drawing a playable card, elect to keep it and pass. */ +export function applyPassAfterDraw(state, seat) { + if (state.phase !== 'mustPlayDrawn') return state; + if (state.currentPlayer !== seat) return state; + const next = cloneState(state); + next.phase = 'play'; + next.pendingDrawn = null; + advanceTurn(next, 1); + return next; +} + +export function isGameOver(state) { + return state.phase === 'gameOver'; +} diff --git a/public/src/main.js b/public/src/main.js index 3372041..7da8460 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -20,6 +20,7 @@ import SkipBoGame from './games/skipbo/SkipBoGame.js'; import Phase10Game from './games/phase10/Phase10Game.js'; import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js'; import GoFishGame from './games/gofish/GoFishGame.js'; +import UnoGame from './games/uno/UnoGame.js'; const config = { type: Phaser.AUTO, @@ -53,6 +54,7 @@ const config = { Phase10Game, ChineseCheckersGame, GoFishGame, + UnoGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 08ac564..eb043bc 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/multiplayer/gameRegistry.js b/server/multiplayer/gameRegistry.js index d02c604..1bd6e03 100644 --- a/server/multiplayer/gameRegistry.js +++ b/server/multiplayer/gameRegistry.js @@ -34,3 +34,4 @@ registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: tru registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false }); registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false }); registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false }); +registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });