feat: add Katamino single-player pentomino puzzle game

Implement Katamino, a 5×N grid tiling puzzle using the 12 standard
pentominoes. Players select a grid width (3-12) and solve puzzles by
placing all chosen pieces to fill the board.

Key features:
- Three-screen flow: penta select → set select → play
- Scrollable puzzle card grid with completion progress tracking
- Piece rotation (R), flip (F), and lift-to-reposition
- Pre-generated puzzle bank (katamino.json) with all valid tilings
- Offline generator script (genKatamino.js) using backtracking solver
- Progress saved to localStorage per puzzle
- Registration in server registry and scene dispatchers
This commit is contained in:
Brian Fertig 2026-06-20 08:26:23 -06:00
parent e3fc11d13a
commit 762792f9f5
9 changed files with 1026 additions and 4 deletions

File diff suppressed because one or more lines are too long

View File

@ -46,6 +46,20 @@ 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'); }
@ -374,7 +388,8 @@ export default class GeniusSquareGame extends Phaser.Scene {
_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);
const [pinDr, pinDc] = _centerPin(oriCells);
this.ghostCells = absoluteCells(oriCells, r - pinDr, c - pinDc);
}
_onGridHover(ptr) {
@ -435,8 +450,9 @@ export default class GeniusSquareGame extends Phaser.Scene {
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;
const [pinDr, pinDc] = _centerPin(oriCells);
this._lastHoverR = anchorR + pinDr;
this._lastHoverC = anchorC + pinDc;
playSound(this, SFX.PIECE_CLICK);
this._renderHumanGrid();

View File

@ -0,0 +1,703 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
ROWS, PIECES, PIECE_COLORS, ORIENTATIONS,
absoluteCells, newBoard, canPlace, placePiece, removePiece, isSolved,
rotateOri, flipOri,
} from './KataminoLogic.js';
// ── Layout ────────────────────────────────────────────────────────────────────
const CELL = 110; // board cell size (px)
const MINI = 22; // tray mini-piece cell size (px)
const SLOT_H = 130; // tray slot height (enough for 5-tall piece + padding)
const BOARD_Y = 90;
const TRAY_GAP = 20;
// Set-select card dimensions
const CARD_W = 160;
const CARD_H = 110;
const CARD_GAP = 12;
const COLS_PER_ROW = 10;
const ROW_H = CARD_H + CARD_GAP;
const CARDS_MARGIN = (GAME_WIDTH - (COLS_PER_ROW * CARD_W + (COLS_PER_ROW - 1) * CARD_GAP)) / 2;
// Scroll area in set-select screen
const SCROLL_AREA_Y = 120;
const SCROLL_AREA_H = GAME_HEIGHT - SCROLL_AREA_Y - 60;
// ── Colors ────────────────────────────────────────────────────────────────────
const BOARD_BG = 0x3d2208;
const BOARD_LINE = 0x7a4c1a;
const SLOT_BG = COLORS.panel;
const SLOT_SEL = 0x2a1f0a;
const D = { bg: -2, board: 0, piece: 2, tray: 8, 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 KataminoGame extends Phaser.Scene {
constructor() { super('KataminoGame'); }
init(data) {
this.gameDef = data.game;
this._bank = null;
this._screen = null; // 'penta' | 'set' | 'play'
this._penta = null;
this._puzzleIdx = null;
this._pieces = null;
// Play state
this._cols = null;
this._boardX = null;
this._board = null;
this._placedPieces = new Set();
this._selectedPiece = null; // { pieceId, oriIdx }
this._ghostCells = null;
this._lastHoverR = -1;
this._lastHoverC = -1;
// Rendering
this._screenContainer = null;
this._maskGfx = null;
this._boardGfx = null;
this._traySlots = [];
// Input handler refs for cleanup
this._inputHandlers = [];
}
create() {
this._bank = this.cache.json.get('katamino');
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0f0d0a).setDepth(D.bg);
// Persistent keyboard handlers — check this._screen before acting
this.input.keyboard.on('keydown-R', () => this._rotateSelected());
this.input.keyboard.on('keydown-F', () => this._flipSelected());
this.input.keyboard.on('keydown-ESCAPE', () => { if (this._screen === 'play') this._clearSelection(); });
this._showPentaSelect();
}
// ── Screen management ──────────────────────────────────────────────────────
_clearScreen() {
if (this._screenContainer) { this._screenContainer.destroy(true); this._screenContainer = null; }
if (this._maskGfx) { this._maskGfx.destroy(); this._maskGfx = null; }
for (const { ev, fn } of this._inputHandlers) this.input.off(ev, fn);
this._inputHandlers = [];
// Reset play state
this._board = null;
this._cols = null;
this._placedPieces.clear();
this._selectedPiece = null;
this._ghostCells = null;
this._lastHoverR = -1;
this._lastHoverC = -1;
this._boardGfx = null;
this._traySlots = [];
}
_addInputHandler(ev, fn) {
this._inputHandlers.push({ ev, fn });
this.input.on(ev, fn);
}
// ── Progress helpers ───────────────────────────────────────────────────────
_isDone(penta, idx) { return localStorage.getItem(`katamino-done-${penta}-${idx}`) === '1'; }
_markDone(penta, idx) { localStorage.setItem(`katamino-done-${penta}-${idx}`, '1'); }
_countDone(penta) {
const puzzles = this._bank?.pentas?.[String(penta)] ?? [];
return puzzles.filter(p => this._isDone(penta, p.idx)).length;
}
// ── Screen 1: Penta Select ─────────────────────────────────────────────────
_showPentaSelect() {
this._clearScreen();
this._screen = 'penta';
const sc = this._screenContainer = this.add.container(0, 0).setDepth(D.ui);
const title = this.add.text(GAME_WIDTH / 2, 55, 'KATAMINO', {
fontFamily: '"Julius Sans One"', fontSize: '72px', color: COLORS.textHex,
}).setOrigin(0.5);
const sub = this.add.text(GAME_WIDTH / 2, 135, 'The Pentomino Challenge', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
sc.add([title, sub]);
const TILE_W = 230, TILE_H = 200, TILE_GAP = 20;
const rowLeft = (GAME_WIDTH - (5 * TILE_W + 4 * TILE_GAP)) / 2;
[[3,4,5,6,7], [8,9,10,11,12]].forEach((row, rowIdx) => {
row.forEach((penta, col) => {
const cx = rowLeft + col * (TILE_W + TILE_GAP) + TILE_W / 2;
const cy = 210 + rowIdx * 230 + TILE_H / 2;
this._buildPentaTile(sc, cx, cy, TILE_W, TILE_H, penta);
});
});
const hint = this.add.text(GAME_WIDTH / 2, 720, 'Select a Penta to choose a puzzle', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
sc.add(hint);
const leave = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 50, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 48, fontSize: 18 }
).setDepth(D.ui + 1);
sc.add(leave);
}
_buildPentaTile(sc, cx, cy, w, h, penta) {
const total = (this._bank?.pentas?.[String(penta)] ?? []).length;
const done = this._countDone(penta);
const allDone = total > 0 && done === total;
const gfx = this.add.graphics();
const hov = this.add.graphics(); hov.setVisible(false);
const draw = (isHover) => {
const target = isHover ? hov : gfx;
target.clear();
target.fillStyle(isHover ? 0x3d2d0a : 0x2a1f0a, 1);
target.fillRoundedRect(cx - w/2, cy - h/2, w, h, 14);
target.lineStyle(2, isHover ? COLORS.gold : (allDone ? 0xd4a017 : 0x6a4a1a), 1);
target.strokeRoundedRect(cx - w/2, cy - h/2, w, h, 14);
};
draw(false);
hov.lineStyle(2, COLORS.gold, 1);
hov.strokeRoundedRect(cx - w/2 + 1, cy - h/2 + 1, w - 2, h - 2, 14);
const numText = this.add.text(cx, cy - 32, String(penta), {
fontFamily: '"Julius Sans One"', fontSize: '72px',
color: allDone ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5, 0.5);
const sizeText = this.add.text(cx, cy + 36, `5 × ${penta} grid`, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const doneText = this.add.text(cx, cy + 62, `${done} / ${total}`, {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: allDone ? COLORS.goldHex : COLORS.mutedHex,
}).setOrigin(0.5);
const zone = this.add.zone(cx, cy, w - 4, h - 4).setInteractive({ useHandCursor: true });
zone.on('pointerover', () => hov.setVisible(true));
zone.on('pointerout', () => hov.setVisible(false));
zone.on('pointerdown', () => this._showSetSelect(penta));
sc.add([gfx, hov, numText, sizeText, doneText, zone]);
}
// ── Screen 2: Set Select ───────────────────────────────────────────────────
_showSetSelect(penta) {
this._clearScreen();
this._screen = 'set';
this._penta = penta;
const sc = this._screenContainer = this.add.container(0, 0).setDepth(D.ui);
const puzzles = this._bank?.pentas?.[String(penta)] ?? [];
const total = puzzles.length;
const done = this._countDone(penta);
// Header (fixed, not scrolled)
const backBtn = new Button(this, 90, 50, '← Back',
() => this._showPentaSelect(),
{ variant: 'ghost', width: 140, height: 44, fontSize: 16 }
).setDepth(D.ui + 1);
const titleTxt = this.add.text(GAME_WIDTH / 2, 35, `Penta ${penta}`, {
fontFamily: '"Julius Sans One"', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5);
const statsTxt = this.add.text(GAME_WIDTH / 2, 78, `${done} / ${total} completed`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
sc.add([backBtn, titleTxt, statsTxt]);
const leave = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 50, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, height: 48, fontSize: 18 }
).setDepth(D.ui + 1);
sc.add(leave);
// Scrollable card area
const scrollContainer = this.add.container(0, SCROLL_AREA_Y);
sc.add(scrollContainer);
// Geometry mask to clip the scroll area
this._maskGfx = this.make.graphics({ add: false });
this._maskGfx.fillStyle(0xffffff);
this._maskGfx.fillRect(0, SCROLL_AREA_Y, GAME_WIDTH, SCROLL_AREA_H);
scrollContainer.setMask(this._maskGfx.createGeometryMask());
// Build all cards at local positions (local y=0 is top of scroll area)
for (let i = 0; i < puzzles.length; i++) {
const col = i % COLS_PER_ROW;
const row = Math.floor(i / COLS_PER_ROW);
const cx = CARDS_MARGIN + col * (CARD_W + CARD_GAP) + CARD_W / 2;
const cy = row * ROW_H + CARD_H / 2;
this._buildPuzzleCard(scrollContainer, cx, cy, penta, puzzles[i]);
}
const rows = Math.ceil(puzzles.length / COLS_PER_ROW);
const contentH = rows * ROW_H;
const maxScroll = Math.max(0, contentH - SCROLL_AREA_H);
let scrollY = 0;
const doScroll = (dy) => {
scrollY = Phaser.Math.Clamp(scrollY + dy, 0, maxScroll);
scrollContainer.y = SCROLL_AREA_Y - scrollY;
};
this._addInputHandler('wheel', (ptr, _o, _dx, dy) => {
if (ptr.y >= SCROLL_AREA_Y && ptr.y <= SCROLL_AREA_Y + SCROLL_AREA_H) doScroll(dy * 0.5);
});
let dragOriginY = 0, dragOriginScroll = 0, dragging = false;
this._addInputHandler('pointerdown', (ptr) => {
if (ptr.y >= SCROLL_AREA_Y && ptr.y <= SCROLL_AREA_Y + SCROLL_AREA_H) {
dragging = true; dragOriginY = ptr.y; dragOriginScroll = scrollY;
}
});
this._addInputHandler('pointermove', (ptr) => {
if (!dragging || !ptr.isDown) return;
doScroll(dragOriginScroll - (ptr.y - dragOriginY) - scrollY);
});
this._addInputHandler('pointerup', () => { dragging = false; });
}
_buildPuzzleCard(sc, cx, cy, penta, puzzleData) {
const done = this._isDone(penta, puzzleData.idx);
const pieces = puzzleData.pieces;
const gfx = this.add.graphics();
gfx.fillStyle(done ? 0x141e10 : COLORS.panel, 1);
gfx.fillRoundedRect(cx - CARD_W/2, cy - CARD_H/2, CARD_W, CARD_H, 8);
gfx.lineStyle(2, done ? 0x3a8040 : 0x3a3020, 1);
gfx.strokeRoundedRect(cx - CARD_W/2, cy - CARD_H/2, CARD_W, CARD_H, 8);
// Piece color chips
const CHIP_W = 18, CHIP_H = 12, CHIP_GAP = 4, CHIPS_PER_ROW = 6;
const chipLeft = cx - CARD_W/2 + 10;
const chipTop = cy - CARD_H/2 + 34;
for (let i = 0; i < pieces.length; i++) {
const col = i % CHIPS_PER_ROW;
const row = Math.floor(i / CHIPS_PER_ROW);
gfx.fillStyle(PIECE_COLORS[pieces[i]], 1);
gfx.fillRoundedRect(
chipLeft + col * (CHIP_W + CHIP_GAP),
chipTop + row * (CHIP_H + CHIP_GAP),
CHIP_W, CHIP_H, 3
);
}
// Done checkmark
if (done) {
gfx.fillStyle(0x3a8040, 0.25);
gfx.fillRoundedRect(cx - CARD_W/2, cy - CARD_H/2, CARD_W, CARD_H, 8);
gfx.fillStyle(0x44aa55, 1);
gfx.fillCircle(cx + CARD_W/2 - 14, cy - CARD_H/2 + 14, 9);
gfx.lineStyle(2, 0xffffff, 1);
gfx.beginPath();
gfx.moveTo(cx + CARD_W/2 - 20, cy - CARD_H/2 + 14);
gfx.lineTo(cx + CARD_W/2 - 14, cy - CARD_H/2 + 20);
gfx.lineTo(cx + CARD_W/2 - 7, cy - CARD_H/2 + 8);
gfx.strokePath();
}
const numTxt = this.add.text(cx - CARD_W/2 + 8, cy - CARD_H/2 + 8, `#${puzzleData.idx + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '11px',
color: done ? '#44aa55' : COLORS.mutedHex,
}).setOrigin(0, 0);
// Hover highlight
const hov = this.add.graphics(); hov.setVisible(false);
hov.lineStyle(2, COLORS.accent, 1);
hov.strokeRoundedRect(cx - CARD_W/2 + 1, cy - CARD_H/2 + 1, CARD_W - 2, CARD_H - 2, 8);
const zone = this.add.zone(cx, cy, CARD_W - 2, CARD_H - 2).setInteractive({ useHandCursor: true });
zone.on('pointerover', () => hov.setVisible(true));
zone.on('pointerout', () => hov.setVisible(false));
zone.on('pointerdown', (ptr) => {
if (ptr.y < SCROLL_AREA_Y || ptr.y > SCROLL_AREA_Y + SCROLL_AREA_H) return;
this._showPlay(this._penta, puzzleData.idx, puzzleData.pieces);
});
sc.add([gfx, numTxt, hov, zone]);
}
// ── Screen 3: Play ─────────────────────────────────────────────────────────
_showPlay(penta, puzzleIdx, pieces) {
this._clearScreen();
this._screen = 'play';
this._penta = penta;
this._puzzleIdx = puzzleIdx;
this._pieces = pieces.slice();
this._cols = penta;
this._board = newBoard(penta);
this._placedPieces.clear();
const boardW = penta * CELL;
this._boardX = (GAME_WIDTH - boardW) / 2;
const sc = this._screenContainer = this.add.container(0, 0).setDepth(D.board);
// Title bar
const backBtn = new Button(this, 90, 46, '← Back',
() => this._showSetSelect(penta),
{ variant: 'ghost', width: 140, height: 44, fontSize: 16 }
).setDepth(D.ui + 1);
const titleTxt = this.add.text(GAME_WIDTH / 2, 35, `Penta ${penta} — Puzzle #${puzzleIdx + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
sc.add([backBtn, titleTxt]);
// Reset and Leave buttons
const resetBtn = new Button(this, GAME_WIDTH - 310, 46, 'Reset',
() => this._resetBoard(),
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
).setDepth(D.ui + 1);
const leaveBtn = new Button(this, GAME_WIDTH - 150, 46, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
).setDepth(D.ui + 1);
sc.add([resetBtn, leaveBtn]);
// Board graphics
this._boardGfx = this.add.graphics().setDepth(D.board);
sc.add(this._boardGfx);
// Board input zone (use setOrigin(0) so x,y = top-left)
const zone = this.add.zone(this._boardX, BOARD_Y, boardW, ROWS * CELL)
.setOrigin(0)
.setInteractive({ useHandCursor: true })
.setDepth(D.piece);
zone.on('pointermove', ptr => this._onBoardHover(ptr));
zone.on('pointerdown', ptr => this._onBoardClick(ptr));
zone.on('pointerout', () => { this._ghostCells = null; this._renderBoard(); });
sc.add(zone);
// Piece tray
this._buildTray(sc, pieces);
// Keyboard hints
const trayY = BOARD_Y + ROWS * CELL + TRAY_GAP;
const hintY = trayY + SLOT_H + 16;
const hint = this.add.text(GAME_WIDTH / 2, hintY, 'R = Rotate F = Flip ESC = Deselect', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5);
sc.add(hint);
this._renderBoard();
this._renderTray();
}
_buildTray(sc, pieces) {
const trayY = BOARD_Y + ROWS * CELL + TRAY_GAP;
for (let i = 0; i < pieces.length; i++) {
const pieceId = pieces[i];
const cx = this._boardX + i * CELL + CELL / 2;
const cy = trayY + SLOT_H / 2;
const bg = this.add.graphics();
const pieceGfx = this.add.graphics();
const container = this.add.container(cx, cy, [bg, pieceGfx]);
container.setSize(CELL - 6, SLOT_H - 6);
container.setInteractive({ useHandCursor: true });
container.on('pointerdown', () => this._selectPiece(pieceId));
container.setDepth(D.tray);
this._traySlots.push({ container, bg, pieceGfx, pieceId });
sc.add(container);
}
}
_drawSlotBg(gfx, selected) {
gfx.clear();
gfx.fillStyle(selected ? SLOT_SEL : SLOT_BG, 1);
gfx.fillRoundedRect(-CELL/2 + 4, -SLOT_H/2 + 4, CELL - 8, SLOT_H - 8, 6);
gfx.lineStyle(2, selected ? COLORS.accent : BOARD_LINE, selected ? 1 : 0.5);
gfx.strokeRoundedRect(-CELL/2 + 4, -SLOT_H/2 + 4, CELL - 8, SLOT_H - 8, 6);
}
_drawMiniPiece(gfx, pieceId, dimmed) {
gfx.clear();
const cells = ORIENTATIONS[pieceId][0];
let maxR = 0, maxC = 0;
for (const [r, c] of cells) { maxR = Math.max(maxR, r); maxC = Math.max(maxC, c); }
const sx = -((maxC + 1) * MINI) / 2;
const sy = -((maxR + 1) * MINI) / 2;
const color = dimmed ? 0x444444 : (PIECE_COLORS[pieceId] ?? 0x888888);
const alpha = dimmed ? 0.35 : 1;
gfx.fillStyle(color, alpha);
for (const [r, c] of cells) {
gfx.fillRoundedRect(sx + c * MINI + 1, sy + r * MINI + 1, MINI - 2, MINI - 2, 3);
}
if (!dimmed) {
gfx.fillStyle(0xffffff, 0.18);
for (const [r, c] of cells) {
if (r === 0) gfx.fillRoundedRect(sx + c * MINI + 2, sy + r * MINI + 2, MINI - 4, 5, 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, selected && !placed);
this._drawMiniPiece(slot.pieceGfx, slot.pieceId, placed);
slot.container.setInteractive(placed ? false : { useHandCursor: true });
}
}
_renderBoard() {
const gfx = this._boardGfx;
const cols = this._cols;
const bx = this._boardX;
const by = BOARD_Y;
const boardW = cols * CELL;
const boardH = ROWS * CELL;
gfx.clear();
// Board background
gfx.fillStyle(BOARD_BG, 1);
gfx.fillRoundedRect(bx - 4, by - 4, boardW + 8, boardH + 8, 12);
// Grid lines
gfx.lineStyle(1, BOARD_LINE, 0.6);
for (let r = 0; r <= ROWS; r++) {
gfx.beginPath(); gfx.moveTo(bx, by + r*CELL); gfx.lineTo(bx + boardW, by + r*CELL); gfx.strokePath();
}
for (let c = 0; c <= cols; c++) {
gfx.beginPath(); gfx.moveTo(bx + c*CELL, by); gfx.lineTo(bx + c*CELL, by + boardH); gfx.strokePath();
}
// Placed pieces
for (let idx = 0; idx < this._board.length; idx++) {
const val = this._board[idx];
if (val === null) continue;
const r = (idx / cols) | 0, c = idx % cols;
const px = bx + c * CELL, py = by + r * CELL;
const color = PIECE_COLORS[val] ?? 0x888888;
gfx.fillStyle(color, 1);
gfx.fillRoundedRect(px + 3, py + 3, CELL - 6, CELL - 6, 9);
gfx.fillStyle(0xffffff, 0.15);
gfx.fillRoundedRect(px + 5, py + 5, CELL - 10, 10, 3);
gfx.fillStyle(0x000000, 0.1);
gfx.fillRoundedRect(px + 3, py + CELL - 14, CELL - 6, 11, 3);
}
// Ghost preview
if (this._ghostCells && this._selectedPiece) {
const pid = this._selectedPiece.pieceId;
const clr = PIECE_COLORS[pid] ?? 0xffffff;
const ok = this._ghostCells.every(([r, c]) =>
r >= 0 && r < ROWS && c >= 0 && c < cols && this._board[r * cols + c] === null
);
gfx.fillStyle(clr, ok ? 0.55 : 0.2);
for (const [r, c] of this._ghostCells) {
if (r < 0 || r >= ROWS || c < 0 || c >= cols) continue;
gfx.fillRoundedRect(bx + c*CELL + 3, by + r*CELL + 3, CELL - 6, CELL - 6, 9);
}
}
}
// ── Piece selection & placement ────────────────────────────────────────────
_selectPiece(pieceId) {
if (this._screen !== 'play' || this._placedPieces.has(pieceId)) return;
if (this._selectedPiece?.pieceId === pieceId) { this._clearSelection(); return; }
this._selectedPiece = { pieceId, oriIdx: 0 };
this._ghostCells = null;
this._renderTray();
this._renderBoard();
}
_clearSelection() {
this._selectedPiece = null;
this._ghostCells = null;
this._lastHoverR = -1;
this._lastHoverC = -1;
this._renderTray();
this._renderBoard();
}
_rotateSelected() {
if (this._screen !== 'play' || !this._selectedPiece) return;
this._selectedPiece.oriIdx = rotateOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
}
_flipSelected() {
if (this._screen !== 'play' || !this._selectedPiece) return;
this._selectedPiece.oriIdx = flipOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
}
_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);
}
_onBoardHover(ptr) {
if (!this._selectedPiece || this._screen !== 'play') return;
const r = Math.floor((ptr.worldY - BOARD_Y) / CELL);
const c = Math.floor((ptr.worldX - this._boardX) / CELL);
this._lastHoverR = r;
this._lastHoverC = c;
this._updateGhostAt(r, c);
this._renderBoard();
}
_onBoardClick(ptr) {
if (this._screen !== 'play') return;
const r = Math.floor((ptr.worldY - BOARD_Y) / CELL);
const c = Math.floor((ptr.worldX - this._boardX) / CELL);
if (r < 0 || r >= ROWS || c < 0 || c >= this._cols) return;
const cellVal = this._board[r * this._cols + c];
if (cellVal !== null) { this._liftPiece(cellVal); return; }
if (!this._selectedPiece || !this._ghostCells) return;
if (!canPlace(this._board, this._cols, this._ghostCells)) {
playSound(this, SFX.PIECE_CLICK);
return;
}
const { pieceId } = this._selectedPiece;
this._board = placePiece(this._board, this._cols, this._ghostCells, pieceId);
this._placedPieces.add(pieceId);
playSound(this, SFX.CARD_PLACE);
this._clearSelection();
this._renderBoard();
this._renderTray();
if (isSolved(this._board)) this._showWinOverlay();
}
_liftPiece(pieceId) {
const cells = [];
for (let i = 0; i < this._board.length; i++) {
if (this._board[i] === pieceId) cells.push([(i / this._cols) | 0, i % this._cols]);
}
this._board = removePiece(this._board, this._cols, cells);
this._placedPieces.delete(pieceId);
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._renderBoard();
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++) {
if (oris[i].map(([r, c]) => `${r},${c}`).join(';') === normKey) return i;
}
return 0;
}
_resetBoard() {
if (this._screen !== 'play') return;
this._board = newBoard(this._cols);
this._placedPieces.clear();
this._clearSelection();
}
// ── Win overlay ────────────────────────────────────────────────────────────
_showWinOverlay() {
this._markDone(this._penta, this._puzzleIdx);
const penta = this._penta;
const puzzleIdx = this._puzzleIdx;
const allPuzzles = this._bank?.pentas?.[String(penta)] ?? [];
const dim = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setDepth(D.overlay);
this._screenContainer.add(dim);
const panelW = 480, panelH = 360;
const panelX = GAME_WIDTH/2 - panelW/2;
const panelY = GAME_HEIGHT/2 - panelH/2;
const panel = this.add.graphics().setDepth(D.overlay + 1);
panel.fillStyle(0x1e1a12, 1);
panel.fillRoundedRect(panelX, panelY, panelW, panelH, 20);
panel.lineStyle(2, COLORS.gold, 1);
panel.strokeRoundedRect(panelX, panelY, panelW, panelH, 20);
this._screenContainer.add(panel);
const solvedTxt = this.add.text(GAME_WIDTH/2, panelY + 52, 'Solved!', {
fontFamily: '"Julius Sans One"', fontSize: '56px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const subTxt = this.add.text(GAME_WIDTH/2, panelY + 108, `Penta ${penta} — Puzzle #${puzzleIdx + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this._screenContainer.add([solvedTxt, subTxt]);
const btnCX = GAME_WIDTH / 2;
const hasNext = puzzleIdx + 1 < allPuzzles.length;
if (hasNext) {
const next = allPuzzles[puzzleIdx + 1];
const nextBtn = new Button(this, btnCX, panelY + 170, 'Next Puzzle',
() => this._showPlay(penta, next.idx, next.pieces),
{ width: 280, height: 54, fontSize: 22 }
).setDepth(D.overlayUI + 1);
this._screenContainer.add(nextBtn);
}
const replayBtn = new Button(this, btnCX, panelY + (hasNext ? 240 : 190), 'Play Again',
() => this._showPlay(penta, puzzleIdx, this._pieces),
{ variant: 'ghost', width: 280, height: 50, fontSize: 20 }
).setDepth(D.overlayUI + 1);
const backBtn = new Button(this, btnCX, panelY + (hasNext ? 300 : 250), 'Back to Set',
() => this._showSetSelect(penta),
{ variant: 'ghost', width: 280, height: 50, fontSize: 20 }
).setDepth(D.overlayUI + 1);
this._screenContainer.add([replayBtn, backBtn]);
}
}

View File

@ -0,0 +1,122 @@
// Katamino — pure game logic, no Phaser.
//
// Board is a flat array (ROWS × cols, row-major):
// null = empty
// pieceId = piece letter placed there ('F', 'I', 'L', …)
export const ROWS = 5;
export const PIECES = [
{ id: 'F', cells: [[0,1],[0,2],[1,0],[1,1],[2,1]] },
{ id: 'I', cells: [[0,0],[1,0],[2,0],[3,0],[4,0]] },
{ id: 'L', cells: [[0,0],[1,0],[2,0],[3,0],[3,1]] },
{ id: 'N', cells: [[0,0],[1,0],[2,0],[2,1],[3,1]] },
{ id: 'P', cells: [[0,0],[0,1],[1,0],[1,1],[2,0]] },
{ id: 'T', cells: [[0,0],[0,1],[0,2],[1,1],[2,1]] },
{ id: 'U', cells: [[0,0],[0,1],[1,0],[2,0],[2,1]] },
{ id: 'V', cells: [[0,0],[1,0],[2,0],[2,1],[2,2]] },
{ id: 'W', cells: [[0,0],[1,0],[1,1],[2,1],[2,2]] },
{ id: 'X', cells: [[0,1],[1,0],[1,1],[1,2],[2,1]] },
{ id: 'Y', cells: [[0,0],[1,0],[1,1],[2,0],[3,0]] },
{ id: 'Z', cells: [[0,0],[0,1],[1,1],[2,1],[2,2]] },
];
export const PIECE_IDS = PIECES.map(p => p.id);
export const PIECE_COLORS = {
F: 0xff6b9d,
I: 0x45c4f0,
L: 0xff9940,
N: 0xa0e03a,
P: 0x9b6cf5,
T: 0xf7e03c,
U: 0x3ae0c4,
V: 0xff4a4a,
W: 0x4af74a,
X: 0xf74af7,
Y: 0x4a9fff,
Z: 0xf0a020,
};
// ── Orientation generation ─────────────────────────────────────────────────────
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]);
}
}
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;
}
export function rotateOri(pieceId, oriIdx) {
return transformedIndex(pieceId, oriIdx, ([r, c]) => [c, -r]);
}
export function flipOri(pieceId, oriIdx) {
return transformedIndex(pieceId, oriIdx, ([r, c]) => [r, -c]);
}
// ── Board helpers ─────────────────────────────────────────────────────────────
export function absoluteCells(oriCells, anchorR, anchorC) {
return oriCells.map(([dr, dc]) => [anchorR + dr, anchorC + dc]);
}
export function newBoard(cols) {
return Array(ROWS * cols).fill(null);
}
export function canPlace(board, cols, cells) {
for (const [r, c] of cells) {
if (r < 0 || r >= ROWS || c < 0 || c >= cols) return false;
if (board[r * cols + c] !== null) return false;
}
return true;
}
export function placePiece(board, cols, cells, pieceId) {
const next = board.slice();
for (const [r, c] of cells) next[r * cols + c] = pieceId;
return next;
}
export function removePiece(board, cols, cells) {
const next = board.slice();
for (const [r, c] of cells) next[r * cols + c] = null;
return next;
}
export function isSolved(board) {
return board.every(cell => cell !== null);
}

View File

@ -81,6 +81,7 @@ import RummikubGame from './games/rummikub/RummikubGame.js';
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
import RiskGame from './games/risk/RiskGame.js';
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
import KataminoGame from './games/katamino/KataminoGame.js';
const config = {
type: Phaser.AUTO,
@ -175,6 +176,7 @@ const config = {
GinRummyGame,
RiskGame,
GeniusSquareGame,
KataminoGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame' };
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', katamino: 'KataminoGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -70,6 +70,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('jewelquest', '/data/jewelquest.json');
this.load.json('zuma', '/data/zuma.json');
this.load.json('dotlink', '/data/dotlink.json');
this.load.json('katamino', '/data/katamino.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');

View File

@ -97,3 +97,4 @@ registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame:
registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 });
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54 });
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });

View File

@ -0,0 +1,176 @@
// Offline generator for Katamino puzzle bank.
//
// For each Penta level N (312) it enumerates every C(12, N) combination of
// the 12 standard pentominoes and runs a backtracking solver to check whether
// that set can tile a 5×N rectangle. Valid combinations become the puzzle bank.
//
// Usage:
// node server/scripts/genKatamino.js [outFile]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, '../../public/data/katamino.json');
const ROWS = 5;
const PIECES = [
{ id: 'F', cells: [[0,1],[0,2],[1,0],[1,1],[2,1]] },
{ id: 'I', cells: [[0,0],[1,0],[2,0],[3,0],[4,0]] },
{ id: 'L', cells: [[0,0],[1,0],[2,0],[3,0],[3,1]] },
{ id: 'N', cells: [[0,0],[1,0],[2,0],[2,1],[3,1]] },
{ id: 'P', cells: [[0,0],[0,1],[1,0],[1,1],[2,0]] },
{ id: 'T', cells: [[0,0],[0,1],[0,2],[1,1],[2,1]] },
{ id: 'U', cells: [[0,0],[0,1],[1,0],[2,0],[2,1]] },
{ id: 'V', cells: [[0,0],[1,0],[2,0],[2,1],[2,2]] },
{ id: 'W', cells: [[0,0],[1,0],[1,1],[2,1],[2,2]] },
{ id: 'X', cells: [[0,1],[1,0],[1,1],[1,2],[2,1]] },
{ id: 'Y', cells: [[0,0],[1,0],[1,1],[2,0],[3,0]] },
{ id: 'Z', cells: [[0,0],[0,1],[1,1],[2,1],[2,2]] },
];
// ── Orientation precompute ────────────────────────────────────────────────────
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]);
}
}
return [...seen.values()];
}
// Pre-flatten each orientation to [r0,c0, r1,c1, ...] for fast access
const ORIS_BY_ID = Object.fromEntries(
PIECES.map(p => [p.id, computeOrientations(p.cells).map(cells => {
const flat = new Int8Array(10);
for (let i = 0; i < 5; i++) { flat[i*2] = cells[i][0]; flat[i*2+1] = cells[i][1]; }
return flat;
})])
);
// ── Backtracking solver ───────────────────────────────────────────────────────
// Iterates a fixed pieceIds[] array + used[] boolean array so we never mutate
// the structure being iterated (which would cause infinite loops).
// Cell indices are stored in plain local variables (i0..i4) rather than a
// shared buffer so recursive calls cannot clobber each other's placed data.
function hasSolution(cols, pieceIds) {
const board = new Int8Array(ROWS * cols); // 0=empty, 1=filled
const used = new Uint8Array(pieceIds.length);
return _solve(board, cols, pieceIds, used);
}
function _solve(board, cols, pieceIds, used) {
// First empty cell (row-major forcing)
let emptyIdx = -1;
for (let i = 0; i < board.length; i++) { if (board[i] === 0) { emptyIdx = i; break; } }
if (emptyIdx === -1) return true; // all cells filled (N pieces × 5 = 5×N board)
const targetR = (emptyIdx / cols) | 0;
const targetC = emptyIdx % cols;
for (let pi = 0; pi < pieceIds.length; pi++) {
if (used[pi]) continue;
const oris = ORIS_BY_ID[pieceIds[pi]];
for (let oi = 0; oi < oris.length; oi++) {
const ori = oris[oi];
// Try each cell of this orientation as the "pin" onto targetR/targetC
for (let k = 0; k < 5; k++) {
const anchorR = targetR - ori[k*2];
const anchorC = targetC - ori[k*2+1];
// Validate all 5 cells and collect board indices into local variables
const a0r = anchorR + ori[0], a0c = anchorC + ori[1];
const a1r = anchorR + ori[2], a1c = anchorC + ori[3];
const a2r = anchorR + ori[4], a2c = anchorC + ori[5];
const a3r = anchorR + ori[6], a3c = anchorC + ori[7];
const a4r = anchorR + ori[8], a4c = anchorC + ori[9];
if (a0r < 0 || a0r >= ROWS || a0c < 0 || a0c >= cols || board[a0r*cols+a0c]) continue;
if (a1r < 0 || a1r >= ROWS || a1c < 0 || a1c >= cols || board[a1r*cols+a1c]) continue;
if (a2r < 0 || a2r >= ROWS || a2c < 0 || a2c >= cols || board[a2r*cols+a2c]) continue;
if (a3r < 0 || a3r >= ROWS || a3c < 0 || a3c >= cols || board[a3r*cols+a3c]) continue;
if (a4r < 0 || a4r >= ROWS || a4c < 0 || a4c >= cols || board[a4r*cols+a4c]) continue;
const i0=a0r*cols+a0c, i1=a1r*cols+a1c, i2=a2r*cols+a2c, i3=a3r*cols+a3c, i4=a4r*cols+a4c;
board[i0]=1; board[i1]=1; board[i2]=1; board[i3]=1; board[i4]=1;
used[pi] = 1;
if (_solve(board, cols, pieceIds, used)) return true;
board[i0]=0; board[i1]=0; board[i2]=0; board[i3]=0; board[i4]=0;
used[pi] = 0;
}
}
}
return false;
}
// ── Combination generator ─────────────────────────────────────────────────────
function combinations(arr, k) {
const result = [];
function pick(start, cur) {
if (cur.length === k) { result.push([...cur]); return; }
for (let i = start; i <= arr.length - (k - cur.length); i++) {
cur.push(arr[i]);
pick(i + 1, cur);
cur.pop();
}
}
pick(0, []);
return result;
}
// ── Main generation ───────────────────────────────────────────────────────────
const pieceIds = PIECES.map(p => p.id);
const pentas = {};
const t0 = Date.now();
for (let n = 3; n <= 12; n++) {
const combos = combinations(pieceIds, n);
const valid = [];
process.stdout.write(`Penta ${n} (5×${n}): ${combos.length} combos … `);
const t1 = Date.now();
for (const combo of combos) {
if (hasSolution(n, combo)) {
valid.push({ idx: valid.length, pieces: combo });
}
}
const dt = ((Date.now() - t1) / 1000).toFixed(1);
console.log(`${valid.length} valid (${dt}s)`);
pentas[String(n)] = valid;
}
const output = {
generatedAt: new Date().toISOString(),
totalTime: `${((Date.now() - t0) / 1000).toFixed(1)}s`,
pentas,
};
fs.writeFileSync(OUT_FILE, JSON.stringify(output));
console.log(`\nWrote ${OUT_FILE} (${(fs.statSync(OUT_FILE).size / 1024).toFixed(0)} KB)`);