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 }; // Returns the cell [dr, dc] in oriCells closest to its centroid. // Used as the "pin" so the piece stays centered under the mouse on rotate/flip. function _centerPin(cells) { const n = cells.length; const cr = cells.reduce((s, [r]) => s + r, 0) / n; const cc = cells.reduce((s, [, c]) => s + c, 0) / n; let best = cells[0], dist = Infinity; for (const cell of cells) { const d = (cell[0] - cr) ** 2 + (cell[1] - cc) ** 2; if (d < dist) { dist = d; best = cell; } } return best; } 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.humanWins = data.humanWins ?? 0; this.aiWins = data.aiWins ?? 0; this.roundNum = data.roundNum ?? 1; 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._pulseGfx = null; this._ghostAnimContainer = null; this._ghostAnim = 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._buildRoundDisplay(); 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(); } update(time) { if (!this._pulseGfx || !this.ghostCells || !this.selectedPiece) { this._pulseGfx?.clear(); return; } const cells = this.ghostCells; const pieceId = this.selectedPiece.pieceId; const alpha = 0.35 + 0.65 * Math.abs(Math.sin(time * 0.004)); const allValid = cells.every(([r, c]) => r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null ); const color = allValid ? (PIECE_COLORS[pieceId] ?? 0xffffff) : 0xff4444; const ghostSet = new Set(cells.map(([r, c]) => `${r},${c}`)); const OFF = 4; this._pulseGfx.clear(); const drawEdges = () => { for (const [r, c] of cells) { const x0 = HGX + c * CELL, y0 = HGY + r * CELL; const x1 = x0 + CELL, y1 = y0 + CELL; if (!ghostSet.has(`${r - 1},${c}`)) { this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y0 - OFF); this._pulseGfx.strokePath(); } if (!ghostSet.has(`${r + 1},${c}`)) { this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y1 + OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath(); } if (!ghostSet.has(`${r},${c - 1}`)) { this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x0 - OFF, y1 + OFF); this._pulseGfx.strokePath(); } if (!ghostSet.has(`${r},${c + 1}`)) { this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x1 + OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath(); } } }; this._pulseGfx.lineStyle(14, color, alpha * 0.4); drawEdges(); this._pulseGfx.lineStyle(3, color, alpha); drawEdges(); } // ── 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); this._pulseGfx = this.add.graphics().setDepth(D.grid + 1); } _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); } } _buildRoundDisplay() { const cx = GAME_WIDTH / 2; const py = 74; this.add.text(cx, py, `ROUND ${this.roundNum} OF 3`, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.ui); // Human win pips (left of center) for (let i = 0; i < 2; i++) { const filled = i < this.humanWins; const gfx = this.add.graphics().setDepth(D.ui); gfx.fillStyle(filled ? 0x45d17a : 0x2a2a4a, 1); gfx.fillCircle(700 + i * 24, py, 8); gfx.lineStyle(2, filled ? 0x45d17a : 0x444466, 1); gfx.strokeCircle(700 + i * 24, py, 8); } // AI win pips (right of center) for (let i = 0; i < 2; i++) { const filled = i < this.aiWins; const gfx = this.add.graphics().setDepth(D.ui); gfx.fillStyle(filled ? COLORS.danger : 0x2a2a4a, 1); gfx.fillCircle(1196 + i * 24, py, 8); gfx.lineStyle(2, filled ? COLORS.danger : 0x444466, 1); gfx.strokeCircle(1196 + i * 24, py, 8); } } _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(0x000000, 0.35); for (const [r, c] of ghostCells) { if (r < 0 || r >= 6 || c < 0 || c >= 6) continue; gfx.fillRoundedRect(gx + c * CELL + 8, gy + r * CELL + 9, CELL - 6, CELL - 6, 7); } gfx.fillStyle(allValid ? color : 0xff4444, allValid ? 0.78 : 0.4); 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; playSound(this, SFX.UI_PICK); 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; playSound(this, SFX.UI_ACTIVATE); this._cancelGhostAnim(); if (!this.ghostCells) { this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx); this._updateGhostAt(this._lastHoverR, this._lastHoverC); this._renderHumanGrid(); return; } this._startGhostAnim('rotate'); } _flip() { if (!this.selectedPiece || this.gameOver) return; playSound(this, SFX.UI_FLIP); this._cancelGhostAnim(); if (!this.ghostCells) { this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx); this._updateGhostAt(this._lastHoverR, this._lastHoverC); this._renderHumanGrid(); return; } this._startGhostAnim('flip'); } _cancelGhostAnim() { if (!this._ghostAnimContainer) return; this.tweens.killTweensOf(this._ghostAnimContainer); this._ghostAnimContainer.destroy(true); this._ghostAnimContainer = null; this._ghostAnim = null; this._updateGhostAt(this._lastHoverR, this._lastHoverC); } _startGhostAnim(type) { const cells = this.ghostCells; const pieceId = this.selectedPiece.pieceId; const hoverR = this._lastHoverR; const hoverC = this._lastHoverC; const n = cells.length; const cr = cells.reduce((s, [r]) => s + r, 0) / n; const cc = cells.reduce((s, [, c]) => s + c, 0) / n; const gfx = this.add.graphics(); this._fillGhostLocal(gfx, cells, cr, cc); const pivotX = HGX + (cc + 0.5) * CELL; const pivotY = HGY + (cr + 0.5) * CELL; const container = this.add.container(pivotX, pivotY, [gfx]).setDepth(D.piece + 1); this._ghostAnimContainer = container; // Suppress normal ghost & pulse while animating this.ghostCells = null; this._pulseGfx?.clear(); this._renderHumanGrid(); const HALF = 80; const finish = () => { container.destroy(true); this._ghostAnimContainer = null; this._ghostAnim = null; this._updateGhostAt(this._lastHoverR, this._lastHoverC); this._renderHumanGrid(); }; const phase2 = () => { const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx]; const [pinDr, pinDc] = _centerPin(oriCells); const newCells = (hoverR >= 0 && hoverC >= 0) ? absoluteCells(oriCells, hoverR - pinDr, hoverC - pinDc) : null; if (!newCells) { finish(); return; } const nn = newCells.length; const ncr = newCells.reduce((s, [r]) => s + r, 0) / nn; const ncc = newCells.reduce((s, [, c]) => s + c, 0) / nn; gfx.clear(); this._fillGhostLocal(gfx, newCells, ncr, ncc); container.setPosition(HGX + (ncc + 0.5) * CELL, HGY + (ncr + 0.5) * CELL); if (type === 'rotate') container.angle = -90; this._ghostAnim = this.tweens.add({ targets: container, angle: 0, scaleX: 1, scaleY: 1, duration: HALF, ease: 'Power2.easeOut', onComplete: finish, }); }; const midpoint = () => { if (type === 'rotate') { this.selectedPiece.oriIdx = rotateOri(pieceId, this.selectedPiece.oriIdx); } else { this.selectedPiece.oriIdx = flipOri(pieceId, this.selectedPiece.oriIdx); } phase2(); }; const phase1Props = type === 'rotate' ? { angle: 90, scaleX: 0, scaleY: 0 } : { scaleX: 0 }; this._ghostAnim = this.tweens.add({ targets: container, ...phase1Props, duration: HALF, ease: 'Power2.easeIn', onComplete: midpoint, }); } _fillGhostLocal(gfx, cells, cr, cc) { const pieceId = this.selectedPiece?.pieceId; const color = PIECE_COLORS[pieceId] ?? 0xffffff; const allValid = cells.every(([r, c]) => r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null ); gfx.fillStyle(0x000000, 0.35); for (const [r, c] of cells) { const lx = (c - cc) * CELL - CELL / 2; const ly = (r - cr) * CELL - CELL / 2; gfx.fillRoundedRect(lx + 8, ly + 9, CELL - 6, CELL - 6, 7); } gfx.fillStyle(allValid ? color : 0xff4444, allValid ? 0.78 : 0.4); for (const [r, c] of cells) { const lx = (c - cc) * CELL - CELL / 2; const ly = (r - cr) * CELL - CELL / 2; gfx.fillRoundedRect(lx + 3, ly + 3, CELL - 6, CELL - 6, 7); } } _updateGhostAt(r, c) { if (r < 0 || c < 0 || !this.selectedPiece) { this.ghostCells = null; return; } const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx]; const [pinDr, pinDc] = _centerPin(oriCells); this.ghostCells = absoluteCells(oriCells, r - pinDr, c - pinDc); } _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; if (this._ghostAnimContainer) return; // let animation finish 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._cancelGhostAnim(); 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.UI_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); const [pinDr, pinDc] = _centerPin(oriCells); this._lastHoverR = anchorR + pinDr; this._lastHoverC = anchorC + pinDc; 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); playSound(this, steps[i] === 'GO!' ? SFX.COUNTDOWN_GO : SFX.COUNTDOWN_TICK); 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'); this._endRound(true); } _onAIWin() { if (this.gameOver) return; this.gameOver = true; this._stopAI(); this.opponentPortrait?.playEmotion('happy'); this._endRound(false); } _endRound(humanWon) { if (humanWon) this.humanWins++; else this.aiWins++; const matchOver = this.humanWins >= 2 || this.aiWins >= 2; if (matchOver) { const playerWon = this.humanWins > this.aiWins; playSound(this, playerWon ? SFX.VICTORY_SHORT : SFX.SCIFI_RISER); api.post('/history/single-player', { slug: 'geniussquare', score: playerWon ? 1 : 0, result: playerWon ? 'win' : 'loss', }).catch(() => {}); this._showMatchOverlay(playerWon); } else { playSound(this, humanWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE); this._showInterRoundOverlay(humanWon); } } _showInterRoundOverlay(humanWon) { const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const borderColor = humanWon ? 0x45d17a : COLORS.danger; const headline = humanWon ? 'Round Win!' : 'Round Loss!'; const headColor = humanWon ? '#45d17a' : COLORS.dangerHex; const subline = humanWon ? 'You filled the grid first!' : 'The opponent finished first.'; this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) .setDepth(D.overlay).setInteractive(); const panel = this.add.graphics().setDepth(D.overlay + 1); panel.fillStyle(0x1a1828, 0.97); panel.fillRoundedRect(cx - 340, cy - 220, 680, 440, 18); panel.lineStyle(3, borderColor, 1); panel.strokeRoundedRect(cx - 340, cy - 220, 680, 440, 18); this.add.text(cx, cy - 150, headline, { fontFamily: 'Righteous', fontSize: '68px', color: headColor, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy - 70, subline, { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy - 12, `${this.humanWins} — ${this.aiWins}`, { fontFamily: 'Righteous', fontSize: '56px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy + 50, `ROUND ${this.roundNum + 1} NEXT`, { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); const nextData = { game: this.gameDef, opponents: this.opponents, playfield: this.playfield, humanWins: this.humanWins, aiWins: this.aiWins, roundNum: this.roundNum + 1, }; new Button(this, cx, cy + 130, 'Next Round', () => this.scene.restart(nextData), { width: 280, height: 58, fontSize: 22 } ).setDepth(D.overlayUI); } _showMatchOverlay(playerWon) { const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const borderColor = playerWon ? 0x45d17a : COLORS.danger; const headline = playerWon ? 'You Win!' : 'Opponent Wins!'; const headColor = playerWon ? '#45d17a' : COLORS.dangerHex; const subline = playerWon ? 'You won the best of 3!' : 'The opponent won the best of 3.'; this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) .setDepth(D.overlay).setInteractive(); const panel = this.add.graphics().setDepth(D.overlay + 1); panel.fillStyle(0x1a1828, 0.97); panel.fillRoundedRect(cx - 340, cy - 220, 680, 440, 18); panel.lineStyle(3, borderColor, 1); panel.strokeRoundedRect(cx - 340, cy - 220, 680, 440, 18); this.add.text(cx, cy - 150, headline, { fontFamily: 'Righteous', fontSize: '68px', color: headColor, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy - 70, subline, { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy - 12, `${this.humanWins} — ${this.aiWins}`, { fontFamily: 'Righteous', fontSize: '56px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.overlayUI); this.add.text(cx, cy + 50, 'BEST OF 3', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.overlayUI); const freshData = { game: this.gameDef, opponents: this.opponents, playfield: this.playfield }; new Button(this, cx - 170, cy + 130, 'Play Again', () => this.scene.restart(freshData), { width: 280, height: 58, fontSize: 22 } ).setDepth(D.overlayUI); new Button(this, cx + 170, cy + 130, 'Menu', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 58, fontSize: 22 } ).setDepth(D.overlayUI); } }