885 lines
32 KiB
JavaScript
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);
|
|
}
|
|
}
|