diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 897b0bc..e288b62 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/shift/alien-world.png b/public/assets/images/shift/alien-world.png new file mode 100644 index 0000000..c48bcfe Binary files /dev/null and b/public/assets/images/shift/alien-world.png differ diff --git a/public/assets/images/shift/dolphin-underwater.png b/public/assets/images/shift/dolphin-underwater.png new file mode 100644 index 0000000..641ee18 Binary files /dev/null and b/public/assets/images/shift/dolphin-underwater.png differ diff --git a/public/assets/tutorial-videos/forbiddenisland.mp4 b/public/assets/tutorial-videos/forbiddenisland.mp4 new file mode 100644 index 0000000..a908803 Binary files /dev/null and b/public/assets/tutorial-videos/forbiddenisland.mp4 differ diff --git a/public/assets/tutorial-videos/splendor.mp4 b/public/assets/tutorial-videos/splendor.mp4 new file mode 100644 index 0000000..e9f3f3c Binary files /dev/null and b/public/assets/tutorial-videos/splendor.mp4 differ diff --git a/public/data/shift-artwork.json b/public/data/shift-artwork.json new file mode 100644 index 0000000..a81a234 --- /dev/null +++ b/public/data/shift-artwork.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/public/src/games/shift/ShiftGame.js b/public/src/games/shift/ShiftGame.js new file mode 100644 index 0000000..e5aa581 --- /dev/null +++ b/public/src/games/shift/ShiftGame.js @@ -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 ("-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]); + } +} diff --git a/public/src/games/shift/ShiftLogic.js b/public/src/games/shift/ShiftLogic.js new file mode 100644 index 0000000..a318f90 --- /dev/null +++ b/public/src/games/shift/ShiftLogic.js @@ -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; +} diff --git a/public/src/main.js b/public/src/main.js index 13e71bb..6f0ea31 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 2946e01..a81541c 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 60be028..44d8210 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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) { diff --git a/server/games/registry.js b/server/games/registry.js index 8b71c77..4083249 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -25,8 +25,8 @@ 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: '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, 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 });