fertig-classic-games/tools/genBloxorz.js

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}`);