feat: add Bloxorz game, balance Total Annihilation weapons

- Add Bloxorz as a new logic game with tutorial support
- Register Bloxorz in games registry, main scene, and game room dispatch
- Preload bloxorz.json artwork data in PreloadScene
- Fix arm-structures artwork path and frame dimensions for Total Annihilation
- Balance Total Annihilation weapon stats:
  - Increase tankgun damage (95→145)
  - Decrease rocketpod damage (130→80)
  - Decrease towerlaser damage but increase fire rate (62→52, 1.4→0.4s reload)
  - Decrease towermissile damage but increase fire rate (175→135, 4.5→1.5s reload)
This commit is contained in:
Brian Fertig 2026-07-25 10:30:30 -06:00
parent 4e56c3bfae
commit 929d27e188
18 changed files with 7604 additions and 9 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

6002
data/bloxorz.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,8 +35,8 @@
"frameWidth": 64, "frameHeight": 64, "cols": 8
},
"arm-structures": {
"key": "ta-arm-structures", "path": null, "kind": "structure",
"frameWidth": 128, "frameHeight": 128, "cols": 6
"key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure",
"frameWidth": 192, "frameHeight": 192, "cols": 6
},
"core-units": {
"key": "ta-core-units", "path": null, "kind": "unit",

View File

@ -277,7 +277,7 @@
"id": "tankgun",
"name": "120mm Cannon",
"kind": "ballistic",
"damage": 95,
"damage": 145,
"reload": 2.0,
"burst": 1,
"range": 350,
@ -308,7 +308,7 @@
"id": "rocketpod",
"name": "Rocket Pod",
"kind": "guided",
"damage": 130,
"damage": 80,
"reload": 4.0,
"burst": 2,
"burstDelay": 0.25,
@ -395,8 +395,8 @@
"id": "towerlaser",
"name": "Heavy Laser",
"kind": "hitscan",
"damage": 62,
"reload": 1.4,
"damage": 52,
"reload": 0.4,
"burst": 1,
"range": 340,
"spread": 0.01,
@ -422,8 +422,8 @@
"id": "towermissile",
"name": "Guided Missile",
"kind": "guided",
"damage": 175,
"reload": 4.5,
"damage": 135,
"reload": 1.5,
"burst": 1,
"range": 620,
"minRange": 180,

View File

@ -116,3 +116,4 @@ registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-
registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 });
registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 });
registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 });
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });

View File

@ -0,0 +1,627 @@
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 {
loadLevel, newState, cloneState, applyMove, solve, tileRenderInfo,
} from './BloxorzLogic.js';
// ── Isometric projection ──────────────────────────────────────────────────────
// 2:1 diamond projection. Board size varies a lot across the 36 levels (a 6x5
// intro vs. a 22x5 capstone), so everything scales per-level to fit the same
// screen area rather than using fixed pixel constants.
const TW = 96;
const TH = 48;
const TILE_DEPTH = 26;
const BLOCK_HEIGHT = 84;
const SPLIT_HEIGHT = 44;
const VOID_BG = 0x05070a;
const FLOOR_TOP = 0xd8b98a;
const FLOOR_CLIFF_S = 0x8a6a45;
const FLOOR_CLIFF_E = 0xa9835a;
const BRIDGE_OPEN_TOP = 0xd8b98a;
const BRIDGE_CLOSED_TOP = 0x3a3f47;
const FRAGILE_TOP = 0xd97b3f;
const WALL_TOP = 0x5b6472;
const WALL_CLIFF_S = 0x363b44;
const WALL_CLIFF_E = 0x454c57;
const GOAL_TOP = 0x0a0c10;
const BLOCK_TOP = 0x4a90d9;
const BLOCK_SIDE_S = 0x2f5f8f;
const BLOCK_SIDE_E = 0x3a74ad;
const D = { board: 0, block: 10, ui: 30, overlay: 60, overlayUI: 62 };
const DIR_KEYS = {
ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down',
KeyA: 'left', KeyD: 'right', KeyW: 'up', KeyS: 'down',
};
function shapeFromBlock(block) {
if (block.mode === 'split') {
return [
{ gx0: block.a.x, gy0: block.a.y, gx1: block.a.x + 1, gy1: block.a.y + 1, height: SPLIT_HEIGHT },
{ gx0: block.b.x, gy0: block.b.y, gx1: block.b.x + 1, gy1: block.b.y + 1, height: SPLIT_HEIGHT },
];
}
if (block.orient === 'up') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 1, height: BLOCK_HEIGHT }];
if (block.orient === 'x') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 2, gy1: block.y + 1, height: BLOCK_HEIGHT }];
return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 2, height: BLOCK_HEIGHT }];
}
function lerpPart(a, b, t) {
return {
gx0: a.gx0 + (b.gx0 - a.gx0) * t,
gy0: a.gy0 + (b.gy0 - a.gy0) * t,
gx1: a.gx1 + (b.gx1 - a.gx1) * t,
gy1: a.gy1 + (b.gy1 - a.gy1) * t,
height: a.height + (b.height - a.height) * t,
};
}
export default class BloxorzGame extends Phaser.Scene {
constructor() { super('BloxorzGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'bloxorz', name: 'Bloxorz' };
this.bank = [];
this.levelsCompleted = 0;
this.canPersist = true;
this.view = 'select';
this.level = 0;
this.def = null;
this.compiled = null;
this.state = null;
this.undoStack = [];
this.moves = 0;
this.par = 0;
this.busy = false;
this.overlayUp = false;
this.scaleF = 1;
this.originX = 0;
this.originY = 0;
}
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, VOID_BG).setDepth(-10);
const raw = this.cache.json.get('bloxorz');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
try {
const res = await api.get('/puzzles/bloxorz/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) {
this.canPersist = false;
this.levelsCompleted = 0;
}
this.layer = this.add.container(0, 0);
this._registerInput();
this.showLevelSelect();
}
clearLayer() {
this.layer.removeAll(true);
this.tileGfx = null;
this.blockGfx = null;
this.movesText = null;
this.undoBtn = null;
}
// ── Input ────────────────────────────────────────────────────────────────────
_registerInput() {
this.input.keyboard.on('keydown', (e) => {
if (this.view !== 'play') return;
const dir = DIR_KEYS[e.code];
if (dir) { e.preventDefault?.(); this.tryMove(dir); }
});
let touchStart = null;
this.input.on('pointerdown', (p) => { touchStart = { x: p.x, y: p.y }; });
this.input.on('pointerup', (p) => {
if (!touchStart || this.view !== 'play') { touchStart = null; return; }
const dx = p.x - touchStart.x;
const dy = p.y - touchStart.y;
touchStart = null;
if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) return;
const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
this.tryMove(dir);
});
}
// ── 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, 'BLOXORZ', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Roll the block and drop it standing into the hole. 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 levels found.\nRun: node tools/genBloxorz.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.par}` : '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(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 });
const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(),
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
this.layer.add([resume, back, reset]);
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);
}
}
confirmResetProgress() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5);
const msg = this.add.text(cx, cy - 14,
'This clears every level you have cleared and\nstarts you back at Level 1. This cannot be undone.', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
}).setOrigin(0.5);
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(),
{ width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex });
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 });
this.layer.add([dim, panel, title, msg, yes, no]);
}
doResetProgress() {
api.post('/puzzles/bloxorz/reset').catch(() => { /* best effort */ });
this.levelsCompleted = 0;
this.showLevelSelect();
}
// ── Play a level ─────────────────────────────────────────────────────────────
playLevel(level) {
const def = this.bank.find((p) => p.level === level);
if (!def) return;
this.view = 'play';
this.level = level;
this.def = def;
this.compiled = loadLevel(def);
this.state = newState(this.compiled);
this.par = def.par;
this.undoStack = [];
this.moves = 0;
this.busy = false;
this.overlayUp = false;
this.clearLayer();
this._computeProjection();
this.drawHud();
this.tileGfx = this.add.graphics().setDepth(D.board);
this.blockGfx = this.add.graphics().setDepth(D.block);
this.layer.add([this.tileGfx, this.blockGfx]);
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
}
_computeProjection() {
const { cols, rows } = this.compiled;
const span = cols + rows;
const boardW = span * (TW / 2);
const boardH = span * (TH / 2);
const availW = 1300;
const availH = 560;
this.scaleF = Math.min(1, availW / boardW, availH / (boardH + 220));
this.originX = GAME_WIDTH / 2 - (cols - rows) * (TW / 2) * this.scaleF / 2;
this.originY = 300;
}
isoX(x, y) { return this.originX + (x - y) * (TW / 2) * this.scaleF; }
isoY(x, y) { return this.originY + (x + y) * (TH / 2) * this.scaleF; }
footprintCorners(gx0, gy0, gx1, gy1) {
return [
[this.isoX(gx0, gy0), this.isoY(gx0, gy0)],
[this.isoX(gx1, gy0), this.isoY(gx1, gy0)],
[this.isoX(gx1, gy1), this.isoY(gx1, gy1)],
[this.isoX(gx0, gy1), this.isoY(gx0, gy1)],
];
}
// ── Rendering ────────────────────────────────────────────────────────────────
fillPoly(gfx, corners, color, alpha = 1) {
gfx.fillStyle(color, alpha);
gfx.beginPath();
gfx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < corners.length; i++) gfx.lineTo(corners[i][0], corners[i][1]);
gfx.closePath();
gfx.fillPath();
}
fillFace(gfx, cA, cB, dy, color) {
const quad = [cA, cB, [cB[0], cB[1] + dy], [cA[0], cA[1] + dy]];
this.fillPoly(gfx, quad, color, 1);
}
redrawTiles() {
this.tileGfx.clear();
const cells = [...this.compiled.tiles.keys()].map((k) => k.split(',').map(Number));
cells.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]));
for (const [x, y] of cells) this._drawTile(x, y);
}
_drawTile(x, y) {
const info = tileRenderInfo(this.compiled, this.state, x, y);
if (!info) return;
if (info.type === 'fragile' && info.broken) return; // broken = void, nothing to draw
const c = this.footprintCorners(x, y, x + 1, y + 1);
const hasSouth = this.compiled.tiles.has(`${x},${y + 1}`);
const hasEast = this.compiled.tiles.has(`${x + 1},${y}`);
const depth = TILE_DEPTH * this.scaleF;
const isWall = info.type === 'wall';
if (!hasSouth) this.fillFace(this.tileGfx, c[3], c[2], depth, isWall ? WALL_CLIFF_S : FLOOR_CLIFF_S);
if (!hasEast) this.fillFace(this.tileGfx, c[1], c[2], depth, isWall ? WALL_CLIFF_E : FLOOR_CLIFF_E);
let topColor = FLOOR_TOP;
if (info.type === 'bridge') topColor = info.open ? BRIDGE_OPEN_TOP : BRIDGE_CLOSED_TOP;
else if (info.type === 'fragile') topColor = FRAGILE_TOP;
else if (info.type === 'goal') topColor = GOAL_TOP;
else if (info.type === 'wall') topColor = WALL_TOP;
this.fillPoly(this.tileGfx, c, topColor, 1);
if (info.type === 'goal') {
this.tileGfx.lineStyle(Math.max(2, 3 * this.scaleF), COLORS.gold, 0.9);
this.tileGfx.beginPath();
this.tileGfx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i++) this.tileGfx.lineTo(c[i][0], c[i][1]);
this.tileGfx.closePath();
this.tileGfx.strokePath();
}
const mid = [(c[0][0] + c[2][0]) / 2, (c[0][1] + c[2][1]) / 2];
const r = 10 * this.scaleF;
if (info.type === 'switch') {
const isSoft = info.requireOrientation === 'lying';
this.tileGfx.fillStyle(isSoft ? 0xf2ead8 : 0xd4602a, 0.95);
if (isSoft) this.tileGfx.fillCircle(mid[0], mid[1], r);
else this.tileGfx.fillRect(mid[0] - r, mid[1] - r, r * 2, r * 2);
} else if (info.type === 'teleport') {
const hue = (hashStr(info.teleportId) % 360) / 360;
const color = Phaser.Display.Color.HSVToRGB(hue, 0.65, 0.95).color;
this.tileGfx.lineStyle(Math.max(2, 3 * this.scaleF), color, 1);
this.tileGfx.strokeCircle(mid[0], mid[1], r);
this.tileGfx.strokeCircle(mid[0], mid[1], r * 0.55);
}
if (info.split) {
this.tileGfx.lineStyle(Math.max(1, 2 * this.scaleF), 0xffffff, 0.35);
this.tileGfx.lineBetween(c[3][0], c[3][1], c[1][0], c[1][1]);
}
}
redrawBlock(parts) {
this.blockGfx.clear();
for (const part of parts) this._drawBlockPart(part);
}
_drawBlockPart({ gx0, gy0, gx1, gy1, height }) {
const h = height * this.scaleF;
const c = this.footprintCorners(gx0, gy0, gx1, gy1);
this.fillFace(this.blockGfx, c[3], c[2], -h, BLOCK_SIDE_S);
this.fillFace(this.blockGfx, c[1], c[2], -h, BLOCK_SIDE_E);
const top = c.map(([px, py]) => [px, py - h]);
this.fillPoly(this.blockGfx, top, BLOCK_TOP, 1);
}
// ── HUD ──────────────────────────────────────────────────────────────────────
drawHud() {
const title = this.add.text(120, 92, `Level ${this.level}`, {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add(title);
this.movesText = this.add.text(GAME_WIDTH - 120, 92, '', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui);
this.layer.add(this.movesText);
this.updateMoves();
const BTN_W = 150;
const BTN_H = 56;
const BTN_GAP = 12;
const BTN_X = 130;
const totalH = 4 * BTN_H + 3 * BTN_GAP;
let btnY = GAME_HEIGHT / 2 - totalH / 2;
const undo = new Button(this, BTN_X, btnY, 'Undo', () => this.undo(),
{ width: BTN_W, height: BTN_H, fontSize: 22 });
btnY += BTN_H + BTN_GAP;
const reset = new Button(this, BTN_X, btnY, 'Reset', () => this.resetLevel(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
btnY += BTN_H + BTN_GAP;
const hint = new Button(this, BTN_X, btnY, 'Hint', () => this.showHint(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
btnY += BTN_H + BTN_GAP;
const levels = new Button(this, BTN_X, btnY, 'Levels', () => this.showLevelSelect(),
{ width: BTN_W, height: BTN_H, 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);
}
// ── Moves ────────────────────────────────────────────────────────────────────
tryMove(dir) {
if (this.busy || this.overlayUp || !this.state || this.state.status !== 'playing') return;
const prevBlock = this.state.block;
const snapshot = cloneState(this.state);
const res = applyMove(this.compiled, this.state, dir);
if (!res.moved) { this._shakeBlock(); return; }
this.undoStack.push(snapshot);
this.moves++;
this.updateMoves();
this.busy = true;
playSound(this, SFX.PIECE_CLICK);
this._animateMove(prevBlock, this.state.block, () => {
this.busy = false;
this.redrawTiles();
if (this.state.status === 'dead') this._onFall();
else if (this.state.status === 'won') this.onSolved();
});
}
_animateMove(prevBlock, nextBlock, onDone) {
const prevParts = shapeFromBlock(prevBlock);
const nextParts = shapeFromBlock(nextBlock);
if (prevParts.length !== nextParts.length) {
this.redrawBlock(nextParts);
this.blockGfx.setAlpha(0.35);
this.tweens.add({ targets: this.blockGfx, alpha: 1, duration: 160, onComplete: onDone });
return;
}
const tw = { t: 0 };
this.tweens.add({
targets: tw,
t: 1,
duration: 170,
ease: 'Sine.easeInOut',
onUpdate: () => {
const parts = prevParts.map((p, i) => lerpPart(p, nextParts[i], tw.t));
this.redrawBlock(parts);
},
onComplete: onDone,
});
}
_shakeBlock() {
if (!this.blockGfx) return;
const x0 = this.blockGfx.x;
this.tweens.add({
targets: this.blockGfx, x: x0 + 10, duration: 60, yoyo: true, repeat: 1,
onComplete: () => { this.blockGfx.x = x0; },
});
}
undo() {
if (!this.undoStack.length || this.busy || this.overlayUp) return;
this.state = this.undoStack.pop();
this.moves++;
this.updateMoves();
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
playSound(this, SFX.PIECE_CLICK);
}
resetLevel() {
if (this.busy || this.overlayUp) return;
this.state = newState(this.compiled);
this.undoStack = [];
this.moves = 0;
this.updateMoves();
this.blockGfx.setAlpha(1);
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
playSound(this, SFX.CARD_SHUFFLE);
}
showHint() {
if (this.busy || this.overlayUp || !this.state) return;
const { path } = solve(this.compiled, { startState: this.state });
if (!path || !path.length) return;
const cx = GAME_WIDTH / 2;
const label = this.add.text(cx, 220, `Try: ${path[0].toUpperCase()}`, {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui).setAlpha(0);
this.layer.add(label);
this.tweens.add({
targets: label, alpha: 1, duration: 200, yoyo: true, hold: 900,
onComplete: () => label.destroy(),
});
}
// ── Fall / solve flow ─────────────────────────────────────────────────────────
_onFall() {
this.overlayUp = true;
playSound(this, SFX.GEM_BIG_DROP);
this.tweens.add({
targets: this.blockGfx, y: '+=160', alpha: 0, duration: 420, ease: 'Quad.easeIn',
onComplete: () => this._showFallOverlay(),
});
}
_showFallOverlay() {
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 - 300, cy - 170, 600, 340, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
this.layer.add(panel);
const title = this.add.text(cx, cy - 90, 'Overboard!', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 14, 'The block fell off. Try again!', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, msg]);
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),
{ width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(),
{ width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([retry, levels]);
}
onSolved() {
this.overlayUp = true;
playSound(this, SFX.VICTORY_SHORT);
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
api.post('/puzzles/bloxorz/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: 'bloxorz', 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, 'In the Hole!', {
fontFamily: 'Righteous', fontSize: '56px', 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 level. 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);
}
}
function hashStr(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}

View File

@ -0,0 +1,350 @@
// Bloxorz — pure board model + BFS solver. No Phaser, no DOM.
// Shared by the client scene and the offline level generator (both ESM).
//
// Grid is sparse: any (x,y) not present in a level's tile map is void (the
// block falls off if any of its occupied cells lands there).
//
// Block states:
// joined: { mode:'joined', x, y, orient } orient: 'up' | 'x' | 'y'
// 'up' -> standing, footprint [(x,y)]
// 'x' -> lying along X, footprint [(x,y),(x+1,y)] (x = min column)
// 'y' -> lying along Y, footprint [(x,y),(x,y+1)] (y = min row)
// split: { mode:'split', a:{x,y}, b:{x,y} } two independent 1x1 units,
// produced when a lying block lands on two tiles both flagged `split`.
// They move together (same input, independent per-tile checks) and
// automatically re-fuse into a joined lying block the instant they end up
// orthogonally adjacent after a move.
//
// Tile types: floor | wall | bridge | fragile | switch | teleport | goal.
// wall — a permanent, non-lethal obstacle: a move into it is simply
// illegal (no-op), never fatal. Used to give split cubes
// something to independently route around.
// bridge — impassable unless its linked switch(es) say otherwise:
// mode:'toggle' -> persistent flip on any contact (hard switch)
// mode:'momentary' -> open only while a LYING block currently
// covers the switch cell (soft switch)
// fragile — supports exactly one standing landing, then breaks permanently
// teleport — a standing (single-cell) landing is relocated to its pair
// goal — only a standing joined block ('up') is supported here; any
// other landing shape falls through like void
export const DIR_LIST = ['up', 'down', 'left', 'right'];
const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
const key = (x, y) => `${x},${y}`;
export function blockCells(block) {
if (block.mode === 'split') return [[block.a.x, block.a.y], [block.b.x, block.b.y]];
if (block.orient === 'up') return [[block.x, block.y]];
return block.orient === 'x'
? [[block.x, block.y], [block.x + 1, block.y]]
: [[block.x, block.y], [block.x, block.y + 1]];
}
// ── Level compilation ────────────────────────────────────────────────────────
export function loadLevel(def) {
const tiles = new Map();
const switches = [];
const switchesByBridge = new Map();
const teleportPairs = new Map();
for (const t of def.tiles) {
tiles.set(key(t.x, t.y), t);
if (t.type === 'switch') {
switches.push(t);
for (const bid of t.linkedBridgeIds) {
if (!switchesByBridge.has(bid)) switchesByBridge.set(bid, []);
switchesByBridge.get(bid).push(t);
}
}
if (t.type === 'teleport') {
if (!teleportPairs.has(t.teleportId)) teleportPairs.set(t.teleportId, []);
teleportPairs.get(t.teleportId).push({ x: t.x, y: t.y });
}
}
return {
cols: def.cols,
rows: def.rows,
tiles,
switches,
switchesByBridge,
teleportPairs,
start: def.start,
goal: def.goal,
};
}
// ── Dynamic tile lookups ─────────────────────────────────────────────────────
function isPressed(state, sw) {
const cells = blockCells(state.block);
const onIt = cells.some(([x, y]) => x === sw.x && y === sw.y);
if (!onIt) return false;
if (sw.requireOrientation === 'any') return true;
return state.block.mode === 'joined' && state.block.orient !== 'up';
}
function bridgeOpen(level, state, bridgeId) {
const sws = level.switchesByBridge.get(bridgeId) ?? [];
const momentary = sws.filter((s) => s.mode === 'momentary');
if (momentary.length) return momentary.some((s) => isPressed(state, s));
return state.toggleBridges.get(bridgeId) ?? false;
}
function tileAt(level, state, x, y) {
const t = level.tiles.get(key(x, y));
if (!t) return null;
if (t.type === 'bridge' && !bridgeOpen(level, state, t.bridgeId)) return null;
if (t.type === 'fragile' && state.broken.has(key(x, y))) return null;
return t;
}
function tileSupports(tile, mode, orient) {
if (!tile) return false;
if (tile.type === 'goal') return mode === 'joined' && orient === 'up';
return true;
}
// 'ok' (safe to land), 'blocked' (wall — illegal move, not fatal), or 'fatal'
// (void / closed bridge / broken fragile / lying-or-split onto a goal).
function cellStatus(level, state, x, y, mode, orient) {
const raw = level.tiles.get(key(x, y));
if (raw?.type === 'wall') return 'blocked';
const tile = tileAt(level, state, x, y);
return tileSupports(tile, mode, orient) ? 'ok' : 'fatal';
}
// Renderer-facing view of a tile including its current dynamic state.
export function tileRenderInfo(level, state, x, y) {
const t = level.tiles.get(key(x, y));
if (!t) return null;
if (t.type === 'bridge') return { ...t, open: bridgeOpen(level, state, t.bridgeId) };
if (t.type === 'fragile') return { ...t, broken: state.broken.has(key(x, y)) };
return t;
}
function evaluateSwitches(level, state) {
for (const sw of level.switches) {
if (sw.mode !== 'toggle') continue;
if (isPressed(state, sw)) {
for (const id of sw.linkedBridgeIds) state.toggleBridges.set(id, !state.toggleBridges.get(id));
}
}
}
function maybeTeleportPoint(level, pt) {
const tile = level.tiles.get(key(pt.x, pt.y));
if (tile?.type !== 'teleport') return pt;
const pair = level.teleportPairs.get(tile.teleportId) ?? [];
const dest = pair.find((c) => c.x !== pt.x || c.y !== pt.y);
return dest ? { x: dest.x, y: dest.y } : pt;
}
// ── Roll physics ─────────────────────────────────────────────────────────────
function rollJoined(block, dir) {
const [dx, dy] = DIRS[dir];
if (block.orient === 'up') {
return dx !== 0
? { mode: 'joined', orient: 'x', x: dx > 0 ? block.x + 1 : block.x - 2, y: block.y }
: { mode: 'joined', orient: 'y', x: block.x, y: dy > 0 ? block.y + 1 : block.y - 2 };
}
if (block.orient === 'x') {
return dx !== 0
? { mode: 'joined', orient: 'up', x: dx > 0 ? block.x + 2 : block.x - 1, y: block.y }
: { mode: 'joined', orient: 'x', x: block.x, y: block.y + dy };
}
// orient === 'y'
return dy !== 0
? { mode: 'joined', orient: 'up', x: block.x, y: dy > 0 ? block.y + 2 : block.y - 1 }
: { mode: 'joined', orient: 'y', x: block.x + dx, y: block.y };
}
function tryMerge(a, b) {
if (a.x === b.x && Math.abs(a.y - b.y) === 1) return { mode: 'joined', orient: 'y', x: a.x, y: Math.min(a.y, b.y) };
if (a.y === b.y && Math.abs(a.x - b.x) === 1) return { mode: 'joined', orient: 'x', x: Math.min(a.x, b.x), y: a.y };
return null;
}
// Shared tail after a successful (non-dead) landing: fragile breakage under a
// standing footprint, goal check (joined+up only), then switch evaluation.
function settle(level, state) {
const standingCells = state.block.mode === 'split'
? [state.block.a, state.block.b]
: (state.block.orient === 'up' ? [{ x: state.block.x, y: state.block.y }] : []);
if (state.block.mode === 'joined' && state.block.orient === 'up') {
state.block = { ...state.block, ...maybeTeleportPoint(level, { x: state.block.x, y: state.block.y }) };
standingCells[0] = { x: state.block.x, y: state.block.y };
}
for (const { x, y } of standingCells) {
const tile = level.tiles.get(key(x, y));
if (tile?.type === 'fragile') state.broken.add(key(x, y));
}
if (state.block.mode === 'joined' && state.block.orient === 'up') {
const tile = level.tiles.get(key(state.block.x, state.block.y));
if (tile?.type === 'goal') state.status = 'won';
}
evaluateSwitches(level, state);
return { moved: true, dead: false, won: state.status === 'won' };
}
function jointStep(level, state, dir) {
const candidate = rollJoined(state.block, dir);
const cells = blockCells(candidate);
for (const [x, y] of cells) {
const status = cellStatus(level, state, x, y, candidate.mode, candidate.orient);
if (status === 'blocked') return { moved: false };
if (status === 'fatal') { state.status = 'dead'; return { moved: true, dead: true }; }
}
if (candidate.orient !== 'up' && cells.every(([x, y]) => level.tiles.get(key(x, y))?.split)) {
state.block = { mode: 'split', a: { x: cells[0][0], y: cells[0][1] }, b: { x: cells[1][0], y: cells[1][1] } };
} else {
state.block = candidate;
}
return settle(level, state);
}
// A single split cube's attempt to step. A wall simply leaves it in place
// (non-lethal) — this is what lets the two units diverge around an obstacle
// over a sequence of identical-direction inputs instead of moving in lockstep.
function stepUnit(level, state, pt, dx, dy) {
const nx = pt.x + dx;
const ny = pt.y + dy;
const status = cellStatus(level, state, nx, ny, 'joined', 'up');
if (status === 'blocked') return { pt, dead: false };
if (status === 'fatal') return { pt, dead: true };
return { pt: maybeTeleportPoint(level, { x: nx, y: ny }), dead: false };
}
function splitStep(level, state, dir) {
const [dx, dy] = DIRS[dir];
const ra = stepUnit(level, state, state.block.a, dx, dy);
const rb = stepUnit(level, state, state.block.b, dx, dy);
if (ra.dead || rb.dead) { state.status = 'dead'; return { moved: true, dead: true }; }
const merged = tryMerge(ra.pt, rb.pt);
state.block = merged ?? { mode: 'split', a: ra.pt, b: rb.pt };
return settle(level, state);
}
// Mutates `state`. Returns { moved, dead?, won? }.
export function applyMove(level, state, dir) {
if (state.status !== 'playing') return { moved: false };
return state.block.mode === 'split' ? splitStep(level, state, dir) : jointStep(level, state, dir);
}
// ── State plumbing (mirrors RushHourLogic/PuddingMonstersLogic conventions) ──
export function newState(level) {
const toggleBridges = new Map();
for (const t of level.tiles.values()) {
if (t.type !== 'bridge' || toggleBridges.has(t.bridgeId)) continue;
const sws = level.switchesByBridge.get(t.bridgeId) ?? [];
if (sws.some((s) => s.mode === 'toggle')) toggleBridges.set(t.bridgeId, !!t.initiallyOpen);
}
const state = {
block: { mode: 'joined', orient: level.start.orient, x: level.start.x, y: level.start.y },
toggleBridges,
broken: new Set(),
status: 'playing',
};
evaluateSwitches(level, state);
return state;
}
export function cloneState(state) {
return {
block: state.block.mode === 'joined'
? { ...state.block }
: { mode: 'split', a: { ...state.block.a }, b: { ...state.block.b } },
toggleBridges: new Map(state.toggleBridges),
broken: new Set(state.broken),
status: state.status,
};
}
export function stateKey(state) {
const b = state.block;
let blockPart;
if (b.mode === 'joined') {
blockPart = `J${b.orient}${b.x},${b.y}`;
} else {
const units = [b.a, b.b].slice().sort((p, q) => p.x - q.x || p.y - q.y);
blockPart = `S${units[0].x},${units[0].y}|${units[1].x},${units[1].y}`;
}
const bridgePart = [...state.toggleBridges.entries()]
.sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0))
.map(([id, o]) => `${id}:${o ? 1 : 0}`)
.join(',');
const brokenPart = [...state.broken].sort().join(',');
return `${blockPart}#${bridgePart}#${brokenPart}`;
}
export function isSolved(state) {
return state.status === 'won';
}
// Every direction that doesn't kill the run, with the resulting state.
export function legalMoves(level, state) {
const moves = [];
for (const dir of DIR_LIST) {
const ns = cloneState(state);
const res = applyMove(level, ns, dir);
if (res.moved && !res.dead) moves.push({ dir, state: ns, won: res.won });
}
return moves;
}
// Breadth-first shortest solution. Returns { moves, path }.
// moves: minimum rolls to solve (0 if already solved, -1 if none found)
// path: array of direction strings (null if unsolvable within maxStates)
// `startState` lets the in-game Hint button solve from wherever the player
// currently is rather than always from the level's initial configuration
// (which is what genBloxorz.js/verifyBloxorz.js want, via the default).
export function solve(level, { maxStates = 300000, startState = null } = {}) {
const start = startState ? cloneState(startState) : newState(level);
if (isSolved(start)) return { moves: 0, path: [] };
const startKey = stateKey(start);
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 k of frontier) {
const cur = stateByKey.get(k);
for (const dir of DIR_LIST) {
const ns = cloneState(cur);
const res = applyMove(level, ns, dir);
if (!res.moved || res.dead) continue;
const nk = stateKey(ns);
if (meta.has(nk)) continue;
meta.set(nk, { parentKey: k, move: dir });
if (ns.status === 'won') {
const path = [];
let c = nk;
while (meta.get(c)) { const e = meta.get(c); path.unshift(e.move); c = e.parentKey; }
return { moves: depth, path };
}
stateByKey.set(nk, ns);
next.push(nk);
}
if (meta.size > maxStates) return { moves: -1, path: null };
}
frontier = next;
if (depth > 400) break;
}
return { moves: -1, path: null };
}

View File

@ -0,0 +1,50 @@
# Bloxorz
Roll a 1x1x2 block across a floating platform and drop it standing into the
goal hole. Fall off the edge and you're back at the start of the level.
## Rolling
- Use the **arrow keys / WASD** (or swipe) to tip the block one direction at
a time.
- Standing upright, it takes up one tile. Tip it over and it lies flat across
two tiles — tip it again along its length and it stands back up.
- The goal only accepts the block **standing upright**. Landing on it lying
down just falls through.
## Bridges & Switches
- **Bridges** are gaps in the platform that only become solid once their
linked switch is thrown.
- An **orange square switch** opens its bridge permanently the instant the
block touches it, in any orientation.
- A **pale circle switch** only holds its bridge open while the block is
lying down directly on top of it — time your crossing so you're still
covering the switch as you tip onto the bridge.
## Fragile Tiles
- Orange, cracked tiles support the block's weight **once**. Stand on one and
it's fine — but come back to that exact tile a second time and it gives
way.
## Teleporters
- Colored ring tiles come in pairs. Land on one standing upright and you're
instantly moved to its partner.
## Splitting
- Some pairs of floor tiles are marked with a diagonal hatch. Tip the block
so it lands lying flat across both of them and it **splits into two
smaller cubes**.
- Both halves move together with every key press, but each obeys the ground
under it independently — one can be stopped by an obstacle while the other
keeps going. Losing either half off the edge fails the level.
- Move them back next to each other and they **fuse** back into the full
block. You can't finish a level while still split.
## Levels
Clear a level to unlock the next. Your move count is shown against each
level's par — beat it for a gold star of bragging rights.

View File

@ -98,6 +98,7 @@ import SuperKartEditor from './games/superkart/SuperKartEditor.js';
import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js';
import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js';
import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js';
import BloxorzGame from './games/bloxorz/BloxorzGame.js';
const config = {
type: Phaser.AUTO,
@ -209,6 +210,7 @@ const config = {
AdvanceWarsGame,
TetrisAttackGame,
TotalAnnihilationGame,
BloxorzGame,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -51,6 +51,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');
this.load.json('rushhour', 'data/rushhour.json');
this.load.json('bloxorz', 'data/bloxorz.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
this.load.json('shift-artwork', 'data/shift-artwork.json');
this.load.json('slots-artwork', 'data/slots-artwork.json');

307
tools/genBloxorz.js Normal file
View File

@ -0,0 +1,307 @@
// Offline generator for the Bloxorz level bank.
//
// Levels are hand-authored (switch/bridge/teleporter/split puzzles need
// designed relational intent that random generation doesn't produce — see
// Rush Hour/Dot Link for where randomize-then-filter DOES work, and note this
// is deliberately NOT that). Each level is built from small composable tile
// helpers below, then solved with BloxorzLogic's BFS solver to compute `par`
// and hard-reject the whole build if any level turns out to be unsolvable.
//
// Usage: node tools/genBloxorz.js [outFile]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, '../data/bloxorz.json');
// ── Tile helpers ─────────────────────────────────────────────────────────────
const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra });
const splitFloor = (x, y) => floor(x, y, { split: true });
const wallTile = (x, y) => ({ x, y, type: 'wall' });
const goalTile = (x, y) => ({ x, y, type: 'goal' });
const fragile = (x, y) => ({ x, y, type: 'fragile' });
const bridge = (x, y, bridgeId, initiallyOpen = false) => ({ x, y, type: 'bridge', bridgeId, initiallyOpen });
const hardSwitch = (x, y, switchId, bridgeIds) => ({
x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'any', mode: 'toggle',
});
const softSwitch = (x, y, switchId, bridgeIds) => ({
x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'lying', mode: 'momentary',
});
const teleport = (x, y, teleportId) => ({ x, y, type: 'teleport', teleportId });
function rect(x0, y0, w, h, extra = {}) {
const out = [];
for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) out.push(floor(x, y, extra));
return out;
}
// Later entries win on coordinate collision — lets a base rect/fill be
// selectively overridden with bridges/switches/fragile/goal/etc.
function compile(tileList) {
const map = new Map();
for (const t of tileList) map.set(`${t.x},${t.y}`, t);
return [...map.values()];
}
function level(n, name, cols, rows, start, goal, tileList) {
return { level: n, name, cols, rows, start, goal, tiles: compile(tileList) };
}
// A single straight horizontal corridor at row `y`, columns 0..length. Rolling
// right from a standing start at column 0 lands STANDING on columns 0,3,6,9…
// and LYING on pairs (1,2),(4,5),(7,8)… — every level below places switches,
// bridges, fragile tiles, teleporters and split-flags using that fixed
// arithmetic, and always ends `length` on a multiple of 3 so the final
// approach lands standing exactly on the goal. `extraRows` widens the board
// so branch tiles (e.g. a switch reached by branching off the row) still fit.
function corridorLevel(n, name, { length, y = 2, rows = 5, overrides = [] }) {
const cols = length + 1;
const tiles = [];
for (let x = 0; x <= length; x++) tiles.push(floor(x, y));
tiles.push(...overrides);
const hasGoal = overrides.some((o) => o.type === 'goal');
const goalPos = hasGoal ? overrides.find((o) => o.type === 'goal') : { x: length, y };
if (!hasGoal) tiles.push(goalTile(length, y));
return level(n, name, cols, rows, { x: 0, y, orient: 'up' }, { x: goalPos.x, y: goalPos.y }, tiles);
}
// ── Levels 1-6: plain rolling ────────────────────────────────────────────────
const L1 = level(1, 'Warm Up', 6, 5, { x: 1, y: 1, orient: 'up' }, { x: 4, y: 3 }, [
...rect(0, 0, 6, 5), goalTile(4, 3),
]);
const L2 = level(2, 'Stretch Out', 7, 5, { x: 1, y: 1, orient: 'up' }, { x: 5, y: 3 }, [
...rect(0, 0, 7, 5), goalTile(5, 3),
]);
const L3 = level(3, 'Open Floor', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 }, [
...rect(0, 0, 8, 6), goalTile(6, 4),
]);
const L4 = level(4, 'The Corner', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 }, [
...rect(0, 0, 5, 7), ...rect(0, 4, 9, 3), goalTile(7, 5),
]);
const L5 = level(5, 'Crossroads', 9, 9, { x: 4, y: 1, orient: 'up' }, { x: 1, y: 4 }, [
...rect(3, 0, 3, 9), ...rect(0, 3, 9, 3), goalTile(1, 4),
]);
const L6 = level(6, 'Staircase', 10, 9, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 7 }, [
...rect(0, 0, 4, 3), ...rect(2, 1, 4, 4), ...rect(4, 3, 4, 4), ...rect(6, 5, 4, 4), goalTile(8, 7),
]);
// ── Levels 7-12: holes, ledges, static causeways ─────────────────────────────
const L7 = level(7, 'Mind the Gap', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 },
compile(rect(0, 0, 8, 6)).filter((t) => !(t.x === 4 && t.y === 2)).concat(goalTile(6, 4)));
const L8 = level(8, 'Pinwheel', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 },
compile(rect(0, 0, 9, 7))
.filter((t) => !([[4, 2], [4, 3], [4, 4], [3, 3], [5, 3]].some(([hx, hy]) => t.x === hx && t.y === hy)))
.concat(goalTile(7, 5)));
const L9 = level(9, 'Causeway', 9, 3, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 1 }, [
...rect(0, 0, 3, 3), ...rect(3, 1, 3, 1), ...rect(6, 0, 3, 3), goalTile(7, 1),
]);
const L10 = level(10, 'Bent Causeway', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [
...rect(0, 0, 3, 3), ...rect(3, 1, 3, 2), ...rect(4, 1, 2, 5), ...rect(3, 5, 5, 3), goalTile(6, 6),
]);
const L11 = level(11, 'Scattered', 10, 8, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 6 },
compile(rect(0, 0, 10, 8))
.filter((t) => !([[3, 3], [6, 2], [4, 6], [7, 5], [2, 6]].some(([hx, hy]) => t.x === hx && t.y === hy)))
.concat(goalTile(8, 6)));
const L12 = level(12, 'Tightrope', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [
...rect(0, 0, 3, 3), ...rect(1, 1, 3, 3), ...rect(2, 2, 3, 3),
...rect(3, 3, 3, 3), ...rect(4, 4, 3, 3), ...rect(5, 5, 3, 3),
goalTile(6, 6),
]);
// ── Levels 13-18: switches + bridges ─────────────────────────────────────────
const L13 = corridorLevel(13, 'Flip the Switch', {
length: 9,
overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false)],
});
const L14 = corridorLevel(14, 'Weight and See', {
length: 6,
overrides: [softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false)],
});
const L15 = corridorLevel(15, 'Double Gate', {
length: 12,
overrides: [hardSwitch(2, 2, 's1', ['b1', 'b2']), bridge(4, 2, 'b1', false), bridge(8, 2, 'b2', false)],
});
const L16 = corridorLevel(16, 'Two Keys', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
hardSwitch(7, 2, 's2', ['b2']), bridge(10, 2, 'b2', false),
],
});
const L17 = corridorLevel(17, 'Soft Landing', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false),
],
});
const L18 = corridorLevel(18, 'Three Gates', {
length: 18,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false),
hardSwitch(13, 2, 's3', ['b3']), bridge(16, 2, 'b3', false),
],
});
// ── Levels 19-24: fragile tiles ───────────────────────────────────────────────
const L19 = corridorLevel(19, 'Cracked Floor', { length: 9, overrides: [fragile(3, 2)] });
const L20 = corridorLevel(20, 'Eggshells', { length: 12, overrides: [fragile(3, 2), fragile(7, 2)] });
const L21 = corridorLevel(21, 'No Turning Back', {
length: 15,
overrides: [fragile(3, 2), fragile(6, 2), fragile(10, 2)],
});
const L22 = corridorLevel(22, 'Fragile Gate', {
length: 12,
overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(5, 2, 'b1', false), fragile(8, 2)],
});
const L23 = corridorLevel(23, 'Soft and Thin', {
length: 15,
overrides: [fragile(3, 2), softSwitch(7, 2, 's1', ['b1']), bridge(9, 2, 'b1', false), fragile(11, 2)],
});
const L24 = corridorLevel(24, 'Every Step Counts', {
length: 18,
overrides: [
fragile(3, 2), hardSwitch(6, 2, 's1', ['b1']), bridge(8, 2, 'b1', false),
fragile(11, 2), softSwitch(14, 2, 's2', ['b2']), bridge(15, 2, 'b2', false),
],
});
// ── Levels 25-30: teleporters ─────────────────────────────────────────────────
const L25 = corridorLevel(25, 'Shortcut', { length: 13, overrides: [teleport(3, 2, 't1'), teleport(10, 2, 't1')] });
const L26 = corridorLevel(26, 'Two Hops', {
length: 21,
overrides: [teleport(3, 2, 't1'), teleport(9, 2, 't1'), teleport(12, 2, 't2'), teleport(18, 2, 't2')],
});
const L27 = corridorLevel(27, 'Triple Hop', {
length: 27,
overrides: [
teleport(3, 2, 't1'), teleport(9, 2, 't1'),
teleport(12, 2, 't2'), teleport(18, 2, 't2'),
teleport(21, 2, 't3'), teleport(24, 2, 't3'),
],
});
const L28 = corridorLevel(28, 'Gate and Go', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
teleport(6, 2, 't1'), teleport(12, 2, 't1'),
],
});
const L29 = corridorLevel(29, 'Brittle Hop', {
length: 12,
overrides: [fragile(3, 2), teleport(6, 2, 't1'), teleport(9, 2, 't1')],
});
const L30 = corridorLevel(30, 'All at Once', {
length: 18,
overrides: [
softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false),
fragile(6, 2), teleport(9, 2, 't1'), teleport(15, 2, 't1'),
],
});
// ── Levels 31-36: split tiles ─────────────────────────────────────────────────
const L31 = corridorLevel(31, 'Split Decision', {
length: 10,
overrides: [splitFloor(4, 2), splitFloor(5, 2)],
});
// NOTE: a split-trigger-then-immediate-remerge costs one extra column versus
// a plain tip (the remerge lands one column further right than a same-shape
// non-split roll would have) — lengths below are chosen with that in mind.
const L32 = corridorLevel(32, 'Split and Gate', {
length: 19,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
splitFloor(7, 2), splitFloor(8, 2),
],
});
const L33 = corridorLevel(33, 'Split and Crack', {
length: 15,
overrides: [fragile(3, 2), splitFloor(6, 2), splitFloor(7, 2)],
});
const L34 = corridorLevel(34, 'Split and Hop', {
length: 16,
overrides: [splitFloor(4, 2), splitFloor(5, 2), teleport(7, 2, 't1'), teleport(13, 2, 't1')],
});
// A wide open room around the split point (not a tight corridor) so the two
// independent units have plenty of room to sidestep the wall pillars and
// reconverge — the BFS solver (not hand-traced choreography) is the actual
// proof this works.
const L35 = level(35, 'Around the Pillar', 16, 7, { x: 1, y: 3, orient: 'up' }, { x: 14, y: 3 }, [
...rect(0, 2, 5, 3), // entry platform, rows 2-4
splitFloor(5, 3), splitFloor(6, 3), // split trigger, row 3
...rect(5, 0, 7, 7), // big open room, rows 0-6, cols 5-11
wallTile(8, 2), wallTile(8, 3), wallTile(8, 4), // pillar splitting the room
...rect(12, 2, 4, 3), // exit platform, rows 2-4
goalTile(14, 3),
]);
// Open rooms throughout (not a tight single-row corridor) so switch/fragile/
// split placement doesn't depend on exact tip arithmetic — only reachability
// within each room, which the BFS solver confirms directly.
const L36 = level(36, 'Grand Finale', 22, 5, { x: 1, y: 1, orient: 'up' }, { x: 19, y: 2 }, [
...rect(0, 0, 5, 4), // room A: start + switch
hardSwitch(2, 2, 's1', ['b1']),
bridge(5, 1, 'b1', false), // gate into room B
...rect(6, 0, 5, 4), // room B: fragile + split trigger
fragile(7, 2),
splitFloor(9, 1), splitFloor(10, 1),
...rect(11, 0, 7, 5), // room C: pillar maze
wallTile(14, 1), wallTile(14, 2), wallTile(14, 3),
...rect(18, 0, 4, 4), // room D: goal
goalTile(19, 2),
]);
const LEVELS = [
L1, L2, L3, L4, L5, L6,
L7, L8, L9, L10, L11, L12,
L13, L14, L15, L16, L17, L18,
L19, L20, L21, L22, L23, L24,
L25, L26, L27, L28, L29, L30,
L31, L32, L33, L34, L35, L36,
];
// ── Solve-gate + write ────────────────────────────────────────────────────────
console.log(`[bloxorz] solving ${LEVELS.length} hand-authored levels…`);
let failed = 0;
const levels = LEVELS.map((def) => {
const compiled = loadLevel(def);
const { moves } = solve(compiled, { maxStates: 300000 });
if (moves < 0) {
console.error(` ✗ level ${def.level} "${def.name}" is UNSOLVABLE`);
failed++;
return { ...def, par: -1 };
}
console.log(` ✓ level ${def.level} "${def.name}" — par ${moves}`);
return { ...def, par: moves };
});
if (failed > 0) {
console.error(`[bloxorz] ${failed} level(s) unsolvable — refusing to write ${OUT_FILE}`);
process.exit(1);
}
const payload = {
generatedAt: new Date().toISOString(),
seed: null,
count: levels.length,
levels,
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
console.log(`[bloxorz] wrote ${levels.length} levels -> ${OUT_FILE}`);

255
tools/verifyBloxorz.js Normal file
View File

@ -0,0 +1,255 @@
// Verifier for Bloxorz (Node only — no browser).
//
// 1. Schema-lints data/bloxorz.json (bounds, bridge/switch/teleport linkage).
// 2. Re-solves every level fresh from the JSON (independent of whatever
// genBloxorz.js already asserted at generation time) and checks par.
// 3. Unit-tests the engine primitives directly against small synthetic
// levels: tip-over physics, death cases, switches, teleport, walls, and
// the split/merge mechanic.
//
// Usage: node tools/verifyBloxorz.js
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadLevel, newState, applyMove, solve,
} from '../src/games/bloxorz/BloxorzLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FILE = path.join(__dirname, '../data/bloxorz.json');
let passes = 0;
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
// ── Bank checks ──────────────────────────────────────────────────────────────
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
const levels = raw.levels ?? [];
console.log(`[verify] ${FILE}`);
console.log(`[verify] ${levels.length} levels`);
check('bank has 36 levels', levels.length === 36, `found ${levels.length}`);
let prevPar = 0;
for (const def of levels) {
const inBounds = (x, y) => x >= 0 && x < def.cols && y >= 0 && y < def.rows;
let boundsOk = inBounds(def.start.x, def.start.y) && inBounds(def.goal.x, def.goal.y);
const bridgeIds = new Set();
const switchLinks = new Map(); // bridgeId -> Set of switch modes
const teleportCounts = new Map();
let splitCount = 0;
for (const t of def.tiles) {
if (!inBounds(t.x, t.y)) boundsOk = false;
if (t.type === 'bridge') bridgeIds.add(t.bridgeId);
if (t.type === 'switch') {
for (const bid of t.linkedBridgeIds) {
if (!switchLinks.has(bid)) switchLinks.set(bid, new Set());
switchLinks.get(bid).add(t.mode);
}
}
if (t.type === 'teleport') teleportCounts.set(t.teleportId, (teleportCounts.get(t.teleportId) ?? 0) + 1);
if (t.split) splitCount++;
}
check(`L${def.level}: all tiles/start/goal in bounds`, boundsOk);
let bridgesLinked = true;
for (const bid of bridgeIds) if (!switchLinks.has(bid)) bridgesLinked = false;
check(`L${def.level}: every bridge has a linking switch`, bridgesLinked);
let noMixedModes = true;
for (const modes of switchLinks.values()) if (modes.size > 1) noMixedModes = false;
check(`L${def.level}: no bridge mixes toggle+momentary switches`, noMixedModes);
let teleportsPaired = true;
for (const count of teleportCounts.values()) if (count !== 2) teleportsPaired = false;
check(`L${def.level}: every teleporter id appears exactly twice`, teleportsPaired);
check(`L${def.level}: split-flagged tile count is even`, splitCount % 2 === 0, `count=${splitCount}`);
// Fresh solver re-run, independent of genBloxorz.js's own check at write time.
const compiled = loadLevel(def);
const { moves } = solve(compiled, { maxStates: 300000 });
check(`L${def.level} "${def.name}": solvable`, moves >= 0, `solve() returned moves=${moves}`);
check(`L${def.level}: par matches fresh solve`, moves === def.par, `par=${def.par} solve=${moves}`);
prevPar = def.par;
}
void prevPar;
// ── Engine unit tests ────────────────────────────────────────────────────────
function synthLevel(tiles, start, cols = 12, rows = 12) {
return loadLevel({ cols, rows, start, goal: { x: 0, y: 0 }, tiles });
}
const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra });
const wall = (x, y) => ({ x, y, type: 'wall' });
const goal = (x, y) => ({ x, y, type: 'goal' });
const fragileT = (x, y) => ({ x, y, type: 'fragile' });
const bridgeT = (x, y, id, open = false) => ({ x, y, type: 'bridge', bridgeId: id, initiallyOpen: open });
const hardSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' });
const softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'lying', mode: 'momentary' });
const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id });
// -- Tip-over physics: standing -> lying --
{
const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), floor(0, 1), floor(0, 2)], { x: 0, y: 0, orient: 'up' });
let s = newState(lvl);
applyMove(lvl, s, 'right');
check('standing rolls right into lying-x at [1,2]', s.block.mode === 'joined' && s.block.orient === 'x' && s.block.x === 1 && s.block.y === 0, JSON.stringify(s.block));
s = newState(lvl);
applyMove(lvl, s, 'down');
check('standing rolls down into lying-y at [1,2]', s.block.orient === 'y' && s.block.x === 0 && s.block.y === 1, JSON.stringify(s.block));
}
// -- Tip-over physics: lying -> standing (along axis) and lying -> lying (perpendicular shift) --
{
const lvl = synthLevel([
floor(1, 0), floor(2, 0), floor(3, 0), floor(4, 0), floor(2, 1), floor(3, 1),
], { x: 1, y: 0, orient: 'up' });
let s = newState(lvl);
applyMove(lvl, s, 'right'); // standing1 -> lying[2,3]
applyMove(lvl, s, 'right'); // lying[2,3] -> standing (anchor2+2=4)
check('lying-x tips right back to standing (x+2)', s.block.orient === 'up' && s.block.x === 4, JSON.stringify(s.block));
s = newState(lvl);
applyMove(lvl, s, 'right'); // standing1 -> lying[2,3]
applyMove(lvl, s, 'down'); // perpendicular shift: stays lying-x, y+1
check('lying-x shifts perpendicular (down) without changing orientation', s.block.orient === 'x' && s.block.x === 2 && s.block.y === 1, JSON.stringify(s.block));
}
// -- Falling off the edge is fatal --
{
const lvl = synthLevel([floor(0, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
const res = applyMove(lvl, s, 'right');
check('rolling off the platform edge is fatal', res.dead === true && s.status === 'dead');
}
// -- Goal only supports a standing landing --
{
const lvl = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
const res = applyMove(lvl, s, 'right'); // lands lying across [1,2]; 2 is void, 1 is goal (needs 'up')
check('a lying landing on/through the goal falls through (fatal)', res.dead === true);
const lvl2 = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), goal(3, 0)], { x: 0, y: 0, orient: 'up' });
const s2 = newState(lvl2);
applyMove(lvl2, s2, 'right'); // standing0 -> lying[1,2]
applyMove(lvl2, s2, 'right'); // lying[1,2] -> standing3 (goal)
check('a standing landing exactly on the goal wins', s2.status === 'won');
}
// -- Fragile tiles: safe once, fatal on a second (already-broken) visit --
{
const lvl = synthLevel([floor(0, 0), fragileT(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' });
const s = { block: { mode: 'joined', orient: 'up', x: 0, y: 0 }, toggleBridges: new Map(), broken: new Set(), status: 'playing' };
// Simulate having already broken (1,0) on a prior visit, then try to land on it again.
s.broken.add('1,0');
const res = applyMove(lvl, s, 'right'); // lying[1,2] overlaps the broken cell
check('landing on an already-broken fragile tile is fatal', res.dead === true);
}
{
const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0), floor(5, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2]
applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (fragile) — first visit, safe
check('first standing visit to a fragile tile survives', s.status === 'playing');
check('fragile tile is marked broken after the first standing visit', s.broken.has('3,0'));
}
// -- Hard switch: any orientation, persistent --
{
const lvl = synthLevel([
floor(0, 0), hardSw(1, 0, 's1', ['b1']), floor(2, 0), bridgeT(3, 0, 'b1', false), floor(4, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the hard switch
check('hard switch opens its bridge on contact (any orientation)', s.toggleBridges.get('b1') === true);
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, onto the now-open bridge
check('hard-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing');
}
// -- Soft (momentary) switch: open only while covered, recloses immediately --
{
const lvl = synthLevel([
floor(0, 0), floor(1, 0), softSw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(5, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the soft switch (col2)
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, crossing the bridge in the same instant
check('soft-switch bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing');
check('soft-switch bridge recloses once the block leaves', s.toggleBridges.get('b1') !== true);
}
// -- Teleport relocates a standing landing --
{
const lvl = synthLevel([
floor(0, 0), floor(1, 0), floor(2, 0), tp(3, 0, 't1'), tp(3, 3, 't1'), floor(4, 3),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2]
applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (teleport source) -> relocated to (3,3)
check('standing on a teleporter relocates to its paired tile', s.block.x === 3 && s.block.y === 3, JSON.stringify(s.block));
}
// -- Split trigger + auto-merge --
{
const lvl = synthLevel([
floor(0, 0), floor(1, 0), floor(2, 0), floor(3, 0),
floor(0, 0), { x: 4, y: 0, type: 'floor', split: true }, { x: 5, y: 0, type: 'floor', split: true },
floor(6, 0), floor(7, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2]
applyMove(lvl, s, 'right'); // lying[1,2] -> standing3
applyMove(lvl, s, 'right'); // standing3 -> candidate lying[4,5] both split-flagged -> SPLIT
check('a lying landing on two split-flagged tiles splits the block', s.block.mode === 'split', JSON.stringify(s.block));
applyMove(lvl, s, 'right'); // both units step +1, become adjacent again -> auto-merge
check('two adjacent split units auto-merge back into a joined lying block', s.block.mode === 'joined' && s.block.orient === 'x', JSON.stringify(s.block));
}
// -- Wall: illegal move, never fatal --
{
const lvl = synthLevel([floor(0, 0), wall(1, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
const res = applyMove(lvl, s, 'right');
check('rolling into a wall is a no-op, not fatal', res.moved === false && s.status === 'playing');
check('the block does not move when blocked by a wall', s.block.x === 0 && s.block.y === 0 && s.block.orient === 'up');
}
// -- Split units move independently: one blocked by a wall, the other proceeds --
{
const lvl = synthLevel([floor(5, 5), wall(6, 5), floor(6, 6), floor(7, 5)], { x: 5, y: 5, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 5, y: 5 }, b: { x: 6, y: 5 } },
toggleBridges: new Map(), broken: new Set(), status: 'playing',
};
applyMove(lvl, s, 'right');
const { a, b } = s.block;
check('a wall-blocked split unit stays put while its partner moves on', a.x === 5 && a.y === 5 && b.x === 7 && b.y === 5, JSON.stringify(s.block));
}
// -- Either split unit falling is fatal for the whole run --
{
const lvl = synthLevel([floor(0, 0), floor(1, 0)], { x: 0, y: 0, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 0 } },
toggleBridges: new Map(), broken: new Set(), status: 'playing',
};
const res = applyMove(lvl, s, 'down'); // both units roll off into the void
check('either split unit falling ends the run', res.dead === true && s.status === 'dead');
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`[verify] ${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);