Added new game: Shift

This commit is contained in:
Brian Fertig 2026-06-11 09:05:57 -06:00
parent d36b330a87
commit c5971d8eb1
12 changed files with 662 additions and 6 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,22 @@
{
"artwork": [
{
"id": "classic-numbered",
"name": "Numbered Tiles",
"key": null,
"path": null
},
{
"id": "exploring-the-alien-world",
"name": "Exploring the Alien World",
"key": "exploring-the-alien-world",
"path": "/assets/images/shift/alien-world.png"
},
{
"id": "dolphin-underwater",
"name": "Underwater Dolphin Adventure",
"key": "dolphin-underwater",
"path": "/assets/images/shift/dolphin-underwater.png"
}
]
}

View File

@ -0,0 +1,550 @@
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 { api } from '../../services/api.js';
import {
DIFFICULTIES, DIFFICULTY_ORDER, shuffleBoard, isSolved,
getEmptyIndex, getLegalMoves, applyMove,
} from './ShiftLogic.js';
const BG = 0x0c1214;
const BOARD_BG = 0x16212a;
const D = { bg: -2, board: 0, tile: 2, tileLabel: 3, ui: 30, overlay: 60, overlayUI: 62 };
const TILE_GAP = 6;
// Palette for numbered-tile fallback (easy to distinguish at a glance).
const FALLBACK_PALETTE = [
0x2e6b8a, 0x3a7d44, 0x8a5c2e, 0x6b3a7d,
0x7d3a3a, 0x2e7d6b, 0x5c7d2e, 0x2e3a7d,
0x7d6b2e, 0x3a6b7d, 0x6b7d3a, 0x7d2e5c,
0x2e5c7d, 0x7d3a5c, 0x4a6b2e, 0x6b4a2e,
0x2e4a6b, 0x6b2e4a, 0x4a2e6b, 0x2e6b4a,
0x6b4a3a, 0x3a4a6b, 0x4a6b3a, 0x6b3a4a,
];
export default class ShiftGame extends Phaser.Scene {
constructor() { super('ShiftGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'shift', name: 'Shift' };
this.view = 'difficulty';
this.difficulty = null;
this.artworkId = null;
this.artDef = null;
this.tiles = [];
this.moves = 0;
this.overlayUp = false;
this.busy = false;
this.tileObjs = []; // tileObjs[v] = Phaser Image or Container for tile value v
this.movesText = null;
this.bestText = null;
this.boardLeft = 0;
this.boardTop = 0;
this.tileSpacing = 0;
this.tileDisplay = 0;
this.boardSize = 0;
this.cols = 0;
this.rows = 0;
}
create() {
this.artworkList = this.cache.json.get('shift-artwork')?.artwork ?? [];
// Register a center-square-crop frame ("<key>-sq") for every loaded artwork.
// This single frame is reused for thumbnails at any scale without distortion.
for (const art of this.artworkList) {
if (art.key && this.textures.exists(art.key)) {
const base = this.textures.getFrame(art.key);
const sq = Math.min(base.realWidth, base.realHeight);
const offX = Math.floor((base.realWidth - sq) / 2);
const offY = Math.floor((base.realHeight - sq) / 2);
this.textures.get(art.key).add(`${art.key}-sq`, 0, offX, offY, sq, sq);
}
}
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) {}
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(D.bg);
this.layer = this.add.container(0, 0);
this.showDifficultySelect();
}
clearLayer() {
this.layer.removeAll(true);
this.tileObjs = [];
this.movesText = null;
this.bestText = null;
}
// ── Difficulty select ─────────────────────────────────────────────────────────
showDifficultySelect() {
this.view = 'difficulty';
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 160, 'SHIFT', {
fontFamily: 'Righteous', fontSize: '96px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 268, 'Slide tiles into the empty space to restore the image.', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const pick = this.add.text(cx, 356, 'Choose a difficulty', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add([title, sub, pick]);
const TILE_W = 430;
const TILE_H = 400;
const GAP = 60;
const PREV_SZ = 180; // artwork preview square size (px)
const accents = { easy: 0x45d17a, medium: 0x5b9bff, hard: 0xff5252 };
const infos = {
easy: '3 × 3 · 8 tiles',
medium: '4 × 4 · 15 tiles',
hard: '5 × 5 · 24 tiles',
};
// First artwork with a loaded texture — used for every card's preview.
const previewArt = this.artworkList.find(a => a.key && this.textures.exists(a.key)) ?? null;
const totalW = DIFFICULTY_ORDER.length * TILE_W + (DIFFICULTY_ORDER.length - 1) * GAP;
const left = cx - totalW / 2 + TILE_W / 2;
const y = 630;
DIFFICULTY_ORDER.forEach((key, i) => {
const def = DIFFICULTIES[key];
const x = left + i * (TILE_W + GAP);
const stroke = accents[key];
const card = this.add.rectangle(x, y, TILE_W, TILE_H, 0x182430)
.setStrokeStyle(3, stroke, 1);
this.layer.add(card);
// ── Artwork preview with per-difficulty grid overlay ──────────────────
const prevY = y - 90;
if (previewArt) {
const thumb = this.add.image(x, prevY, previewArt.key, `${previewArt.key}-sq`)
.setDisplaySize(PREV_SZ, PREV_SZ);
this.layer.add(thumb);
// Grid lines sized for this difficulty
const gfx = this.add.graphics();
gfx.lineStyle(1, 0xffffff, 0.45);
const px0 = x - PREV_SZ / 2, py0 = prevY - PREV_SZ / 2;
for (let c = 1; c < def.cols; c++) {
const lx = px0 + c * PREV_SZ / def.cols;
gfx.lineBetween(lx, py0, lx, py0 + PREV_SZ);
}
for (let r = 1; r < def.rows; r++) {
const ly = py0 + r * PREV_SZ / def.rows;
gfx.lineBetween(px0, ly, px0 + PREV_SZ, ly);
}
gfx.strokeRect(px0, py0, PREV_SZ, PREV_SZ);
this.layer.add(gfx);
} else {
// No artwork loaded — draw a grid placeholder
const gfx = this.add.graphics();
gfx.fillStyle(0x223344, 1);
gfx.fillRect(x - PREV_SZ / 2, prevY - PREV_SZ / 2, PREV_SZ, PREV_SZ);
gfx.lineStyle(1, 0x5b9bff, 0.4);
const px0 = x - PREV_SZ / 2, py0 = prevY - PREV_SZ / 2;
for (let c = 1; c < def.cols; c++) {
const lx = px0 + c * PREV_SZ / def.cols;
gfx.lineBetween(lx, py0, lx, py0 + PREV_SZ);
}
for (let r = 1; r < def.rows; r++) {
const ly = py0 + r * PREV_SZ / def.rows;
gfx.lineBetween(px0, ly, px0 + PREV_SZ, ly);
}
gfx.strokeRect(px0, py0, PREV_SZ, PREV_SZ);
this.layer.add(gfx);
}
// ── Text ──────────────────────────────────────────────────────────────
const name = this.add.text(x, y + 34, def.label, {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5);
const info = this.add.text(x, y + 100, infos[key], {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const best = this._bestForDifficulty(key);
const bestLabel = this.add.text(x, y + 148, best !== null ? `Best: ${best} moves` : 'Not played yet', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add([name, info, bestLabel]);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => card.setStrokeStyle(5, stroke, 1));
card.on('pointerout', () => card.setStrokeStyle(3, stroke, 1));
card.on('pointerup', () => this.onDifficultyPicked(key));
});
const back = new Button(this, cx, GAME_HEIGHT - 100, 'Back',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 240, height: 60, fontSize: 24 });
this.layer.add(back);
}
_bestForDifficulty(difficulty) {
let best = null;
for (const art of this.artworkList) {
const v = parseInt(localStorage.getItem(`shift-best-${difficulty}-${art.id}`), 10);
if (!isNaN(v) && (best === null || v < best)) best = v;
}
return best;
}
// ── Artwork select ────────────────────────────────────────────────────────────
onDifficultyPicked(difficulty) {
this.difficulty = difficulty;
if (this.artworkList.length <= 1) {
this.startGame(difficulty, this.artworkList[0]?.id ?? 'fallback');
} else {
this.showArtworkSelect(difficulty);
}
}
showArtworkSelect(difficulty) {
this.view = 'artwork';
this.overlayUp = false;
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 120, 'Choose Your Image', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add(title);
const CARD_W = 310;
const CARD_H = 360;
const GAP = 50;
const THUMB = 240;
const MAX_ROW = 5;
const perRow = Math.min(this.artworkList.length, MAX_ROW);
const totalW = perRow * CARD_W + (perRow - 1) * GAP;
const startX = cx - totalW / 2 + CARD_W / 2;
const startY = 400;
this.artworkList.forEach((art, i) => {
const row = Math.floor(i / MAX_ROW);
const col = i % MAX_ROW;
const x = startX + col * (CARD_W + GAP);
const y = startY + row * (CARD_H + 50);
const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x182430)
.setStrokeStyle(2, COLORS.accent, 0.5);
this.layer.add(card);
if (art.key && this.textures.exists(art.key)) {
const thumb = this.add.image(x, y - 40, art.key, `${art.key}-sq`)
.setDisplaySize(THUMB, THUMB);
this.layer.add(thumb);
} else {
const ph = this.add.graphics();
ph.fillStyle(0x223344, 1);
ph.fillRoundedRect(x - THUMB / 2, y - 40 - THUMB / 2, THUMB, THUMB, 8);
const icon = this.add.text(x, y - 40, '▦', {
fontFamily: 'Righteous', fontSize: '80px', color: '#5b9bff',
}).setOrigin(0.5);
this.layer.add([ph, icon]);
}
const label = this.add.text(x, y + CARD_H / 2 - 80, art.name, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
align: 'center', wordWrap: { width: CARD_W - 20 },
}).setOrigin(0.5, 0);
const lsKey = `shift-best-${difficulty}-${art.id}`;
const bestV = parseInt(localStorage.getItem(lsKey), 10);
const bestLb = this.add.text(x, y + CARD_H / 2 - 44, !isNaN(bestV) ? `Best: ${bestV}` : '', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0);
this.layer.add([label, bestLb]);
card.setInteractive({ useHandCursor: true });
card.on('pointerover', () => card.setStrokeStyle(3, COLORS.accent, 1));
card.on('pointerout', () => card.setStrokeStyle(2, COLORS.accent, 0.5));
card.on('pointerup', () => this.startGame(difficulty, art.id));
});
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back',
() => this.showDifficultySelect(),
{ variant: 'ghost', width: 240, height: 60, fontSize: 24 });
this.layer.add(back);
}
// ── Gameplay ──────────────────────────────────────────────────────────────────
startGame(difficulty, artworkId) {
this.view = 'play';
this.difficulty = difficulty;
this.artworkId = artworkId;
this.artDef = this.artworkList.find(a => a.id === artworkId) ?? null;
const { cols, rows } = DIFFICULTIES[difficulty];
this.cols = cols;
this.rows = rows;
this.tiles = shuffleBoard(cols, rows);
this.moves = 0;
this.overlayUp = false;
this.busy = false;
this.clearLayer();
this._computeLayout(cols, rows);
this._drawBoardChrome();
this._buildTiles();
this._drawHud();
}
_computeLayout(cols, rows) {
const HUD_H = 120;
const MARGIN = 80;
const maxW = GAME_WIDTH - MARGIN * 2;
const maxH = GAME_HEIGHT - HUD_H - MARGIN;
this.boardSize = Math.min(maxW, maxH);
this.boardLeft = (GAME_WIDTH - this.boardSize) / 2;
this.boardTop = HUD_H + (GAME_HEIGHT - HUD_H - this.boardSize) / 2;
this.tileSpacing = this.boardSize / cols;
this.tileDisplay = this.tileSpacing - TILE_GAP;
}
_tileScreenPos(idx) {
const col = idx % this.cols;
const row = Math.floor(idx / this.cols);
return {
x: this.boardLeft + col * this.tileSpacing + this.tileSpacing / 2,
y: this.boardTop + row * this.tileSpacing + this.tileSpacing / 2,
};
}
_drawBoardChrome() {
const gfx = this.add.graphics().setDepth(D.board);
gfx.fillStyle(BOARD_BG, 1);
gfx.fillRoundedRect(
this.boardLeft - 12, this.boardTop - 12,
this.boardSize + 24, this.boardSize + 24,
14,
);
this.layer.add(gfx);
}
_buildTiles() {
const { cols, rows } = this;
const N = cols * rows;
const hasArt = !!(this.artDef?.key && this.textures.exists(this.artDef.key));
// Register per-tile frames on the texture so setDisplaySize scales each tile
// correctly (it scales relative to the frame's own dimensions, not the full image).
if (hasArt) {
const baseFrame = this.textures.getFrame(this.artDef.key);
const imgW = baseFrame.realWidth;
const imgH = baseFrame.realHeight;
const square = Math.min(imgW, imgH);
const offX = Math.floor((imgW - square) / 2);
const offY = Math.floor((imgH - square) / 2);
const tw = square / cols;
const th = square / rows;
const texture = this.textures.get(this.artDef.key);
for (let v = 1; v < N; v++) {
const sc = (v - 1) % cols;
const sr = Math.floor((v - 1) / cols);
texture.add(
`shift-tile-${v}`, 0,
Math.round(offX + sc * tw), Math.round(offY + sr * th),
Math.round(tw), Math.round(th),
);
}
}
const td = this.tileDisplay;
for (let v = 1; v < N; v++) {
const pos = this.tiles.indexOf(v);
const { x, y } = this._tileScreenPos(pos);
if (hasArt) {
const img = this.add.image(x, y, this.artDef.key, `shift-tile-${v}`)
.setDisplaySize(td, td)
.setDepth(D.tile);
img.setInteractive({ useHandCursor: true });
img.on('pointerover', () => img.setTint(0xcccccc));
img.on('pointerout', () => img.clearTint());
img.on('pointerup', () => this.onTileClick(v));
this.tileObjs[v] = img;
this.layer.add(img);
} else {
const color = FALLBACK_PALETTE[(v - 1) % FALLBACK_PALETTE.length];
const container = this.add.container(x, y).setDepth(D.tile);
const rect = this.add.graphics();
rect.fillStyle(color, 1);
rect.fillRoundedRect(-td / 2, -td / 2, td, td, 10);
const fontSize = Math.round(td * (this.cols <= 3 ? 0.38 : 0.30));
const lbl = this.add.text(0, 0, String(v), {
fontFamily: 'Righteous', fontSize: `${fontSize}px`, color: '#ffffff',
}).setOrigin(0.5).setDepth(D.tileLabel);
container.add([rect, lbl]);
container.setSize(td, td);
container.setInteractive({ useHandCursor: true });
container.on('pointerover', () => container.setAlpha(0.78));
container.on('pointerout', () => container.setAlpha(1));
container.on('pointerup', () => this.onTileClick(v));
this.tileObjs[v] = container;
this.layer.add(container);
}
}
}
_drawHud() {
const cx = GAME_WIDTH / 2;
const titleT = this.add.text(44, 54, 'SHIFT', {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
const { cols, rows } = DIFFICULTIES[this.difficulty];
const diffT = this.add.text(44, 94, `${DIFFICULTIES[this.difficulty].label} · ${cols}×${rows}`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.movesText = this.add.text(cx, 54, 'Moves: 0', {
fontFamily: 'Righteous', fontSize: '42px', color: COLORS.textHex,
}).setOrigin(0.5, 0.5).setDepth(D.ui);
const lsKey = `shift-best-${this.difficulty}-${this.artworkId}`;
const bestV = parseInt(localStorage.getItem(lsKey), 10);
this.bestText = this.add.text(cx, 96, !isNaN(bestV) ? `Best: ${bestV}` : '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5, 0.5).setDepth(D.ui);
const shuffleBtn = new Button(this, GAME_WIDTH - 200, 60, 'Shuffle',
() => this._reshuffleBoard(),
{ width: 220, height: 56, fontSize: 22 }).setDepth(D.ui);
const backBtn = new Button(this, GAME_WIDTH - 440, 60, 'Back',
() => this.showDifficultySelect(),
{ variant: 'ghost', width: 200, height: 56, fontSize: 22 }).setDepth(D.ui);
this.layer.add([titleT, diffT, this.movesText, this.bestText, shuffleBtn, backBtn]);
}
_reshuffleBoard() {
if (this.busy) return;
const { cols, rows } = DIFFICULTIES[this.difficulty];
this.tiles = shuffleBoard(cols, rows);
this.moves = 0;
this.overlayUp = false;
const N = cols * rows;
for (let v = 1; v < N; v++) {
const pos = this.tiles.indexOf(v);
const { x, y } = this._tileScreenPos(pos);
this.tileObjs[v].setPosition(x, y);
}
this._updateHud();
}
_updateHud() {
if (this.movesText) this.movesText.setText(`Moves: ${this.moves}`);
}
// ── Input ─────────────────────────────────────────────────────────────────────
onTileClick(v) {
if (this.busy || this.overlayUp) return;
const tileIdx = this.tiles.indexOf(v);
const emptyIdx = getEmptyIndex(this.tiles);
if (!getLegalMoves(this.tiles, this.cols, this.rows).includes(tileIdx)) return;
this.busy = true;
const { x, y } = this._tileScreenPos(emptyIdx);
playSound(this, SFX.PIECE_CLICK);
this.tweens.add({
targets: this.tileObjs[v],
x, y,
duration: 100,
ease: 'Quad.easeOut',
onComplete: () => {
this.tiles = applyMove(this.tiles, tileIdx, emptyIdx);
this.moves++;
this._updateHud();
this.busy = false;
if (isSolved(this.tiles)) this._onWin();
},
});
}
// ── Win overlay ───────────────────────────────────────────────────────────────
_onWin() {
this.overlayUp = true;
playSound(this, SFX.VICTORY_SHORT);
const lsKey = `shift-best-${this.difficulty}-${this.artworkId}`;
const prevB = parseInt(localStorage.getItem(lsKey), 10);
const newBest = isNaN(prevB) || this.moves < prevB;
if (newBest) localStorage.setItem(lsKey, String(this.moves));
const displayBest = newBest ? this.moves : prevB;
api.post('/history/single-player', {
slug: 'shift', score: this.moves, opponentScores: [], result: 'win',
}).catch(() => {});
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.64)
.setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.97);
panel.fillRoundedRect(cx - 340, cy - 230, 680, 460, 22);
panel.lineStyle(3, 0x45d17a, 1);
panel.strokeRoundedRect(cx - 340, cy - 230, 680, 460, 22);
this.layer.add(panel);
const winTitle = this.add.text(cx, cy - 148, 'Puzzle Solved!', {
fontFamily: 'Righteous', fontSize: '72px', color: '#45d17a',
}).setOrigin(0.5).setDepth(D.overlayUI);
const movesLbl = this.add.text(cx, cy - 42, `${this.moves} moves`, {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const bestMsg = newBest && !isNaN(prevB)
? `★ New Best! (was ${prevB})`
: `Best: ${displayBest} moves`;
const bestLbl = this.add.text(cx, cy + 30, bestMsg, {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([winTitle, movesLbl, bestLbl]);
const again = new Button(this, cx - 200, cy + 140,
'Play Again', () => this.startGame(this.difficulty, this.artworkId),
{ width: 320, height: 66, fontSize: 26 }).setDepth(D.overlayUI);
const goBack = new Button(this, cx + 200, cy + 140,
this.artworkList.length > 1 ? 'Choose Image' : 'Difficulty',
() => {
if (this.artworkList.length > 1) this.showArtworkSelect(this.difficulty);
else this.showDifficultySelect();
},
{ variant: 'ghost', width: 320, height: 66, fontSize: 26 }).setDepth(D.overlayUI);
this.layer.add([again, goBack]);
}
}

View File

@ -0,0 +1,78 @@
export const DIFFICULTIES = {
easy: { key: 'easy', label: 'Easy', cols: 3, rows: 3 },
medium: { key: 'medium', label: 'Medium', cols: 4, rows: 4 },
hard: { key: 'hard', label: 'Hard', cols: 5, rows: 5 },
};
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard'];
// Tiles are a flat array of length cols*rows.
// Value 0 = empty space. Values 1..(N-1) are tile numbers.
// Solved state: tiles[i] === (i + 1) % N
// i.e. [1, 2, ..., N-1, 0] — empty at bottom-right.
export function isSolved(tiles) {
const n = tiles.length;
return tiles.every((v, i) => v === (i + 1) % n);
}
export function getEmptyIndex(tiles) {
return tiles.indexOf(0);
}
// Returns indices of tiles that may legally slide into the empty cell
// (the four orthogonal neighbours of the empty cell).
export function getLegalMoves(tiles, cols, rows) {
const emptyIdx = getEmptyIndex(tiles);
const ec = emptyIdx % cols;
const er = Math.floor(emptyIdx / cols);
const moves = [];
if (er > 0) moves.push(emptyIdx - cols);
if (er < rows - 1) moves.push(emptyIdx + cols);
if (ec > 0) moves.push(emptyIdx - 1);
if (ec < cols - 1) moves.push(emptyIdx + 1);
return moves;
}
export function applyMove(tiles, tileIdx, emptyIdx) {
const next = tiles.slice();
next[emptyIdx] = next[tileIdx];
next[tileIdx] = 0;
return next;
}
// Standard N-puzzle solvability check via inversion count.
export function isSolvable(tiles, cols, rows) {
const n = tiles.length;
let inversions = 0;
for (let i = 0; i < n - 1; i++) {
if (tiles[i] === 0) continue;
for (let j = i + 1; j < n; j++) {
if (tiles[j] !== 0 && tiles[i] > tiles[j]) inversions++;
}
}
if (cols % 2 === 1) {
return inversions % 2 === 0;
}
const emptyRow = rows - Math.floor(tiles.indexOf(0) / cols);
return (inversions + emptyRow) % 2 === 0;
}
function fisherYates(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Returns a solvable, non-solved shuffle.
export function shuffleBoard(cols, rows) {
const n = cols * rows;
const solved = Array.from({ length: n }, (_, i) => (i + 1) % n);
let shuffled;
do {
shuffled = fisherYates(solved.slice());
} while (!isSolvable(shuffled, cols, rows) || isSolved(shuffled));
return shuffled;
}

View File

@ -64,6 +64,7 @@ import FreecellGame from './games/freecell/FreecellGame.js';
import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
import ShiftGame from './games/shift/ShiftGame.js';
const config = {
type: Phaser.AUTO,
@ -141,6 +142,7 @@ const config = {
RushHourGame,
HexsweeperGame,
PuddingMonstersGame,
ShiftGame,
],
};

View File

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

View File

@ -61,6 +61,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('music', '/data/music.json');
this.load.json('rushhour', '/data/rushhour.json');
this.load.json('puddingmonsters', '/data/puddingmonsters.json');
this.load.json('shift-artwork', '/data/shift-artwork.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
@ -149,9 +150,11 @@ export default class PreloadScene extends Phaser.Scene {
const pfd = this.cache.json.get('playfields');
const cbd = this.cache.json.get('card-backs');
const shiftArt = this.cache.json.get('shift-artwork');
const toLoad = [
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
...(shiftArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)),
];
if (toLoad.length > 0) {

View File

@ -26,7 +26,7 @@ export function getGame(slug) {
// Built-in catalog so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 0 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 1 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 1 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 2 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 7, iconFrame: 3 });
registerGame({ slug: 'yatzi', name: 'Zahtzee', category: 'cards', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 4 });
@ -65,12 +65,12 @@ registerGame({ slug: 'blokus', name: 'Blokus', category: 'ta
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 42 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, hasTutorial: true, maxOpponents: 3, iconFrame: 41 });
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 });
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });
registerGame({ slug: 'farkel', name: 'Farkle', category: 'cards', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
registerGame({ slug: 'farkel', name: 'Farkle', category: 'cards', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 45 });
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });
@ -79,3 +79,4 @@ registerGame({ slug: 'freecell', name: 'Freecell', category:
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });
registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 });
registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 });