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:
parent
4e56c3bfae
commit
929d27e188
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 |
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue