feat: add Genius Square single-player game with AI opponent
Implement Genius Square, a 6x6 grid puzzle game where players place 9 polyomino pieces to fill the board around randomly placed blockers. Key features: - Pure JS game logic (GeniusSquareLogic.js) with backtracking solver - AI opponent with 5 skill levels using pre-computed solutions - Phaser-based UI with piece tray, grid rendering, and animations - Dice-based blocker placement using zone partitioning for balanced boards - Orientation support: rotate (R), flip (F), and piece lifting - Win/loss overlay with play again and menu options - Register game in server registry and scene dispatchers
This commit is contained in:
parent
e3fb356520
commit
e3fc11d13a
Binary file not shown.
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 264 KiB |
Binary file not shown.
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ import Game2048 from './games/2048/2048Game.js';
|
||||||
import RummikubGame from './games/rummikub/RummikubGame.js';
|
import RummikubGame from './games/rummikub/RummikubGame.js';
|
||||||
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
|
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
|
||||||
import RiskGame from './games/risk/RiskGame.js';
|
import RiskGame from './games/risk/RiskGame.js';
|
||||||
|
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -173,6 +174,7 @@ const config = {
|
||||||
RummikubGame,
|
RummikubGame,
|
||||||
GinRummyGame,
|
GinRummyGame,
|
||||||
RiskGame,
|
RiskGame,
|
||||||
|
GeniusSquareGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
|
|
||||||
// Skill control: pips always show the level; the +/- buttons appear only
|
// 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.
|
// 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';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue