Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig b7821c3bd8 feat: add piece rotation/flip animations and UI sound feedback
Enhance Genius Square and Katamino with:
- Smooth piece rotation and flip animations (scale + rotate tweens)
- Pulsing outline glow on ghost piece preview
- New UI sound effects: pick, activate, flip, place, chime
- Music player support in both games
- Win chime with delayed overlay for better feedback
- Move Reset/Leave buttons to bottom in Katamino
2026-06-20 09:31:04 -06:00
Brian Fertig 762792f9f5 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
2026-06-20 08:26:23 -06:00
17 changed files with 1380 additions and 10 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

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'); }
@ -70,7 +84,10 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.humanGridGfx = null;
this.aiGridGfx = null;
this.traySlots = []; // [{ container, pieceId }]
this._pulseGfx = null;
this._ghostAnimContainer = null;
this._ghostAnim = null;
this.traySlots = []; // [{ container, pieceId }]
this.opponentPortrait = null;
}
@ -110,6 +127,43 @@ export default class GeniusSquareGame extends Phaser.Scene {
this._startCountdown();
}
update(time) {
if (!this._pulseGfx || !this.ghostCells || !this.selectedPiece) {
this._pulseGfx?.clear();
return;
}
const cells = this.ghostCells;
const pieceId = this.selectedPiece.pieceId;
const alpha = 0.35 + 0.65 * Math.abs(Math.sin(time * 0.004));
const allValid = cells.every(([r, c]) =>
r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null
);
const color = allValid ? (PIECE_COLORS[pieceId] ?? 0xffffff) : 0xff4444;
const ghostSet = new Set(cells.map(([r, c]) => `${r},${c}`));
const OFF = 4;
this._pulseGfx.clear();
this._pulseGfx.lineStyle(5, color, alpha);
for (const [r, c] of cells) {
const x0 = HGX + c * CELL, y0 = HGY + r * CELL;
const x1 = x0 + CELL, y1 = y0 + CELL;
if (!ghostSet.has(`${r - 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y0 - OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r + 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y1 + OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c - 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x0 - OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c + 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x1 + OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
}
}
// ── Scene construction ────────────────────────────────────────────────────────
_buildBackground() {
@ -151,6 +205,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
_buildGridGraphics() {
this.humanGridGfx = this.add.graphics().setDepth(D.grid);
this.aiGridGfx = this.add.graphics().setDepth(D.grid);
this._pulseGfx = this.add.graphics().setDepth(D.grid + 1);
}
_buildLabels() {
@ -344,6 +399,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
}
this.selectedPiece = { pieceId, oriIdx: 0 };
this.ghostCells = null;
playSound(this, SFX.UI_PICK);
this._renderTray();
this._renderHumanGrid();
}
@ -359,22 +415,137 @@ export default class GeniusSquareGame extends Phaser.Scene {
_rotate() {
if (!this.selectedPiece || this.gameOver) return;
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
playSound(this, SFX.UI_ACTIVATE);
this._cancelGhostAnim();
if (!this.ghostCells) {
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
return;
}
this._startGhostAnim('rotate');
}
_flip() {
if (!this.selectedPiece || this.gameOver) return;
this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
playSound(this, SFX.UI_FLIP);
this._cancelGhostAnim();
if (!this.ghostCells) {
this.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
return;
}
this._startGhostAnim('flip');
}
_cancelGhostAnim() {
if (!this._ghostAnimContainer) return;
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
}
_startGhostAnim(type) {
const cells = this.ghostCells;
const pieceId = this.selectedPiece.pieceId;
const hoverR = this._lastHoverR;
const hoverC = this._lastHoverC;
const n = cells.length;
const cr = cells.reduce((s, [r]) => s + r, 0) / n;
const cc = cells.reduce((s, [, c]) => s + c, 0) / n;
const gfx = this.add.graphics();
this._fillGhostLocal(gfx, cells, cr, cc);
const pivotX = HGX + (cc + 0.5) * CELL;
const pivotY = HGY + (cr + 0.5) * CELL;
const container = this.add.container(pivotX, pivotY, [gfx]).setDepth(D.piece + 1);
this._ghostAnimContainer = container;
// Suppress normal ghost & pulse while animating
this.ghostCells = null;
this._pulseGfx?.clear();
this._renderHumanGrid();
const HALF = 80;
const finish = () => {
container.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderHumanGrid();
};
const phase2 = () => {
const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx];
const [pinDr, pinDc] = _centerPin(oriCells);
const newCells = (hoverR >= 0 && hoverC >= 0)
? absoluteCells(oriCells, hoverR - pinDr, hoverC - pinDc)
: null;
if (!newCells) { finish(); return; }
const nn = newCells.length;
const ncr = newCells.reduce((s, [r]) => s + r, 0) / nn;
const ncc = newCells.reduce((s, [, c]) => s + c, 0) / nn;
gfx.clear();
this._fillGhostLocal(gfx, newCells, ncr, ncc);
container.setPosition(HGX + (ncc + 0.5) * CELL, HGY + (ncr + 0.5) * CELL);
if (type === 'rotate') container.angle = -90;
this._ghostAnim = this.tweens.add({
targets: container,
angle: 0, scaleX: 1, scaleY: 1,
duration: HALF,
ease: 'Power2.easeOut',
onComplete: finish,
});
};
const midpoint = () => {
if (type === 'rotate') {
this.selectedPiece.oriIdx = rotateOri(pieceId, this.selectedPiece.oriIdx);
} else {
this.selectedPiece.oriIdx = flipOri(pieceId, this.selectedPiece.oriIdx);
}
phase2();
};
const phase1Props = type === 'rotate'
? { angle: 90, scaleX: 0, scaleY: 0 }
: { scaleX: 0 };
this._ghostAnim = this.tweens.add({
targets: container,
...phase1Props,
duration: HALF,
ease: 'Power2.easeIn',
onComplete: midpoint,
});
}
_fillGhostLocal(gfx, cells, cr, cc) {
const pieceId = this.selectedPiece?.pieceId;
const color = PIECE_COLORS[pieceId] ?? 0xffffff;
const allValid = cells.every(([r, c]) =>
r >= 0 && r < 6 && c >= 0 && c < 6 && this.humanBoard[r * 6 + c] === null
);
gfx.fillStyle(color, allValid ? 0.55 : 0.2);
for (const [r, c] of cells) {
const lx = (c - cc) * CELL - CELL / 2;
const ly = (r - cr) * CELL - CELL / 2;
gfx.fillRoundedRect(lx + 3, ly + 3, CELL - 6, CELL - 6, 7);
}
}
_updateGhostAt(r, c) {
if (r < 0 || c < 0 || !this.selectedPiece) { this.ghostCells = null; return; }
const oriCells = ORIENTATIONS[this.selectedPiece.pieceId][this.selectedPiece.oriIdx];
this.ghostCells = absoluteCells(oriCells, r, c);
const [pinDr, pinDc] = _centerPin(oriCells);
this.ghostCells = absoluteCells(oriCells, r - pinDr, c - pinDc);
}
_onGridHover(ptr) {
@ -383,6 +554,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
const c = Math.floor((ptr.worldX - HGX) / CELL);
this._lastHoverR = r;
this._lastHoverC = c;
if (this._ghostAnimContainer) return; // let animation finish
this._updateGhostAt(r, c);
this._renderHumanGrid();
}
@ -398,6 +570,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
// Click on a placed piece → lift it back to the tray
if (cellVal && cellVal !== 'blocked') {
this._cancelGhostAnim();
this._liftPiece(cellVal);
return;
}
@ -411,7 +584,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
const { pieceId } = this.selectedPiece;
this.humanBoard = placePiece(this.humanBoard, this.ghostCells, pieceId);
this.placedPieces.add(pieceId);
playSound(this, SFX.CARD_PLACE);
playSound(this, SFX.UI_PLACE);
this._clearSelection();
this._renderHumanGrid();
this._renderTray();
@ -435,8 +608,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();
@ -541,6 +715,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
this.gameOver = true;
this._stopAI();
this.opponentPortrait?.playEmotion('upset');
playSound(this, SFX.UI_CHIME);
playSound(this, SFX.VICTORY_SHORT);
this._recordResult('win');
this._showOverlay(true);

View File

@ -0,0 +1,882 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
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._pulseGfx = null;
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._traySlots = [];
// Input handler refs for cleanup
this._inputHandlers = [];
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
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();
}
update(time) {
if (!this._pulseGfx || this._screen !== 'play' || !this._ghostCells || !this._selectedPiece) {
this._pulseGfx?.clear();
return;
}
const cells = this._ghostCells;
const pieceId = this._selectedPiece.pieceId;
const alpha = 0.35 + 0.65 * Math.abs(Math.sin(time * 0.004));
const allValid = cells.every(([r, c]) =>
r >= 0 && r < ROWS && c >= 0 && c < this._cols && this._board[r * this._cols + c] === null
);
const color = allValid ? (PIECE_COLORS[pieceId] ?? 0xffffff) : 0xff4444;
const ghostSet = new Set(cells.map(([r, c]) => `${r},${c}`));
const OFF = 4;
const bx = this._boardX;
this._pulseGfx.clear();
this._pulseGfx.lineStyle(5, color, alpha);
for (const [r, c] of cells) {
const x0 = bx + c * CELL, y0 = BOARD_Y + r * CELL;
const x1 = x0 + CELL, y1 = y0 + CELL;
if (!ghostSet.has(`${r - 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y0 - OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r + 1},${c}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y1 + OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c - 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x0 - OFF, y0 - OFF); this._pulseGfx.lineTo(x0 - OFF, y1 + OFF); this._pulseGfx.strokePath();
}
if (!ghostSet.has(`${r},${c + 1}`)) {
this._pulseGfx.beginPath(); this._pulseGfx.moveTo(x1 + OFF, y0 - OFF); this._pulseGfx.lineTo(x1 + OFF, y1 + OFF); this._pulseGfx.strokePath();
}
}
}
// ── 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 = [];
// Kill any in-progress piece animation
if (this._ghostAnimContainer) {
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
}
this._ghostAnim = null;
// 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._pulseGfx = 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 (lower right)
const resetBtn = new Button(this, GAME_WIDTH - 310, GAME_HEIGHT - 40, 'Reset',
() => this._resetBoard(),
{ variant: 'ghost', width: 140, height: 44, fontSize: 18 }
).setDepth(D.ui + 1);
const leaveBtn = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 40, '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);
// Pulse outline (above board fill, below tray)
this._pulseGfx = this.add.graphics().setDepth(D.board + 1);
sc.add(this._pulseGfx);
// 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;
playSound(this, SFX.UI_PICK);
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;
playSound(this, SFX.UI_ACTIVATE);
this._cancelGhostAnim();
if (!this._ghostCells) {
this._selectedPiece.oriIdx = rotateOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
return;
}
this._startGhostAnim('rotate');
}
_flipSelected() {
if (this._screen !== 'play' || !this._selectedPiece) return;
playSound(this, SFX.UI_FLIP);
this._cancelGhostAnim();
if (!this._ghostCells) {
this._selectedPiece.oriIdx = flipOri(this._selectedPiece.pieceId, this._selectedPiece.oriIdx);
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
return;
}
this._startGhostAnim('flip');
}
_cancelGhostAnim() {
if (!this._ghostAnimContainer) return;
this.tweens.killTweensOf(this._ghostAnimContainer);
this._ghostAnimContainer.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
}
_startGhostAnim(type) {
const cells = this._ghostCells;
const pieceId = this._selectedPiece.pieceId;
const hoverR = this._lastHoverR;
const hoverC = this._lastHoverC;
const n = cells.length;
const cr = cells.reduce((s, [r]) => s + r, 0) / n;
const cc = cells.reduce((s, [, c]) => s + c, 0) / n;
const gfx = this.add.graphics();
this._fillGhostLocal(gfx, cells, cr, cc);
const pivotX = this._boardX + (cc + 0.5) * CELL;
const pivotY = BOARD_Y + (cr + 0.5) * CELL;
const container = this.add.container(pivotX, pivotY, [gfx]).setDepth(D.piece + 1);
this._ghostAnimContainer = container;
// Suppress normal ghost & pulse while animating
this._ghostCells = null;
this._pulseGfx?.clear();
this._renderBoard();
const HALF = 80;
const finish = () => {
container.destroy(true);
this._ghostAnimContainer = null;
this._ghostAnim = null;
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
this._renderBoard();
};
const phase2 = () => {
const oriCells = ORIENTATIONS[this._selectedPiece.pieceId][this._selectedPiece.oriIdx];
const [pinDr, pinDc] = _centerPin(oriCells);
const newCells = (hoverR >= 0 && hoverC >= 0)
? absoluteCells(oriCells, hoverR - pinDr, hoverC - pinDc)
: null;
if (!newCells) { finish(); return; }
const nn = newCells.length;
const ncr = newCells.reduce((s, [r]) => s + r, 0) / nn;
const ncc = newCells.reduce((s, [, c]) => s + c, 0) / nn;
gfx.clear();
this._fillGhostLocal(gfx, newCells, ncr, ncc);
container.setPosition(this._boardX + (ncc + 0.5) * CELL, BOARD_Y + (ncr + 0.5) * CELL);
if (type === 'rotate') container.angle = -90;
this._ghostAnim = this.tweens.add({
targets: container,
angle: 0, scaleX: 1, scaleY: 1,
duration: HALF,
ease: 'Power2.easeOut',
onComplete: finish,
});
};
const midpoint = () => {
if (type === 'rotate') {
this._selectedPiece.oriIdx = rotateOri(pieceId, this._selectedPiece.oriIdx);
} else {
this._selectedPiece.oriIdx = flipOri(pieceId, this._selectedPiece.oriIdx);
}
phase2();
};
const phase1Props = type === 'rotate'
? { angle: 90, scaleX: 0, scaleY: 0 }
: { scaleX: 0 };
this._ghostAnim = this.tweens.add({
targets: container,
...phase1Props,
duration: HALF,
ease: 'Power2.easeIn',
onComplete: midpoint,
});
}
_fillGhostLocal(gfx, cells, cr, cc) {
const pieceId = this._selectedPiece?.pieceId;
const color = PIECE_COLORS[pieceId] ?? 0xffffff;
const allValid = cells.every(([r, c]) =>
r >= 0 && r < ROWS && c >= 0 && c < this._cols && this._board[r * this._cols + c] === null
);
gfx.fillStyle(color, allValid ? 0.55 : 0.2);
for (const [r, c] of cells) {
const lx = (c - cc) * CELL - CELL / 2;
const ly = (r - cr) * CELL - CELL / 2;
gfx.fillRoundedRect(lx + 3, ly + 3, CELL - 6, CELL - 6, 9);
}
}
_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;
if (this._ghostAnimContainer) return; // let animation finish
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._cancelGhostAnim(); 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.UI_PLACE);
this._clearSelection();
this._renderBoard();
this._renderTray();
if (isSolved(this._board)) {
playSound(this, SFX.UI_CHIME);
this.time.delayedCall(1000, () => 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');
@ -124,6 +125,11 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-woosh', '/assets/fx/woosh.mp3');
this.load.spritesheet('jello-items', '/assets/images/jello-items.png', { frameWidth: 132, frameHeight: 132 });
this.load.audio('sfx-jello-monsters','/assets/fx/jello-monsters.mp3');
this.load.audio('sfx-ui-pick', '/assets/fx/ui-pick.mp3');
this.load.audio('sfx-ui-activate', '/assets/fx/ui-activate.mp3');
this.load.audio('sfx-ui-flip', '/assets/fx/ui-flip.mp3');
this.load.audio('sfx-ui-place', '/assets/fx/ui-place.mp3');
this.load.audio('sfx-ui-chime', '/assets/fx/ui-chime.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -48,6 +48,11 @@ export const SFX = {
SQUASH: 'sfx-squash',
WOOSH: 'sfx-woosh',
JELLO_MONSTERS: 'sfx-jello-monsters',
UI_PICK: 'sfx-ui-pick',
UI_ACTIVATE: 'sfx-ui-activate',
UI_FLIP: 'sfx-ui-flip',
UI_PLACE: 'sfx-ui-place',
UI_CHIME: 'sfx-ui-chime',
};
export function playSound(scene, key) {

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 (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)`);