488 lines
10 KiB
JavaScript
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}`);
|