Added new game: Shift
This commit is contained in:
parent
d36b330a87
commit
c5971d8eb1
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.
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue