diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 269ce15..91d8046 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 9c5c4bd..c1183d5 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/geniussquare/GeniusSquareAI.js b/public/src/games/geniussquare/GeniusSquareAI.js new file mode 100644 index 0000000..7a648d2 --- /dev/null +++ b/public/src/games/geniussquare/GeniusSquareAI.js @@ -0,0 +1,52 @@ +// Genius Square AI — pre-solves the board at round start, then ticks through +// placements at a skill-scaled delay (mirrors the Nerts real-time pattern). +// +// The tick loop lives in GeniusSquareGame.js (Phaser's time.delayedCall). +// This module is pure JS with no Phaser dependency. + +import { solveFromBlockers } from './GeniusSquareLogic.js'; + +const SKILL_PROFILES = { + 1: { delay: [8000, 12000] }, // very slow — human wins easily + 2: { delay: [4000, 7000] }, + 3: { delay: [2000, 4000] }, // balanced + 4: { delay: [1000, 2000] }, // challenging + 5: { delay: [400, 800] }, // expert +}; + +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} + +/** Milliseconds until the AI's next piece placement, randomized within skill's band. */ +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +/** + * Create AI state. Pass in a pre-computed solution to avoid solving twice. + * solution: array of { pieceId, oriIdx, anchorR, anchorC, cells } | null + */ +export function createAIState(blockers, skill, solution) { + return { + skill, + solution: solution ?? solveFromBlockers(blockers), + stepIndex: 0, + done: false, + }; +} + +/** + * Return the next placement to visually execute, or null if done. + * Mutates aiState.stepIndex and aiState.done. + */ +export function getNextPlacement(aiState) { + if (aiState.done || !aiState.solution || aiState.stepIndex >= aiState.solution.length) { + return null; + } + const placement = aiState.solution[aiState.stepIndex]; + aiState.stepIndex++; + if (aiState.stepIndex >= aiState.solution.length) aiState.done = true; + return placement; +} diff --git a/public/src/games/geniussquare/GeniusSquareGame.js b/public/src/games/geniussquare/GeniusSquareGame.js new file mode 100644 index 0000000..b9cbc92 --- /dev/null +++ b/public/src/games/geniussquare/GeniusSquareGame.js @@ -0,0 +1,600 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { auth } from '../../services/auth.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { + PIECES, ORIENTATIONS, + rollDice, newBoard, absoluteCells, canPlace, placePiece, removePiece, isSolved, + rotateOri, flipOri, solveFromBlockers, +} from './GeniusSquareLogic.js'; +import { createAIState, nextThinkDelay, getNextPlacement } from './GeniusSquareAI.js'; + +// ── Layout ───────────────────────────────────────────────────────────────────── +const CELL = 90; +const GRID_PX = 6 * CELL; // 540 +const HGX = 80; // human grid left +const HGY = 190; // human grid top +const AGX = 1300; // AI grid left +const AGY = 190; // AI grid top +const HCX = HGX + GRID_PX / 2; // 350 — human column center x +const ACX = AGX + GRID_PX / 2; // 1570 — AI column center x +const SLOT_W = 165; +const SLOT_H = 92; +const TRAY_Y = HGY + GRID_PX + 28; // 758 — top of piece tray +const MINI = 16; // mini-piece cell size in tray + +// ── Colors ───────────────────────────────────────────────────────────────────── +const PIECE_COLORS = { + dot: 0xf7c948, + domino: 0x5b9bff, + itromino: 0x45d17a, + ltromino: 0xff7c4a, + square: 0xd45bff, + ltetromino: 0xff4a6e, + stetromino: 0x4af7ee, + ttetromino: 0xf7a84a, + itetromino: 0x7af74a, +}; +const BLOCKER_COLOR = 0x2c2c3e; +const BLOCKER_STROKE = 0x4a4a5a; +const GRID_BG = 0x12122a; +const GRID_LINE = 0x252540; + +const D = { bg: -2, grid: 0, piece: 2, tray: 10, ui: 20, overlay: 60, overlayUI: 62 }; + +export default class GeniusSquareGame extends Phaser.Scene { + constructor() { super('GeniusSquareGame'); } + + init(data) { + this.gameDef = data.game; + this.opponents = data.opponents ?? []; + this.skill = data.opponents?.[0]?.skill ?? 3; + this.playfield = data.playfield ?? null; + + this.blockers = null; + this.humanBoard = null; + this.aiBoard = null; + this.aiState = null; + this.aiTimer = null; + this.gameOver = false; + + this.placedPieces = new Set(); + this.selectedPiece = null; // { pieceId, oriIdx } + this.ghostCells = null; + this._lastHoverR = -1; + this._lastHoverC = -1; + + this.humanGridGfx = null; + this.aiGridGfx = null; + this.traySlots = []; // [{ container, pieceId }] + this.opponentPortrait = null; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) {} + + // Roll dice + solve (retry on the rare unsolvable config) + let blockers, solution; + do { + blockers = rollDice(); + solution = solveFromBlockers(blockers); + } while (!solution); + + this.blockers = blockers; + this.humanBoard = newBoard(blockers); + this.aiBoard = newBoard(blockers); + this.aiState = createAIState(blockers, this.skill, solution); + + this._buildBackground(); + this._buildGridGraphics(); + this._buildLabels(); + this._buildPortraits(); + this._buildTray(); + this._buildHints(); + + this.input.keyboard.on('keydown-R', () => this._rotate()); + this.input.keyboard.on('keydown-F', () => this._flip()); + this.input.keyboard.on('keydown-ESC', () => this._clearSelection()); + + this.events.once('shutdown', () => this._stopAI()); + + this._renderHumanGrid(); + this._renderAIGrid(); + this._startCountdown(); + } + + // ── Scene construction ──────────────────────────────────────────────────────── + + _buildBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg - 1); + // Dark overlay so text and grids stay readable over any playfield image + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45) + .setDepth(D.bg); + } else if (pf?.fallbackColor) { + const color = parseInt(pf.fallbackColor.replace('#', ''), 16); + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color) + .setDepth(D.bg); + } else { + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg) + .setDepth(D.bg); + } + + // Center divider + const div = this.add.graphics().setDepth(D.ui); + div.lineStyle(1, COLORS.muted, 0.25); + div.beginPath(); + div.moveTo(GAME_WIDTH / 2, HGY - 10); + div.lineTo(GAME_WIDTH / 2, HGY + GRID_PX + 10); + div.strokePath(); + + // Title + this.add.text(GAME_WIDTH / 2, 36, 'GENIUS SQUARE', { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.accentHex, + }).setOrigin(0.5).setDepth(D.ui); + + // "VS" badge + this.add.text(GAME_WIDTH / 2, HGY + GRID_PX / 2, 'VS', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + } + + _buildGridGraphics() { + this.humanGridGfx = this.add.graphics().setDepth(D.grid); + this.aiGridGfx = this.add.graphics().setDepth(D.grid); + } + + _buildLabels() { + // Labels are rendered alongside portraits in _buildPortraits() + } + + _buildPortraits() { + const r = 36; + const pY = HGY - 52; // portrait center Y, shared by both sides + const depth = D.ui; + + // ── Human side ────────────────────────────────────────────────────────── + // Portrait left-aligned with the grid edge; name + label stacked to its right + const hpX = HGX + r + 8; // portrait center x + const htX = hpX + r + 14; // text left x + createPlayerPortrait(this, hpX, pY, r, depth, 'GeniusSquareGame'); + this.add.text(htX, pY - 3, auth.user?.username ?? 'You', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0, 1).setDepth(depth + 1); + this.add.text(htX, pY + 3, 'YOUR GRID', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0, 0).setDepth(depth + 1); + + // ── Opponent side ──────────────────────────────────────────────────────── + const apX = AGX + r + 8; + const atX = apX + r + 14; + const opp = this.opponents[0]; + if (opp) { + this.opponentPortrait = createOpponentPortrait(this, opp, apX, pY, r, depth, { playIntro: false }); + this.add.text(atX, pY - 3, opp.name ?? 'CPU', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0, 1).setDepth(depth + 1); + this.add.text(atX, pY + 3, "OPPONENT'S GRID", { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0, 0).setDepth(depth + 1); + } + } + + _buildTray() { + const trayLeft = HCX - (SLOT_W * 1.5); // left edge of 3-column tray + + for (let i = 0; i < PIECES.length; i++) { + const col = i % 3; + const row = Math.floor(i / 3); + const cx = trayLeft + col * SLOT_W + SLOT_W / 2; + const cy = TRAY_Y + row * SLOT_H + SLOT_H / 2; + const { id: pieceId } = PIECES[i]; + + const bg = this.add.graphics(); + this._drawSlotBg(bg, 0, 0, false); + + const pieceGfx = this.add.graphics(); + this._drawMiniPiece(pieceGfx, 0, 0, pieceId, PIECE_COLORS[pieceId], false); + + const container = this.add.container(cx, cy, [bg, pieceGfx]); + container.setSize(SLOT_W - 8, SLOT_H - 8); + container.setInteractive({ useHandCursor: true }); + container.on('pointerdown', () => this._selectPiece(pieceId)); + container.setDepth(D.tray); + + this.traySlots.push({ container, bg, pieceGfx, pieceId }); + } + } + + _buildHints() { + this.add.text(HCX, TRAY_Y + SLOT_H * 3 + 14, 'R = Rotate F = Flip ESC = Deselect', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + + new Button(this, GAME_WIDTH - 110, GAME_HEIGHT - 46, 'Leave', + () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 52, fontSize: 20 } + ).setDepth(D.ui); + } + + _buildGridInput() { + const zone = this.add.zone(HGX, HGY, GRID_PX, GRID_PX).setOrigin(0).setInteractive({ useHandCursor: true }); + zone.on('pointermove', ptr => this._onGridHover(ptr)); + zone.on('pointerdown', ptr => this._onGridClick(ptr)); + zone.on('pointerout', () => { this.ghostCells = null; this._renderHumanGrid(); }); + zone.setDepth(D.piece); + } + + // ── Piece tray rendering ────────────────────────────────────────────────────── + + _drawSlotBg(gfx, cx, cy, selected) { + gfx.clear(); + gfx.fillStyle(selected ? 0x2a2a4a : COLORS.panel, 1); + gfx.fillRoundedRect(cx - SLOT_W / 2 + 4, cy - SLOT_H / 2 + 4, SLOT_W - 8, SLOT_H - 8, 6); + gfx.lineStyle(2, selected ? COLORS.accent : GRID_LINE, 1); + gfx.strokeRoundedRect(cx - SLOT_W / 2 + 4, cy - SLOT_H / 2 + 4, SLOT_W - 8, SLOT_H - 8, 6); + } + + _drawMiniPiece(gfx, cx, cy, pieceId, color, dimmed) { + gfx.clear(); + const cells = ORIENTATIONS[pieceId][0]; + let maxR = 0, maxC = 0; + for (const [r, c] of cells) { if (r > maxR) maxR = r; if (c > maxC) maxC = c; } + const startX = cx - ((maxC + 1) * MINI) / 2; + const startY = cy - ((maxR + 1) * MINI) / 2; + const alpha = dimmed ? 0.3 : 1; + gfx.fillStyle(dimmed ? 0x666666 : color, alpha); + for (const [r, c] of cells) { + gfx.fillRoundedRect(startX + c * MINI + 1, startY + r * MINI + 1, MINI - 2, MINI - 2, 2); + } + } + + _renderTray() { + for (const slot of this.traySlots) { + const placed = this.placedPieces.has(slot.pieceId); + const selected = this.selectedPiece?.pieceId === slot.pieceId; + this._drawSlotBg(slot.bg, 0, 0, selected && !placed); + this._drawMiniPiece(slot.pieceGfx, 0, 0, slot.pieceId, PIECE_COLORS[slot.pieceId], placed); + slot.container.setInteractive(placed ? false : { useHandCursor: true }); + } + } + + // ── Grid rendering ──────────────────────────────────────────────────────────── + + _drawGrid(gfx, gx, gy, board, ghostCells, ghostPieceId) { + gfx.clear(); + + // Background + gfx.fillStyle(GRID_BG, 1); + gfx.fillRoundedRect(gx - 2, gy - 2, GRID_PX + 4, GRID_PX + 4, 10); + + // Grid lines + gfx.lineStyle(1, GRID_LINE, 1); + for (let i = 0; i <= 6; i++) { + gfx.beginPath(); gfx.moveTo(gx + i * CELL, gy); gfx.lineTo(gx + i * CELL, gy + GRID_PX); gfx.strokePath(); + gfx.beginPath(); gfx.moveTo(gx, gy + i * CELL); gfx.lineTo(gx + GRID_PX, gy + i * CELL); gfx.strokePath(); + } + + // Placed cells + for (let idx = 0; idx < 36; idx++) { + const val = board[idx]; + if (val === null) continue; + const r = Math.floor(idx / 6), c = idx % 6; + const px = gx + c * CELL, py = gy + r * CELL; + + if (val === 'blocked') { + gfx.fillStyle(BLOCKER_COLOR, 1); + gfx.fillRoundedRect(px + 3, py + 3, CELL - 6, CELL - 6, 5); + gfx.lineStyle(2, BLOCKER_STROKE, 0.8); + gfx.beginPath(); gfx.moveTo(px + 16, py + 16); gfx.lineTo(px + CELL - 16, py + CELL - 16); gfx.strokePath(); + gfx.beginPath(); gfx.moveTo(px + CELL - 16, py + 16); gfx.lineTo(px + 16, py + CELL - 16); gfx.strokePath(); + } else { + const color = PIECE_COLORS[val] ?? 0x888888; + gfx.fillStyle(color, 1); + gfx.fillRoundedRect(px + 3, py + 3, CELL - 6, CELL - 6, 7); + // Top sheen + gfx.fillStyle(0xffffff, 0.18); + gfx.fillRoundedRect(px + 5, py + 5, CELL - 10, 9, 3); + } + } + + // Ghost preview + if (ghostCells && ghostPieceId) { + const color = PIECE_COLORS[ghostPieceId] ?? 0xffffff; + const allValid = ghostCells.every(([r, c]) => + r >= 0 && r < 6 && c >= 0 && c < 6 && board[r * 6 + c] === null + ); + gfx.fillStyle(color, allValid ? 0.5 : 0.2); + for (const [r, c] of ghostCells) { + if (r < 0 || r >= 6 || c < 0 || c >= 6) continue; + gfx.fillRoundedRect(gx + c * CELL + 3, gy + r * CELL + 3, CELL - 6, CELL - 6, 7); + } + } + } + + _renderHumanGrid() { + this._drawGrid( + this.humanGridGfx, HGX, HGY, + this.humanBoard, + this.ghostCells, + this.selectedPiece?.pieceId, + ); + } + + _renderAIGrid() { + this._drawGrid(this.aiGridGfx, AGX, AGY, this.aiBoard, null, null); + } + + // ── Piece selection & placement ─────────────────────────────────────────────── + + _selectPiece(pieceId) { + if (this.gameOver || this.placedPieces.has(pieceId)) return; + if (this.selectedPiece?.pieceId === pieceId) { + this._clearSelection(); + return; + } + this.selectedPiece = { pieceId, oriIdx: 0 }; + this.ghostCells = null; + this._renderTray(); + this._renderHumanGrid(); + } + + _clearSelection() { + this.selectedPiece = null; + this.ghostCells = null; + this._lastHoverR = -1; + this._lastHoverC = -1; + this._renderTray(); + this._renderHumanGrid(); + } + + _rotate() { + if (!this.selectedPiece || this.gameOver) return; + this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx); + this._updateGhostAt(this._lastHoverR, this._lastHoverC); + this._renderHumanGrid(); + } + + _flip() { + if (!this.selectedPiece || this.gameOver) return; + this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx); + this._updateGhostAt(this._lastHoverR, this._lastHoverC); + this._renderHumanGrid(); + } + + _updateGhostAt(r, c) { + if (r < 0 || c < 0 || !this.selectedPiece) { this.ghostCells = null; return; } + const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx]; + this.ghostCells = absoluteCells(oriCells, r, c); + } + + _onGridHover(ptr) { + if (!this.selectedPiece || this.gameOver) return; + const r = Math.floor((ptr.worldY - HGY) / CELL); + const c = Math.floor((ptr.worldX - HGX) / CELL); + this._lastHoverR = r; + this._lastHoverC = c; + this._updateGhostAt(r, c); + this._renderHumanGrid(); + } + + _onGridClick(ptr) { + if (this.gameOver) return; + + const r = Math.floor((ptr.worldY - HGY) / CELL); + const c = Math.floor((ptr.worldX - HGX) / CELL); + if (r < 0 || r >= 6 || c < 0 || c >= 6) return; + + const cellVal = this.humanBoard[r * 6 + c]; + + // Click on a placed piece → lift it back to the tray + if (cellVal && cellVal !== 'blocked') { + this._liftPiece(cellVal); + return; + } + + // Otherwise place the selected piece + if (!this.selectedPiece || !this.ghostCells) return; + if (!canPlace(this.humanBoard, this.ghostCells)) { + playSound(this, SFX.PIECE_CLICK); + return; + } + const { pieceId } = this.selectedPiece; + this.humanBoard = placePiece(this.humanBoard, this.ghostCells, pieceId); + this.placedPieces.add(pieceId); + playSound(this, SFX.CARD_PLACE); + this._clearSelection(); + this._renderHumanGrid(); + this._renderTray(); + + if (isSolved(this.humanBoard)) this._onHumanWin(); + } + + _liftPiece(pieceId) { + // Collect all cells this piece occupies (board scan is row-major, matching normalize order) + const cells = []; + for (let i = 0; i < 36; i++) { + if (this.humanBoard[i] === pieceId) cells.push([Math.floor(i / 6), i % 6]); + } + this.humanBoard = removePiece(this.humanBoard, cells); + this.placedPieces.delete(pieceId); + + // Recover the original orientation and anchor, then show it as a ghost in place + const oriIdx = this._matchOrientation(pieceId, cells); + this.selectedPiece = { pieceId, oriIdx }; + const oriCells = ORIENTATIONS[pieceId][oriIdx]; + const anchorR = cells[0][0] - oriCells[0][0]; + const anchorC = cells[0][1] - oriCells[0][1]; + this.ghostCells = absoluteCells(oriCells, anchorR, anchorC); + this._lastHoverR = anchorR; + this._lastHoverC = anchorC; + + playSound(this, SFX.PIECE_CLICK); + this._renderHumanGrid(); + this._renderTray(); + } + + _matchOrientation(pieceId, cells) { + let minR = Infinity, minC = Infinity; + for (const [r, c] of cells) { if (r < minR) minR = r; if (c < minC) minC = c; } + const normKey = cells + .map(([r, c]) => `${r - minR},${c - minC}`) + .sort() + .join(';'); + const oris = ORIENTATIONS[pieceId]; + for (let i = 0; i < oris.length; i++) { + const key = oris[i].map(([r, c]) => `${r},${c}`).join(';'); + if (key === normKey) return i; + } + return 0; + } + + // ── Countdown ──────────────────────────────────────────────────────────────── + + _startCountdown() { + const steps = ['3', '2', '1', 'GO!']; + + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45) + .setDepth(D.overlay - 1); + + const label = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, '', { + fontFamily: 'Righteous', + fontSize: '240px', + color: '#f7c948', + stroke: '#b08800', + strokeThickness: 8, + }).setOrigin(0.5).setDepth(D.overlay).setAlpha(0); + + const runStep = (i) => { + if (i >= steps.length) { + dim.destroy(); + label.destroy(); + this._buildGridInput(); + this._startAI(); + return; + } + + label.setText(steps[i]).setScale(1.6).setAlpha(1); + + this.tweens.add({ + targets: label, scaleX: 1, scaleY: 1, + duration: 260, ease: 'Back.easeOut', + }); + + this.time.delayedCall(steps[i] === 'GO!' ? 560 : 740, () => { + this.tweens.add({ + targets: label, alpha: 0, duration: 180, + onComplete: () => runStep(i + 1), + }); + }); + }; + + runStep(0); + } + + // ── AI tick loop ────────────────────────────────────────────────────────────── + + _startAI() { + if (!this.aiState.solution) return; // should not happen with zone-partitioned dice + this._scheduleAITick(); + } + + _scheduleAITick() { + if (this.gameOver || this.aiState.done) return; + const delay = nextThinkDelay(this.skill); + this.aiTimer = this.time.delayedCall(delay, () => this._aiTick()); + } + + _aiTick() { + if (this.gameOver) return; + const placement = getNextPlacement(this.aiState); + if (!placement) { this._onAIWin(); return; } + + this.aiBoard = placePiece(this.aiBoard, placement.cells, placement.pieceId); + playSound(this, SFX.PIECE_CLICK); + this._renderAIGrid(); + + if (isSolved(this.aiBoard)) { + this._onAIWin(); + } else { + this._scheduleAITick(); + } + } + + _stopAI() { + if (this.aiTimer) { this.aiTimer.remove(false); this.aiTimer = null; } + } + + // ── Win / loss ──────────────────────────────────────────────────────────────── + + _onHumanWin() { + if (this.gameOver) return; + this.gameOver = true; + this._stopAI(); + this.opponentPortrait?.playEmotion('upset'); + playSound(this, SFX.VICTORY_SHORT); + this._recordResult('win'); + this._showOverlay(true); + } + + _onAIWin() { + if (this.gameOver) return; + this.gameOver = true; + this.opponentPortrait?.playEmotion('happy'); + this._recordResult('loss'); + this._showOverlay(false); + } + + _recordResult(result) { + api.post('/history/single-player', { + slug: 'geniussquare', score: result === 'win' ? 1 : 0, result, + }).catch(() => {}); + } + + _showOverlay(humanWon) { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const borderColor = humanWon ? 0x45d17a : COLORS.danger; + const headline = humanWon ? 'You Win!' : 'Opponent Wins!'; + const headColor = humanWon ? '#45d17a' : COLORS.dangerHex; + const subline = humanWon ? 'You filled the grid first!' : 'The opponent finished first.'; + + // Dim background + this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) + .setDepth(D.overlay).setInteractive(); + + // Panel + const panel = this.add.graphics().setDepth(D.overlay + 1); + panel.fillStyle(0x1a1828, 0.97); + panel.fillRoundedRect(cx - 340, cy - 190, 680, 380, 18); + panel.lineStyle(3, borderColor, 1); + panel.strokeRoundedRect(cx - 340, cy - 190, 680, 380, 18); + + this.add.text(cx, cy - 110, headline, { + fontFamily: 'Righteous', fontSize: '68px', color: headColor, + }).setOrigin(0.5).setDepth(D.overlayUI); + + this.add.text(cx, cy - 26, subline, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + + const data = { game: this.gameDef, opponents: this.opponents, playfield: this.playfield }; + new Button(this, cx - 170, cy + 100, 'Play Again', + () => this.scene.restart(data), + { width: 280, height: 58, fontSize: 22 } + ).setDepth(D.overlayUI); + + new Button(this, cx + 170, cy + 100, 'Menu', + () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 280, height: 58, fontSize: 22 } + ).setDepth(D.overlayUI); + } +} diff --git a/public/src/games/geniussquare/GeniusSquareLogic.js b/public/src/games/geniussquare/GeniusSquareLogic.js new file mode 100644 index 0000000..348457e --- /dev/null +++ b/public/src/games/geniussquare/GeniusSquareLogic.js @@ -0,0 +1,177 @@ +// Genius Square — pure game logic, no Phaser. +// +// Board is a 36-element flat array (6×6, row-major): +// null = empty +// 'blocked' = die/blocker occupying this cell +// pieceId = placed piece id +// +// The 9 pieces total 29 cells; 7 blockers = 36 = 6×6. + +// ── Piece definitions ───────────────────────────────────────────────────────── + +export const PIECES = [ + { id: 'dot', cells: [[0,0]] }, + { id: 'domino', cells: [[0,0],[0,1]] }, + { id: 'itromino', cells: [[0,0],[0,1],[0,2]] }, + { id: 'ltromino', cells: [[0,0],[1,0],[1,1]] }, + { id: 'square', cells: [[0,0],[0,1],[1,0],[1,1]] }, + { id: 'ltetromino', cells: [[0,0],[1,0],[2,0],[2,1]] }, + { id: 'stetromino', cells: [[0,1],[0,2],[1,0],[1,1]] }, + { id: 'ttetromino', cells: [[0,0],[0,1],[0,2],[1,1]] }, + { id: 'itetromino', cells: [[0,0],[0,1],[0,2],[0,3]] }, +]; + +export const PIECE_IDS = PIECES.map(p => p.id); + +// ── Orientation generation ──────────────────────────────────────────────────── +// Enumerate all unique free-polyomino orientations (rotations + reflections) +// using the same normalize/deduplicate approach as BlokusBoard.js. + +function normalize(cells) { + let minR = Infinity, minC = Infinity; + for (const [r, c] of cells) { if (r < minR) minR = r; if (c < minC) minC = c; } + return cells.map(([r, c]) => [r - minR, c - minC]).sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +function keyOf(cells) { + return cells.map(([r, c]) => `${r},${c}`).join(';'); +} + +function computeOrientations(cells) { + const seen = new Map(); + const base = cells.map(([r, c]) => [r, c]); + for (let flip = 0; flip < 2; flip++) { + let work = flip ? base.map(([r, c]) => [r, -c]) : base; + for (let rot = 0; rot < 4; rot++) { + const norm = normalize(work); + const k = keyOf(norm); + if (!seen.has(k)) seen.set(k, norm); + work = work.map(([r, c]) => [c, -r]); // rotate 90° clockwise + } + } + return [...seen.values()]; +} + +export const ORIENTATIONS = Object.fromEntries( + PIECES.map(p => [p.id, computeOrientations(p.cells)]) +); + +const ORI_INDEX = Object.fromEntries( + PIECES.map(p => [p.id, new Map(ORIENTATIONS[p.id].map((o, i) => [keyOf(o), i]))]) +); + +function transformedIndex(pieceId, oriIdx, fn) { + const cur = ORIENTATIONS[pieceId][oriIdx]; + const next = normalize(cur.map(fn)); + return ORI_INDEX[pieceId].get(keyOf(next)) ?? oriIdx; +} + +/** Orientation index after a 90° clockwise rotation. */ +export function rotateOri(pieceId, oriIdx) { + return transformedIndex(pieceId, oriIdx, ([r, c]) => [c, -r]); +} + +/** Orientation index after a horizontal mirror. */ +export function flipOri(pieceId, oriIdx) { + return transformedIndex(pieceId, oriIdx, ([r, c]) => [r, -c]); +} + +// ── Dice zones ──────────────────────────────────────────────────────────────── +// 7 zones partition all 36 cells. One cell picked randomly from each zone +// per round, ensuring blockers spread across the entire board. + +const ZONES = [ + [[0,0],[0,1],[0,2],[0,3],[1,3]], + [[0,4],[0,5],[1,4],[1,5],[2,5]], + [[1,0],[1,1],[1,2],[2,0],[2,1]], + [[2,2],[2,3],[2,4],[3,2],[3,3]], + [[3,0],[3,1],[4,0],[4,1],[5,0]], + [[3,4],[3,5],[4,4],[4,5],[5,5]], + [[4,2],[4,3],[5,1],[5,2],[5,3],[5,4]], +]; + +/** Roll 7 dice → array of 7 [row, col] blocker positions. */ +export function rollDice() { + return ZONES.map(zone => zone[Math.floor(Math.random() * zone.length)]); +} + +// ── Board helpers ───────────────────────────────────────────────────────────── + +export function newBoard(blockers) { + const board = Array(36).fill(null); + for (const [r, c] of blockers) board[r * 6 + c] = 'blocked'; + return board; +} + +/** Translate orientation-relative [dr,dc] cells to absolute [r,c] at an anchor. */ +export function absoluteCells(oriCells, anchorR, anchorC) { + return oriCells.map(([dr, dc]) => [anchorR + dr, anchorC + dc]); +} + +export function canPlace(board, cells) { + for (const [r, c] of cells) { + if (r < 0 || r >= 6 || c < 0 || c >= 6) return false; + if (board[r * 6 + c] !== null) return false; + } + return true; +} + +export function placePiece(board, cells, pieceId) { + const next = board.slice(); + for (const [r, c] of cells) next[r * 6 + c] = pieceId; + return next; +} + +export function removePiece(board, cells) { + const next = board.slice(); + for (const [r, c] of cells) next[r * 6 + c] = null; + return next; +} + +export function isSolved(board) { + return board.every(cell => cell !== null); +} + +// ── Backtracking solver ─────────────────────────────────────────────────────── +// Always targets the first null cell (left-to-right, top-to-bottom). For each +// remaining piece, tries every orientation offset that covers that cell, then +// recurses. This forces row-major filling and collapses the search space +// dramatically — runs in <10ms for typical Genius Square boards. + +export function solve(board, remainingPieces) { + const emptyIdx = board.indexOf(null); + if (emptyIdx === -1) return remainingPieces.size === 0 ? [] : null; + if (remainingPieces.size === 0) return null; + + const targetR = Math.floor(emptyIdx / 6); + const targetC = emptyIdx % 6; + + for (const pieceId of remainingPieces) { + const oris = ORIENTATIONS[pieceId]; + for (let oriIdx = 0; oriIdx < oris.length; oriIdx++) { + const oriCells = oris[oriIdx]; + for (const [dr, dc] of oriCells) { + const anchorR = targetR - dr; + const anchorC = targetC - dc; + const cells = absoluteCells(oriCells, anchorR, anchorC); + if (!canPlace(board, cells)) continue; + + const nextBoard = placePiece(board, cells, pieceId); + const nextRemaining = new Set(remainingPieces); + nextRemaining.delete(pieceId); + + const sub = solve(nextBoard, nextRemaining); + if (sub !== null) { + return [{ pieceId, oriIdx, anchorR, anchorC, cells }, ...sub]; + } + } + } + } + return null; +} + +/** Convenience: build fresh board from blockers and solve with all 9 pieces. */ +export function solveFromBlockers(blockers) { + const board = newBoard(blockers); + return solve(board, new Set(PIECE_IDS)); +} diff --git a/public/src/main.js b/public/src/main.js index e4b9474..c297bfa 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -80,6 +80,7 @@ import Game2048 from './games/2048/2048Game.js'; import RummikubGame from './games/rummikub/RummikubGame.js'; import GinRummyGame from './games/ginrummy/GinRummyGame.js'; import RiskGame from './games/risk/RiskGame.js'; +import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js'; const config = { type: Phaser.AUTO, @@ -173,6 +174,7 @@ const config = { RummikubGame, GinRummyGame, RiskGame, + GeniusSquareGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index a7f44f7..548f40f 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,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', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index 0fcdf1d..92e0136 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene { // Skill control: pips always show the level; the +/- buttons appear only // when this opponent is selected. Enabled for games with a 1–5 AI skill. - if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong'].includes(this.gameDef.slug)) { + if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare'].includes(this.gameDef.slug)) { bio.style.webkitLineClamp = '1'; const skillRow = document.createElement('div'); diff --git a/server/games/registry.js b/server/games/registry.js index d9cde8c..0823561 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -96,3 +96,4 @@ registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, max registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 }); registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 }); registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54 }); +registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });