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:
parent
e3fc11d13a
commit
762792f9f5
File diff suppressed because one or more lines are too long
|
|
@ -46,6 +46,20 @@ const GRID_LINE = 0x252540;
|
||||||
|
|
||||||
const D = { bg: -2, grid: 0, piece: 2, tray: 10, ui: 20, overlay: 60, overlayUI: 62 };
|
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 {
|
export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
constructor() { super('GeniusSquareGame'); }
|
constructor() { super('GeniusSquareGame'); }
|
||||||
|
|
||||||
|
|
@ -374,7 +388,8 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
_updateGhostAt(r, c) {
|
_updateGhostAt(r, c) {
|
||||||
if (r < 0 || c < 0 || !this.selectedPiece) { this.ghostCells = null; return; }
|
if (r < 0 || c < 0 || !this.selectedPiece) { this.ghostCells = null; return; }
|
||||||
const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx];
|
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) {
|
_onGridHover(ptr) {
|
||||||
|
|
@ -435,8 +450,9 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
||||||
const anchorR = cells[0][0] - oriCells[0][0];
|
const anchorR = cells[0][0] - oriCells[0][0];
|
||||||
const anchorC = cells[0][1] - oriCells[0][1];
|
const anchorC = cells[0][1] - oriCells[0][1];
|
||||||
this.ghostCells = absoluteCells(oriCells, anchorR, anchorC);
|
this.ghostCells = absoluteCells(oriCells, anchorR, anchorC);
|
||||||
this._lastHoverR = anchorR;
|
const [pinDr, pinDc] = _centerPin(oriCells);
|
||||||
this._lastHoverC = anchorC;
|
this._lastHoverR = anchorR + pinDr;
|
||||||
|
this._lastHoverC = anchorC + pinDc;
|
||||||
|
|
||||||
playSound(this, SFX.PIECE_CLICK);
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
this._renderHumanGrid();
|
this._renderHumanGrid();
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ 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';
|
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
|
||||||
|
import KataminoGame from './games/katamino/KataminoGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -175,6 +176,7 @@ const config = {
|
||||||
GinRummyGame,
|
GinRummyGame,
|
||||||
RiskGame,
|
RiskGame,
|
||||||
GeniusSquareGame,
|
GeniusSquareGame,
|
||||||
|
KataminoGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', 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]) {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('jewelquest', '/data/jewelquest.json');
|
this.load.json('jewelquest', '/data/jewelquest.json');
|
||||||
this.load.json('zuma', '/data/zuma.json');
|
this.load.json('zuma', '/data/zuma.json');
|
||||||
this.load.json('dotlink', '/data/dotlink.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-splash', '/assets/fx/water-splash.mp3');
|
||||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||||
|
|
|
||||||
|
|
@ -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: '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 });
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
// Offline generator for Katamino puzzle bank.
|
||||||
|
//
|
||||||
|
// For each Penta level N (3–12) 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)`);
|
||||||
Loading…
Reference in New Issue