fertig-classic-games/src/games/geniussquare/GeniusSquareGame.js

885 lines
32 KiB
JavaScript

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