Compare commits
No commits in common. "b7821c3bd8bb44d536956a356fa5817df723b076" and "e3fc11d13aeae8ac1e8d10122527459cd5bd3f35" have entirely different histories.
b7821c3bd8
...
e3fc11d13a
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: 266 KiB After Width: | Height: | Size: 264 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -46,20 +46,6 @@ 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'); }
|
||||
|
||||
|
|
@ -84,10 +70,7 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
|||
|
||||
this.humanGridGfx = null;
|
||||
this.aiGridGfx = null;
|
||||
this._pulseGfx = null;
|
||||
this._ghostAnimContainer = null;
|
||||
this._ghostAnim = null;
|
||||
this.traySlots = []; // [{ container, pieceId }]
|
||||
this.traySlots = []; // [{ container, pieceId }]
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
|
|
@ -127,43 +110,6 @@ 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() {
|
||||
|
|
@ -205,7 +151,6 @@ 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() {
|
||||
|
|
@ -399,7 +344,6 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
|||
}
|
||||
this.selectedPiece = { pieceId, oriIdx: 0 };
|
||||
this.ghostCells = null;
|
||||
playSound(this, SFX.UI_PICK);
|
||||
this._renderTray();
|
||||
this._renderHumanGrid();
|
||||
}
|
||||
|
|
@ -415,137 +359,22 @@ export default class GeniusSquareGame extends Phaser.Scene {
|
|||
|
||||
_rotate() {
|
||||
if (!this.selectedPiece || this.gameOver) 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._renderHumanGrid();
|
||||
return;
|
||||
}
|
||||
this._startGhostAnim('rotate');
|
||||
this.selectedPiece.oriIdx = rotateOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
|
||||
this._updateGhostAt(this._lastHoverR, this._lastHoverC);
|
||||
this._renderHumanGrid();
|
||||
}
|
||||
|
||||
_flip() {
|
||||
if (!this.selectedPiece || this.gameOver) return;
|
||||
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.selectedPiece.oriIdx = flipOri(this.selectedPiece.pieceId, this.selectedPiece.oriIdx);
|
||||
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];
|
||||
const [pinDr, pinDc] = _centerPin(oriCells);
|
||||
this.ghostCells = absoluteCells(oriCells, r - pinDr, c - pinDc);
|
||||
this.ghostCells = absoluteCells(oriCells, r, c);
|
||||
}
|
||||
|
||||
_onGridHover(ptr) {
|
||||
|
|
@ -554,7 +383,6 @@ 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();
|
||||
}
|
||||
|
|
@ -570,7 +398,6 @@ 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;
|
||||
}
|
||||
|
|
@ -584,7 +411,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.UI_PLACE);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this._clearSelection();
|
||||
this._renderHumanGrid();
|
||||
this._renderTray();
|
||||
|
|
@ -608,9 +435,8 @@ 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);
|
||||
const [pinDr, pinDc] = _centerPin(oriCells);
|
||||
this._lastHoverR = anchorR + pinDr;
|
||||
this._lastHoverC = anchorC + pinDc;
|
||||
this._lastHoverR = anchorR;
|
||||
this._lastHoverC = anchorC;
|
||||
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this._renderHumanGrid();
|
||||
|
|
@ -715,7 +541,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,882 +0,0 @@
|
|||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
// 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,7 +81,6 @@ 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,
|
||||
|
|
@ -176,7 +175,6 @@ 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', katamino: 'KataminoGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ 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');
|
||||
|
|
@ -125,11 +124,6 @@ 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,11 +48,6 @@ 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,4 +97,3 @@ 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 });
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
// 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