308 lines
13 KiB
JavaScript
308 lines
13 KiB
JavaScript
// 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}`);
|