feat: add Rush Hour puzzle game with logic category
- Register Rush Hour game in server registry (logic category, single-player) - Add "Logic & Puzzle" tab to game menu - Integrate RushHourGame scene in client-side main.js and GameRoomScene - Load rushhour.json game data in PreloadScene - Add puzzle API routes for server-side puzzle management - Configure tab icon frame for new logic category
This commit is contained in:
parent
684f5ed7b2
commit
3d3d09a9fb
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,475 @@
|
|||
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 {
|
||||
GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve,
|
||||
} from './RushHourLogic.js';
|
||||
|
||||
const CELL = 132;
|
||||
const BOARD_PX = CELL * GRID;
|
||||
const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2);
|
||||
const BOARD_TOP = 188;
|
||||
const PAD = 10; // inset of a vehicle body within its cells
|
||||
|
||||
const FELT = 0x0e2233;
|
||||
const FRAME = 0x0a1722;
|
||||
const CELLBG = 0x14304a;
|
||||
const GRIDLN = 0x21466a;
|
||||
const TARGET_COLOR = 0xe03131;
|
||||
const CAR_COLORS = [
|
||||
0x4a90d9, 0x2ecc71, 0xf1c40f, 0x9b59b6, 0xe67e22, 0x1abc9c,
|
||||
0x3498db, 0xe84393, 0x16a085, 0xd35400, 0x27ae60, 0x8e44ad,
|
||||
0x7f8c8d, 0x2980b9, 0xf39c12, 0x6ab04c,
|
||||
];
|
||||
|
||||
const D = { felt: -2, frame: -1, grid: 0, exit: 1, vehicle: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
|
||||
|
||||
export default class RushHourGame extends Phaser.Scene {
|
||||
constructor() { super('RushHourGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' };
|
||||
this.bank = [];
|
||||
this.levelsCompleted = 0; // highest contiguous level cleared
|
||||
this.canPersist = true;
|
||||
this.view = 'select';
|
||||
|
||||
// per-level play state
|
||||
this.level = 0;
|
||||
this.startVehicles = null;
|
||||
this.vehicles = null;
|
||||
this.sprites = new Map(); // id -> Container
|
||||
this.undoStack = [];
|
||||
this.moves = 0;
|
||||
this.overlayUp = false;
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
async create() {
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
|
||||
|
||||
const raw = this.cache.json.get('rushhour');
|
||||
this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
try {
|
||||
const res = await api.get('/puzzles/rushhour/progress');
|
||||
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||||
} catch (_) {
|
||||
// Not signed in or offline — progress won't persist; play in-session.
|
||||
this.canPersist = false;
|
||||
this.levelsCompleted = 0;
|
||||
}
|
||||
|
||||
this.layer = this.add.container(0, 0);
|
||||
this.showLevelSelect();
|
||||
}
|
||||
|
||||
clearLayer() {
|
||||
this.sprites.clear();
|
||||
this.layer.removeAll(true);
|
||||
// These were just destroyed; drop the stale references so updateMoves()
|
||||
// doesn't poke a dead Button (whose scene is now undefined) before drawHud
|
||||
// rebuilds them for the next level.
|
||||
this.undoBtn = null;
|
||||
this.movesText = null;
|
||||
}
|
||||
|
||||
// ── Level select ────────────────────────────────────────────────────────────
|
||||
|
||||
showLevelSelect() {
|
||||
this.view = 'select';
|
||||
this.overlayUp = false;
|
||||
this.busy = false;
|
||||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'RUSH HOUR', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 138, 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([title, sub]);
|
||||
|
||||
if (!this.bank.length) {
|
||||
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genRushHour.js', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(msg);
|
||||
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||||
this.layer.add(back);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
|
||||
const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(prog);
|
||||
|
||||
const COLS = 12;
|
||||
const SIZE = 104;
|
||||
const GAP = 14;
|
||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const top = 268;
|
||||
|
||||
this.bank.forEach((p, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
const x = left + col * (SIZE + GAP);
|
||||
const y = top + row * (SIZE + GAP);
|
||||
const level = p.level;
|
||||
const cleared = level <= this.levelsCompleted;
|
||||
const playable = level <= nextLevel;
|
||||
|
||||
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
|
||||
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
|
||||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||||
const num = this.add.text(x, y - 8, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '34px',
|
||||
color: playable || cleared ? COLORS.textHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
const tag = this.add.text(x, y + 28, cleared ? '✓ cleared' : playable ? `par ${p.minMoves}` : 'locked', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px',
|
||||
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([tile, num, tag]);
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(cleared ? 3 : 3, stroke, 1));
|
||||
tile.on('pointerup', () => this.playLevel(level));
|
||||
}
|
||||
});
|
||||
|
||||
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
|
||||
{ width: 280, height: 58, fontSize: 24 });
|
||||
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
|
||||
this.layer.add([resume, back]);
|
||||
|
||||
if (!this.canPersist) {
|
||||
const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play a level ────────────────────────────────────────────────────────────
|
||||
|
||||
playLevel(level) {
|
||||
const puzzle = this.bank.find((p) => p.level === level);
|
||||
if (!puzzle) return;
|
||||
this.view = 'play';
|
||||
this.level = level;
|
||||
this.startVehicles = cloneVehicles(puzzle.vehicles);
|
||||
this.vehicles = cloneVehicles(puzzle.vehicles);
|
||||
this.par = puzzle.minMoves;
|
||||
this.undoStack = [];
|
||||
this.moves = 0;
|
||||
this.overlayUp = false;
|
||||
this.busy = false;
|
||||
|
||||
this.clearLayer();
|
||||
this.drawBoardChrome();
|
||||
this.drawHud();
|
||||
this.renderVehicles();
|
||||
}
|
||||
|
||||
drawBoardChrome() {
|
||||
const g = this.add.graphics().setDepth(D.frame);
|
||||
// Outer frame
|
||||
g.fillStyle(FRAME, 1);
|
||||
g.fillRoundedRect(BOARD_LEFT - 22, BOARD_TOP - 22, BOARD_PX + 44, BOARD_PX + 44, 18);
|
||||
// Playfield
|
||||
g.fillStyle(CELLBG, 1);
|
||||
g.fillRect(BOARD_LEFT, BOARD_TOP, BOARD_PX, BOARD_PX);
|
||||
this.layer.add(g);
|
||||
|
||||
const grid = this.add.graphics().setDepth(D.grid);
|
||||
grid.lineStyle(2, GRIDLN, 0.9);
|
||||
for (let i = 0; i <= GRID; i++) {
|
||||
grid.lineBetween(BOARD_LEFT + i * CELL, BOARD_TOP, BOARD_LEFT + i * CELL, BOARD_TOP + BOARD_PX);
|
||||
grid.lineBetween(BOARD_LEFT, BOARD_TOP + i * CELL, BOARD_LEFT + BOARD_PX, BOARD_TOP + i * CELL);
|
||||
}
|
||||
this.layer.add(grid);
|
||||
|
||||
// Exit gap on the right wall at EXIT_ROW
|
||||
const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL;
|
||||
const ex = this.add.graphics().setDepth(D.exit);
|
||||
ex.fillStyle(FELT, 1);
|
||||
ex.fillRect(BOARD_LEFT + BOARD_PX - 1, BOARD_TOP + EXIT_ROW * CELL + 6, 46, CELL - 12);
|
||||
ex.fillStyle(COLORS.gold, 0.9);
|
||||
ex.fillTriangle(
|
||||
BOARD_LEFT + BOARD_PX + 12, exitY - 26,
|
||||
BOARD_LEFT + BOARD_PX + 12, exitY + 26,
|
||||
BOARD_LEFT + BOARD_PX + 44, exitY,
|
||||
);
|
||||
this.layer.add(ex);
|
||||
const exitLabel = this.add.text(BOARD_LEFT + BOARD_PX + 30, exitY + 50, 'EXIT', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(exitLabel);
|
||||
}
|
||||
|
||||
drawHud() {
|
||||
const title = this.add.text(BOARD_LEFT - 22, 92, `Level ${this.level}`, {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.layer.add(title);
|
||||
|
||||
this.movesText = this.add.text(BOARD_LEFT + BOARD_PX + 22, 92, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
this.layer.add(this.movesText);
|
||||
this.updateMoves();
|
||||
|
||||
const by = GAME_HEIGHT - 70;
|
||||
const undo = new Button(this, BOARD_LEFT + 90, by, 'Undo', () => this.undo(),
|
||||
{ width: 150, height: 56, fontSize: 22 });
|
||||
const reset = new Button(this, BOARD_LEFT + 256, by, 'Reset', () => this.resetLevel(),
|
||||
{ width: 150, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
const hint = new Button(this, BOARD_LEFT + 422, by, 'Hint', () => this.showHint(),
|
||||
{ width: 150, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
const levels = new Button(this, BOARD_LEFT + BOARD_PX - 110, by, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 200, height: 56, fontSize: 22, variant: 'ghost' });
|
||||
this.undoBtn = undo;
|
||||
this.layer.add([undo, reset, hint, levels]);
|
||||
this.updateMoves();
|
||||
}
|
||||
|
||||
updateMoves() {
|
||||
if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`);
|
||||
if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0);
|
||||
}
|
||||
|
||||
// ── Vehicle rendering + dragging ──────────────────────────────────────────────
|
||||
|
||||
centerFor(v) {
|
||||
const wCells = v.orient === 'h' ? v.len : 1;
|
||||
const hCells = v.orient === 'v' ? v.len : 1;
|
||||
return {
|
||||
x: BOARD_LEFT + (v.x + wCells / 2) * CELL,
|
||||
y: BOARD_TOP + (v.y + hCells / 2) * CELL,
|
||||
};
|
||||
}
|
||||
|
||||
renderVehicles() {
|
||||
this.sprites.forEach((c) => c.destroy());
|
||||
this.sprites.clear();
|
||||
|
||||
let carIdx = 0;
|
||||
for (const v of this.vehicles) {
|
||||
const wCells = v.orient === 'h' ? v.len : 1;
|
||||
const hCells = v.orient === 'v' ? v.len : 1;
|
||||
const w = wCells * CELL - PAD * 2;
|
||||
const h = hCells * CELL - PAD * 2;
|
||||
const color = v.isTarget ? TARGET_COLOR : CAR_COLORS[carIdx++ % CAR_COLORS.length];
|
||||
|
||||
const { x, y } = this.centerFor(v);
|
||||
const c = this.add.container(x, y).setDepth(D.vehicle);
|
||||
|
||||
const body = this.add.graphics();
|
||||
body.fillStyle(color, 1);
|
||||
body.fillRoundedRect(-w / 2, -h / 2, w, h, 16);
|
||||
body.fillStyle(0xffffff, 0.14);
|
||||
body.fillRoundedRect(-w / 2 + 8, -h / 2 + 8, w - 16, Math.max(10, h * 0.22), 10);
|
||||
body.lineStyle(3, 0x000000, 0.28);
|
||||
body.strokeRoundedRect(-w / 2, -h / 2, w, h, 16);
|
||||
c.add(body);
|
||||
|
||||
if (v.isTarget) {
|
||||
c.add(this.add.text(0, 0, '★', { fontFamily: 'serif', fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setAlpha(0.9));
|
||||
}
|
||||
|
||||
// The body graphics are centered on the container origin, but setSize()
|
||||
// makes Phaser offset the input hit test by the display origin (w/2, h/2).
|
||||
// So the hit area must be specified top-left at (0,0) — NOT (-w/2,-h/2) —
|
||||
// to line up with the visible car (mirrors Battleship's draggable ships).
|
||||
c.setSize(w, h);
|
||||
c.setInteractive(new Phaser.Geom.Rectangle(0, 0, w, h), Phaser.Geom.Rectangle.Contains);
|
||||
c.input.cursor = 'grab';
|
||||
this.input.setDraggable(c);
|
||||
|
||||
c._vid = v.id;
|
||||
this.attachDrag(c, v);
|
||||
this.layer.add(c);
|
||||
this.sprites.set(v.id, c);
|
||||
}
|
||||
}
|
||||
|
||||
attachDrag(c, _v) {
|
||||
c.on('dragstart', () => {
|
||||
if (this.busy || this.overlayUp) { c._noDrag = true; return; }
|
||||
c._noDrag = false;
|
||||
const v = this.vehicles.find((q) => q.id === c._vid);
|
||||
const r = slideRange(this.vehicles, this.vehicles.indexOf(v));
|
||||
c._drag = { v, range: r, home: this.centerFor(v) };
|
||||
c.setDepth(D.vehicle + 1);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
});
|
||||
|
||||
c.on('drag', (_pointer, dragX, dragY) => {
|
||||
if (c._noDrag || !c._drag) return;
|
||||
const { v, range, home } = c._drag;
|
||||
if (v.orient === 'h') {
|
||||
const minX = BOARD_LEFT + (range.min + v.len / 2) * CELL;
|
||||
const maxX = BOARD_LEFT + (range.max + v.len / 2) * CELL;
|
||||
c.x = Phaser.Math.Clamp(dragX, minX, maxX);
|
||||
c.y = home.y;
|
||||
} else {
|
||||
const minY = BOARD_TOP + (range.min + v.len / 2) * CELL;
|
||||
const maxY = BOARD_TOP + (range.max + v.len / 2) * CELL;
|
||||
c.y = Phaser.Math.Clamp(dragY, minY, maxY);
|
||||
c.x = home.x;
|
||||
}
|
||||
});
|
||||
|
||||
c.on('dragend', () => {
|
||||
c.setDepth(D.vehicle);
|
||||
if (c._noDrag || !c._drag) return;
|
||||
const { v, range } = c._drag;
|
||||
c._drag = null;
|
||||
let target;
|
||||
if (v.orient === 'h') {
|
||||
target = Math.round((c.x - BOARD_LEFT) / CELL - v.len / 2);
|
||||
} else {
|
||||
target = Math.round((c.y - BOARD_TOP) / CELL - v.len / 2);
|
||||
}
|
||||
target = Phaser.Math.Clamp(target, range.min, range.max);
|
||||
const before = v.orient === 'h' ? v.x : v.y;
|
||||
if (target === before) {
|
||||
this.snapSprite(c, v);
|
||||
return;
|
||||
}
|
||||
this.commitMove(v, target);
|
||||
});
|
||||
}
|
||||
|
||||
snapSprite(c, v) {
|
||||
const { x, y } = this.centerFor(v);
|
||||
this.tweens.add({ targets: c, x, y, duration: 90, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
commitMove(v, target) {
|
||||
this.undoStack.push({ id: v.id, x: v.x, y: v.y });
|
||||
if (v.orient === 'h') v.x = target; else v.y = target;
|
||||
this.moves++;
|
||||
this.updateMoves();
|
||||
const c = this.sprites.get(v.id);
|
||||
this.snapSprite(c, v);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
|
||||
if (isSolved(this.vehicles)) this.driveOut(v);
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.undoStack.length || this.busy || this.overlayUp) return;
|
||||
const last = this.undoStack.pop();
|
||||
const v = this.vehicles.find((q) => q.id === last.id);
|
||||
v.x = last.x; v.y = last.y;
|
||||
this.moves++; // undo counts as a move taken
|
||||
this.updateMoves();
|
||||
this.snapSprite(this.sprites.get(v.id), v);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
}
|
||||
|
||||
resetLevel() {
|
||||
if (this.busy || this.overlayUp) return;
|
||||
this.vehicles = cloneVehicles(this.startVehicles);
|
||||
this.undoStack = [];
|
||||
this.moves = 0;
|
||||
this.updateMoves();
|
||||
this.renderVehicles();
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
}
|
||||
|
||||
showHint() {
|
||||
if (this.busy || this.overlayUp) return;
|
||||
const { path } = solve(this.vehicles);
|
||||
if (!path || !path.length) return;
|
||||
const mv = path[0];
|
||||
const c = this.sprites.get(mv.id);
|
||||
if (!c) return;
|
||||
this.tweens.add({ targets: c, scale: 1.08, duration: 220, yoyo: true, repeat: 2, ease: 'Sine.easeInOut' });
|
||||
}
|
||||
|
||||
// ── Solve flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
driveOut(target) {
|
||||
this.busy = true;
|
||||
const c = this.sprites.get(target.id);
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
this.tweens.add({
|
||||
targets: c,
|
||||
x: GAME_WIDTH + 200,
|
||||
duration: 620,
|
||||
ease: 'Back.easeIn',
|
||||
onComplete: () => this.onSolved(),
|
||||
});
|
||||
}
|
||||
|
||||
onSolved() {
|
||||
this.overlayUp = true;
|
||||
// Optimistic local progression so play continues even if the request fails.
|
||||
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
||||
|
||||
// Persist completion + record the match (best effort).
|
||||
api.post('/puzzles/rushhour/complete', { level: this.level })
|
||||
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
|
||||
.catch(() => { /* best effort */ });
|
||||
api.post('/history/single-player', {
|
||||
slug: 'rushhour', score: this.moves, opponentScores: [], result: 'win',
|
||||
}).catch(() => { /* best effort */ });
|
||||
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||||
this.layer.add(dim);
|
||||
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(COLORS.panel, 0.98);
|
||||
panel.fillRoundedRect(cx - 320, cy - 200, 640, 400, 20);
|
||||
panel.lineStyle(3, COLORS.accent, 1);
|
||||
panel.strokeRoundedRect(cx - 320, cy - 200, 640, 400, 20);
|
||||
this.layer.add(panel);
|
||||
|
||||
const beatPar = this.moves <= this.par;
|
||||
const title = this.add.text(cx, cy - 130, 'Solved!', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const stat = this.add.text(cx, cy - 50,
|
||||
`Level ${this.level} cleared in ${this.moves} moves\nPar: ${this.par}${beatPar ? ' ★ par or better!' : ''}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add([title, stat]);
|
||||
|
||||
const hasNext = this.level < this.bank.length;
|
||||
const btns = [];
|
||||
if (hasNext) {
|
||||
btns.push(new Button(this, cx, cy + 60, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
|
||||
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
|
||||
} else {
|
||||
btns.push(this.add.text(cx, cy + 50, 'You cleared every puzzle. Bravo!', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
}
|
||||
const replay = new Button(this, cx - 110, cy + 140, 'Replay', () => this.playLevel(this.level),
|
||||
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
const levels = new Button(this, cx + 120, cy + 140, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
btns.push(replay, levels);
|
||||
this.layer.add(btns);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// Rush Hour — pure board model + BFS solver. No Phaser, no DOM.
|
||||
// Shared by the client scene and the offline puzzle generator (both ESM).
|
||||
//
|
||||
// Grid is 6x6. x = column (0 left .. 5 right), y = row (0 top .. 5 bottom).
|
||||
// The red target car is horizontal in EXIT_ROW and escapes through a gap in
|
||||
// the right wall: solved when its right end reaches the rightmost column.
|
||||
//
|
||||
// A vehicle: { id, x, y, len, orient: 'h'|'v', isTarget }
|
||||
// horizontal -> occupies (x..x+len-1, y); vertical -> occupies (x, y..y+len-1)
|
||||
//
|
||||
// Move metric (matches published Rush Hour difficulty): sliding one car any
|
||||
// number of squares in a single direction counts as ONE move.
|
||||
|
||||
export const GRID = 6;
|
||||
export const EXIT_ROW = 2;
|
||||
export const TARGET_ID = 'X';
|
||||
|
||||
export function vehicleCells(v) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < v.len; i++) {
|
||||
cells.push(v.orient === 'h' ? [v.x + i, v.y] : [v.x, v.y + i]);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// 6x6 grid of vehicle id (or null) for occupancy tests.
|
||||
export function buildGrid(vehicles) {
|
||||
const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null));
|
||||
for (const v of vehicles) {
|
||||
for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function isSolved(vehicles) {
|
||||
const t = vehicles.find((v) => v.isTarget);
|
||||
return !!t && t.orient === 'h' && t.y === EXIT_ROW && t.x + t.len - 1 === GRID - 1;
|
||||
}
|
||||
|
||||
// Compact state key. Vehicle order / len / orient / fixed-axis never change,
|
||||
// so keying on each vehicle's moving coordinate is sufficient and unique.
|
||||
export function stateKey(vehicles) {
|
||||
return vehicles.map((v) => (v.orient === 'h' ? v.x : v.y)).join(',');
|
||||
}
|
||||
|
||||
// Every distinct landing position reachable in one slide. Each entry is one
|
||||
// move regardless of distance travelled.
|
||||
export function legalMoves(vehicles) {
|
||||
const grid = buildGrid(vehicles);
|
||||
const moves = [];
|
||||
vehicles.forEach((v, idx) => {
|
||||
if (v.orient === 'h') {
|
||||
for (let nx = v.x - 1; nx >= 0 && grid[v.y][nx] === null; nx--) {
|
||||
moves.push({ idx, id: v.id, x: nx, y: v.y });
|
||||
}
|
||||
for (let nx = v.x + v.len; nx < GRID && grid[v.y][nx] === null; nx++) {
|
||||
moves.push({ idx, id: v.id, x: nx - v.len + 1, y: v.y });
|
||||
}
|
||||
} else {
|
||||
for (let ny = v.y - 1; ny >= 0 && grid[ny][v.x] === null; ny--) {
|
||||
moves.push({ idx, id: v.id, x: v.x, y: ny });
|
||||
}
|
||||
for (let ny = v.y + v.len; ny < GRID && grid[ny][v.x] === null; ny++) {
|
||||
moves.push({ idx, id: v.id, x: v.x, y: ny - v.len + 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
return moves;
|
||||
}
|
||||
|
||||
// Contiguous range a vehicle can occupy along its axis, for drag snapping.
|
||||
// Horizontal -> {min,max} are x; vertical -> {min,max} are y.
|
||||
export function slideRange(vehicles, idx) {
|
||||
const grid = buildGrid(vehicles);
|
||||
const v = vehicles[idx];
|
||||
if (v.orient === 'h') {
|
||||
let min = v.x, max = v.x;
|
||||
for (let nx = v.x - 1; nx >= 0 && grid[v.y][nx] === null; nx--) min = nx;
|
||||
for (let nx = v.x + v.len; nx < GRID && grid[v.y][nx] === null; nx++) max = nx - v.len + 1;
|
||||
return { min, max };
|
||||
}
|
||||
let min = v.y, max = v.y;
|
||||
for (let ny = v.y - 1; ny >= 0 && grid[ny][v.x] === null; ny--) min = ny;
|
||||
for (let ny = v.y + v.len; ny < GRID && grid[ny][v.x] === null; ny++) max = ny - v.len + 1;
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function cloneVehicles(vehicles) {
|
||||
return vehicles.map((v) => ({ ...v }));
|
||||
}
|
||||
|
||||
// Breadth-first shortest solution. Returns { moves, path }.
|
||||
// moves: minimum number of slides to solve (0 if already solved, -1 if none)
|
||||
// path: array of { id, x, y } optimal moves (null if unsolvable)
|
||||
// The 6x6 state space is small, but packed unsolvable layouts can still have a
|
||||
// large reachable space. `maxStates` bounds the search so a single solve stays
|
||||
// cheap (used heavily by the generator); exceeding it returns moves:-1.
|
||||
export function solve(vehicles, { maxStates = 600000 } = {}) {
|
||||
const start = cloneVehicles(vehicles);
|
||||
const startKey = stateKey(start);
|
||||
if (isSolved(start)) return { moves: 0, path: [] };
|
||||
|
||||
const meta = new Map([[startKey, null]]); // key -> { parentKey, move }
|
||||
const stateByKey = new Map([[startKey, start]]);
|
||||
let frontier = [startKey];
|
||||
let depth = 0;
|
||||
|
||||
while (frontier.length) {
|
||||
depth++;
|
||||
const next = [];
|
||||
for (const key of frontier) {
|
||||
const state = stateByKey.get(key);
|
||||
for (const mv of legalMoves(state)) {
|
||||
const ns = cloneVehicles(state);
|
||||
ns[mv.idx].x = mv.x;
|
||||
ns[mv.idx].y = mv.y;
|
||||
const nk = stateKey(ns);
|
||||
if (meta.has(nk)) continue;
|
||||
meta.set(nk, { parentKey: key, move: { id: mv.id, x: mv.x, y: mv.y } });
|
||||
stateByKey.set(nk, ns);
|
||||
if (isSolved(ns)) {
|
||||
const path = [];
|
||||
let cur = nk;
|
||||
while (meta.get(cur)) { const e = meta.get(cur); path.unshift(e.move); cur = e.parentKey; }
|
||||
return { moves: depth, path };
|
||||
}
|
||||
next.push(nk);
|
||||
}
|
||||
if (meta.size > maxStates) return { moves: -1, path: null };
|
||||
}
|
||||
frontier = next;
|
||||
if (depth > 100) break;
|
||||
}
|
||||
return { moves: -1, path: null };
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ import KiitosGame from './games/kiitos/KiitosGame.js';
|
|||
import MonopolyGame from './games/monopoly/MonopolyGame.js';
|
||||
import TriominoesGame from './games/triominoes/TriominoesGame.js';
|
||||
import FreecellGame from './games/freecell/FreecellGame.js';
|
||||
import RushHourGame from './games/rushhour/RushHourGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -135,6 +136,7 @@ const config = {
|
|||
MonopolyGame,
|
||||
TriominoesGame,
|
||||
FreecellGame,
|
||||
RushHourGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ const CATEGORIES = [
|
|||
{ key: 'cards', label: 'Cards & Dice' },
|
||||
{ key: 'casino', label: 'Casino' },
|
||||
{ key: 'word', label: 'Words & Numbers' },
|
||||
{ key: 'logic', label: 'Logic & Puzzle' },
|
||||
];
|
||||
|
||||
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3 };
|
||||
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4 };
|
||||
const ICON_INACTIVE = 56;
|
||||
const ICON_ACTIVE = 72;
|
||||
const ICON_OVERSHOOT = 86;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('playfields', '/data/playfields.json');
|
||||
this.load.json('card-backs', '/data/card-backs.json');
|
||||
this.load.json('music', '/data/music.json');
|
||||
this.load.json('rushhour', '/data/rushhour.json');
|
||||
|
||||
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
|
||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- Extend the games.category CHECK constraint to include 'logic'.
|
||||
-- SQLite requires table recreation to change a CHECK constraint.
|
||||
|
||||
CREATE TABLE games_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('tabletop', 'casino', 'word', 'cards', 'logic')),
|
||||
max_players INTEGER NOT NULL DEFAULT 2,
|
||||
supports_multiplayer INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
INSERT INTO games_new SELECT * FROM games;
|
||||
|
||||
DROP TABLE games;
|
||||
|
||||
ALTER TABLE games_new RENAME TO games;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
-- Per-user, per-game level progress for ordered single-player puzzle games
|
||||
-- (Rush Hour and future Logic & Puzzle titles).
|
||||
--
|
||||
-- A single integer `levels_completed` records the highest contiguous level the
|
||||
-- user has cleared. Because levels must be played in order, the next playable
|
||||
-- level is always levels_completed + 1.
|
||||
|
||||
CREATE TABLE puzzle_progress (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
levels_completed INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, slug)
|
||||
);
|
||||
|
|
@ -76,3 +76,4 @@ registerGame({ slug: 'kiitos', name: 'Kiitos', category: '
|
|||
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });
|
||||
registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 49 });
|
||||
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });
|
||||
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import profileRoutes from './profile/routes.js';
|
|||
import historyRoutes from './history/routes.js';
|
||||
import historyRecordRoutes from './history/recordRoutes.js';
|
||||
import wordRoutes from './words/wordRoutes.js';
|
||||
import puzzleRoutes from './puzzles/routes.js';
|
||||
import { listGames } from './games/registry.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -25,6 +26,7 @@ app.use('/api/profile', profileRoutes);
|
|||
app.use('/api/history', historyRoutes);
|
||||
app.use('/api/history', historyRecordRoutes);
|
||||
app.use('/api/words', wordRoutes);
|
||||
app.use('/api/puzzles', puzzleRoutes);
|
||||
|
||||
app.use(express.static(config.publicDir, { extensions: ['html'] }));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getGame } from '../games/registry.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/puzzles/:slug/progress
|
||||
// Returns the highest contiguous level the signed-in user has cleared.
|
||||
router.get('/:slug/progress', requireAuth, (req, res) => {
|
||||
const { slug } = req.params;
|
||||
if (!getGame(slug)) return res.status(400).json({ error: 'Unknown game slug.' });
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT levels_completed FROM puzzle_progress WHERE user_id = ? AND slug = ?')
|
||||
.get(req.user.id, slug);
|
||||
|
||||
res.json({ levelsCompleted: row?.levels_completed ?? 0 });
|
||||
});
|
||||
|
||||
// POST /api/puzzles/:slug/complete body: { level }
|
||||
// Records completion of `level`. Levels must be cleared in order, so this only
|
||||
// advances progress when level === current + 1; replaying an earlier level is a
|
||||
// no-op that still returns the current high-water mark.
|
||||
router.post('/:slug/complete', requireAuth, (req, res) => {
|
||||
const { slug } = req.params;
|
||||
const { level } = req.body ?? {};
|
||||
|
||||
if (!getGame(slug)) return res.status(400).json({ error: 'Unknown game slug.' });
|
||||
if (!Number.isInteger(level) || level < 1) {
|
||||
return res.status(400).json({ error: 'Invalid level.' });
|
||||
}
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT levels_completed FROM puzzle_progress WHERE user_id = ? AND slug = ?')
|
||||
.get(req.user.id, slug);
|
||||
const current = row?.levels_completed ?? 0;
|
||||
|
||||
if (level !== current + 1) {
|
||||
return res.json({ levelsCompleted: current });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO puzzle_progress (user_id, slug, levels_completed, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, slug)
|
||||
DO UPDATE SET levels_completed = excluded.levels_completed,
|
||||
updated_at = excluded.updated_at`,
|
||||
).run(req.user.id, slug, level);
|
||||
|
||||
res.json({ levelsCompleted: level });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
// Offline generator for Rush Hour puzzles.
|
||||
//
|
||||
// Random-fills a 6x6 board with a red target car plus cars (len 2) and trucks
|
||||
// (len 3), runs the BFS solver to (a) reject unsolvable/trivial layouts and
|
||||
// (b) label each survivor with its minimum-moves difficulty, then selects a
|
||||
// smooth easy->expert curve and writes ordered levels to public/data/rushhour.json.
|
||||
//
|
||||
// Usage:
|
||||
// node server/scripts/genRushHour.js [seed] [outFile]
|
||||
//
|
||||
// Deterministic: same seed -> same bank. Re-run after changing the curve.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
GRID, EXIT_ROW, TARGET_ID, isSolved, solve, vehicleCells,
|
||||
} from '../../public/src/games/rushhour/RushHourLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_FILE = process.argv[3]
|
||||
? path.resolve(process.argv[3])
|
||||
: path.join(__dirname, '../../public/data/rushhour.json');
|
||||
|
||||
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x9e3779b9;
|
||||
|
||||
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||
function makeRng(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const rng = makeRng(SEED);
|
||||
const randInt = (n) => Math.floor(rng() * n);
|
||||
const pick = (arr) => arr[randInt(arr.length)];
|
||||
|
||||
// Difficulty curve: count + [min,max] minMoves per tier. Sums to the bank size.
|
||||
const TIERS = [
|
||||
{ count: 8, min: 2, max: 4 },
|
||||
{ count: 10, min: 5, max: 7 },
|
||||
{ count: 10, min: 8, max: 11 },
|
||||
{ count: 8, min: 12, max: 15 },
|
||||
{ count: 7, min: 16, max: 20 },
|
||||
{ count: 5, min: 21, max: 40 },
|
||||
];
|
||||
const MAX_ATTEMPTS = 8000000;
|
||||
const MAX_SECONDS = 220; // wall-clock budget
|
||||
const SOLVE_MAX_STATES = 12000; // bound per-solve cost (rejects pathological layouts fast)
|
||||
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop';
|
||||
|
||||
// Canonical, label-independent key for dedup.
|
||||
function canonKey(vehicles) {
|
||||
return vehicles
|
||||
.map((v) => `${v.x},${v.y},${v.len},${v.orient},${v.isTarget ? 1 : 0}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function fits(grid, v) {
|
||||
for (const [x, y] of vehicleCells(v)) {
|
||||
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
|
||||
if (grid[y][x] !== null) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function stamp(grid, v) {
|
||||
for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id;
|
||||
}
|
||||
|
||||
// Build one random candidate layout (target + blockers), or null if it failed
|
||||
// to place the target.
|
||||
function randomLayout() {
|
||||
const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null));
|
||||
const vehicles = [];
|
||||
|
||||
// Target car: horizontal, length 2, starting far left so its escape path is
|
||||
// long and likely blocked -> richer puzzles.
|
||||
const target = { id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true };
|
||||
stamp(grid, target);
|
||||
vehicles.push(target);
|
||||
|
||||
const count = 6 + randInt(5); // 6..10 blockers
|
||||
let letterIdx = 0;
|
||||
let tries = 0;
|
||||
while (vehicles.length < count + 1 && tries < 200) {
|
||||
tries++;
|
||||
// Favour vertical cars: they cross the exit row and create real blockades.
|
||||
const orient = rng() < 0.6 ? 'v' : 'h';
|
||||
const len = rng() < 0.3 ? 3 : 2;
|
||||
const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID);
|
||||
const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1);
|
||||
const v = { id: LETTERS[letterIdx], x, y, len, orient, isTarget: false };
|
||||
if (!fits(grid, v)) continue;
|
||||
stamp(grid, v);
|
||||
vehicles.push(v);
|
||||
letterIdx++;
|
||||
}
|
||||
return vehicles;
|
||||
}
|
||||
|
||||
// ── Generate pool ────────────────────────────────────────────────────────────
|
||||
console.log(`[rushhour] generating with seed ${SEED}…`);
|
||||
const target = TIERS.reduce((t, x) => t + x.count, 0);
|
||||
const buckets = TIERS.map(() => []);
|
||||
const seen = new Set();
|
||||
let attempts = 0;
|
||||
let solved = 0;
|
||||
|
||||
function tierIndexFor(moves) {
|
||||
for (let i = 0; i < TIERS.length; i++) {
|
||||
if (moves >= TIERS[i].min && moves <= TIERS[i].max) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
const tierFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (attempts < MAX_ATTEMPTS && !tierFull()) {
|
||||
attempts++;
|
||||
if ((attempts & 0x3ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) {
|
||||
console.log('\n[rushhour] time budget reached, stopping early');
|
||||
break;
|
||||
}
|
||||
const vehicles = randomLayout();
|
||||
if (isSolved(vehicles)) continue; // already at exit -> trivial
|
||||
const key = canonKey(vehicles);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const { moves } = solve(vehicles, { maxStates: SOLVE_MAX_STATES });
|
||||
if (moves < 2) continue; // unsolvable or 1-move (no blockers)
|
||||
solved++;
|
||||
const ti = tierIndexFor(moves);
|
||||
if (ti === -1) continue;
|
||||
if (buckets[ti].length >= TIERS[ti].count) continue;
|
||||
buckets[ti].push({ minMoves: moves, vehicles });
|
||||
|
||||
if (solved % 2000 === 0) {
|
||||
process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// ── Assemble ordered levels ──────────────────────────────────────────────────
|
||||
const chosen = [];
|
||||
buckets.forEach((b) => {
|
||||
b.sort((p, q) => p.minMoves - q.minMoves);
|
||||
chosen.push(...b);
|
||||
});
|
||||
// Overall ascending so levels ramp smoothly even across tier boundaries.
|
||||
chosen.sort((p, q) => p.minMoves - q.minMoves);
|
||||
|
||||
const puzzles = chosen.map((p, i) => ({
|
||||
level: i + 1,
|
||||
minMoves: p.minMoves,
|
||||
vehicles: p.vehicles,
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
seed: SEED,
|
||||
count: puzzles.length,
|
||||
puzzles,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||
|
||||
const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' ');
|
||||
console.log(`[rushhour] attempts=${attempts} solvable=${solved}`);
|
||||
console.log(`[rushhour] tiers filled: ${perTier}`);
|
||||
console.log(`[rushhour] wrote ${puzzles.length} levels (minMoves ${puzzles[0]?.minMoves}..${puzzles[puzzles.length - 1]?.minMoves}) -> ${OUT_FILE}`);
|
||||
Loading…
Reference in New Issue