Compare commits
2 Commits
e3fc11d13a
...
b7821c3bd8
| Author | SHA1 | Date |
|---|---|---|
|
|
b7821c3bd8 | |
|
|
762792f9f5 |
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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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