fertig-classic-games/tools/genBloxorz.js

488 lines
10 KiB
JavaScript

// Offline generator for the Bloxorz level bank.
//
// Levels are hand-authored as ASCII maps (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). Every map is then run through
// BloxorzLogic's BFS solver to compute `par`, and through a set of design gates
// that hard-reject the build if a level is unsolvable, if a mechanic on the
// board turns out to be decorative, or if the difficulty curve regresses.
//
// Legend
// (space) void . floor S start (standing) G goal hole
// # wall ~ fragile : split-flagged floor
// 1 2 3 bridge tiles for groups 1-3
// A B C heavy switch — must come to rest STANDING on it (toggles 1-3)
// a b c soft switch — any contact, incl. a split cube (toggles 1-3)
// p q r hold pad — bridge open only while a LYING block covers it
// t u v teleport pads — each letter must appear exactly twice
//
// Per-level options: { open: [1] } starts those bridge groups open,
// { links: { A: [1, 2] } } wires a switch char to extra bridge groups.
//
// Usage: node tools/genBloxorz.js [outFile]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseMap, auditLevel } from './bloxorzMap.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');
const LEVELS = [];
const lvl = (n, name, art, opts) => { LEVELS.push(parseMap(n, name, art, opts)); };
// ── Act I (1-6): rolling. Shape, parity and the 1x2 footprint. ───────────────
lvl(1, 'First Roll', `
.......
.S.....
.......
.....G.
.......
`);
lvl(2, 'Mind the Gap', `
.........
.S.......
.... ...
.... ...
.........
.......G.
`);
lvl(3, 'Right Angle', `
.....
.S...
.....
..
..
.....
...G.
.....
`);
lvl(4, 'Narrow Pass', `
.... .....
.S.. .....
............
.... .....
..G..
`);
lvl(5, 'The Ring', `
.........
.S.......
... ...
... ...
... ...
.......G.
.........
`);
lvl(6, 'Zigzag', `
.....
.S...
.....
......
......
......
......
......
....G.
......
`);
// ── Act II (7-12): switches and bridges. ─────────────────────────────────────
// The heavy X switch only counts if the block comes to rest upright on it.
lvl(7, 'Heavy Switch', `
..... .....
.S... .....
.....11.G...
..... .....
..A.. .....
`);
// Soft switches trip on any contact — including in passing. Trip both and you
// have toggled the bridge shut again.
lvl(8, 'Light Touch', `
.........
.S.......
..a...a..
.........
1
1
.........
....G....
.........
`);
// A hold pad only counts while a LYING block is on it, so the bridge is open
// for exactly the one move that rolls off the pad onto it.
lvl(9, 'Hold the Pad', `
....
.S..
....
...p1.....
.....
..G..
`);
lvl(10, 'The Long Way', `
...... ...
.S.... ...
...... ...
... ...
... ...
......111...
...... ...
..A... .G.
...... ...
`);
lvl(11, 'Two Gates', `
.... ..... ....
.S.. ..B.. ....
....11.....22.G..
..A. ..... ....
`);
// Bridge 1 starts open and bridge 2 shut; the switch in the middle room swaps
// them, so the way in is also the way you give up.
lvl(12, 'Shuttle', `
.... .... ....
.S.. .A.. ....
....11......22..G...
.... .... ....
`, { open: [1], links: { A: [1, 2] } });
// ── Act III (13-18): fragile ground. Standing upright on orange puts the block
// straight through it, so these are orientation puzzles: you cross lying, which
// means shuffling sideways, and you can only change axis on solid ground. ────
lvl(13, 'Two at a Time', `
....
.S..
....
~~
~~
....
..G.
....
`);
lvl(14, 'Cracked Gate', `
..... ..~~~..
.S... ..~~~..
.....11..~~~G.
..... ..~~~..
..A.. ..~~~..
`);
lvl(15, 'Corner Ice', `
....~~~~~~
.S..~~~~~~
....~~~~~~
~~~~~~
~~~~~~
......
...G..
`);
lvl(16, 'Thin Ice', `
....~~~~....
.S..~~~~..G.
....~~~~....
`);
lvl(17, 'Collapse', `
..... .~~~~~..
.S... .~~~~~..
.....11.~~~~~G.
..... .~~~~~..
..A.. .~~~~~..
`);
lvl(18, 'Stepping Stones', `
....~~....~~....
.S..~~....~~..G.
....~~....~~....
`);
// ── Act IV (19-24): teleport pads. Only a STANDING landing warps. ────────────
lvl(19, 'Warp', `
.......
.S.....
...t...
.......
.......
...t...
.......
..G....
`);
lvl(20, 'Brittle Warp', `
......
.S....
...t..
......
~~~~~~~
~~t~~~~
~~~~~~~
~~~~G..
`);
lvl(21, 'Crossed Wires', `
...... ......
.S..t. ..u...
...... ......
...t..
......
..u...
...G..
......
`);
lvl(22, 'Round Trip', `
...... ......
.S.... ..t...
...t.. ......
....u.
......
..u...
......
...G..
......
`);
lvl(23, 'Warp Gate', `
..... ......
.S...11...t..
..... ......
..A.. ......
......
..t...
....G.
......
`);
lvl(24, 'Warp Maze', `
..... ......
.S... ..t...
.....11......
..A.. ......
..... ......
...... .....
..t... ..u..
...... .....
...u.. ..G..
...... .....
`);
// ── Act V (25-30): splitting. Two cubes, one input between them. The ground is
// narrow on purpose — an open room lets a joined block fix its parity by
// walking around, which would make the split optional. ──────────────────────
// The pair rejoins on the very next move, one cell further along than a plain
// tip would have landed: splitting is a parity shift, and this corridor's goal
// sits on the one parity a rolling block can never reach.
lvl(25, 'Break Apart', `
....
.S..
....
.
.
....::....G
`);
lvl(26, 'Split Gate', `
....
.S..
....
.
.
....::....A
1
1
.....
..G..
.....
`);
lvl(27, 'Split Warp', `
....
.S..
....
.
.
....::..t..
......
..t...
....G.
......
`);
// Here the wall catches one cube while the other rolls on, so the two travel
// the board separately and have to be lined back up to fuse.
lvl(28, 'Held Back', `
S::........
.#..##...#.
.......#...
........#..
...........
........#G.
`);
lvl(29, 'Split Ice', `
....
.S..
....
~~
~~
~~
......::....G
`);
lvl(30, 'Reassembly', `
S::........
..#.....#..
.......#...
...#....#..
...........
........#G.
`);
// ── Act VI (31-36): everything at once, on bigger boards. ────────────────────
lvl(31, 'Warp and Crack', `
..... ..~~~..
.S...11..~~~..
..... ..~~~..
..A.. ..~~~..
..... ..~t~..
.......
...t...
.......
..G....
.......
`);
lvl(32, 'Gatehouse', `
..... ..~~~.. .....
.S... ..~~~.. .....
.....11..~~~..22..G..
..... ..~~~.. .....
..A.. ..B~~.. .....
`);
lvl(33, 'Pillars', `
S::..........
..#.......#..
.#...........
.............
.............
...#.#.......
.........#.#.
........#..G.
`);
lvl(34, 'The Gauntlet', `
..... ..~~~~..
.S...11..~~~~..
..... ..~~~~..
..A.. ..~~~~..
..... ..~~~~..
.
.
....::....G
`);
lvl(35, 'Two Wings', `
..A..
.....
.
..... .......... .....
.S...11..........22..G..
..... .......... .....
.
.....
..B..
`);
lvl(36, 'Grand Finale', `
..... .......
.S...11...t...
..... .......
..A.. .......
..~~~~..
..~~~~..
t.~~~~..
..~~~~..
..~~~~..
.
.
....::....G
`);
// ── Solve-gate + write ───────────────────────────────────────────────────────
console.log(`[bloxorz] auditing ${LEVELS.length} hand-authored levels…`);
let failed = 0;
const levels = LEVELS.map((def) => {
const { par, problems, notes } = auditLevel(def);
if (problems.length) {
console.error(` ✗ level ${def.level} "${def.name}" — ${problems.join(', ')}`);
failed++;
} else {
const tail = notes.length ? ` (note: ${notes.join('; ')})` : '';
console.log(` ✓ level ${def.level} "${def.name}" — ${def.cols}x${def.rows}, par ${par}${tail}`);
}
return { ...def, par };
});
// The whole point of the overhaul: difficulty has to actually climb. Compare
// six-level acts rather than adjacent levels, which leaves room for a breather
// after a hard one without letting a whole act flatten out.
const acts = [];
for (let i = 0; i < levels.length; i += 6) {
const slice = levels.slice(i, i + 6);
acts.push(slice.reduce((s, l) => s + l.par, 0) / slice.length);
}
acts.forEach((avg, i) => console.log(`[bloxorz] act ${i + 1} average par ${avg.toFixed(1)}`));
for (let i = 1; i < acts.length; i++) {
if (acts[i] <= acts[i - 1]) {
console.error(` ✗ act ${i + 1} (avg par ${acts[i].toFixed(1)}) is no harder than act ${i} (${acts[i - 1].toFixed(1)})`);
failed++;
}
}
if (failed > 0) {
console.error(`[bloxorz] ${failed} problem(s) — 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}`);