158 lines
6.5 KiB
JavaScript
158 lines
6.5 KiB
JavaScript
// Bloxorz ASCII map parser + level design gates. Shared by genBloxorz.js (which
|
|
// builds the bank) and by scratch/tuning scripts, so a level can be audited on
|
|
// its own without running the whole generator. Pure Node, no Phaser.
|
|
//
|
|
// See genBloxorz.js for the map legend.
|
|
|
|
import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js';
|
|
|
|
// ── Map parser ───────────────────────────────────────────────────────────────
|
|
|
|
const BRIDGE_CHARS = '123';
|
|
const HEAVY_CHARS = 'ABC';
|
|
const SOFT_CHARS = 'abc';
|
|
const HOLD_CHARS = 'pqr';
|
|
const TELEPORT_CHARS = 'tuv';
|
|
|
|
export function parseMap(n, name, art, opts = {}) {
|
|
const raw = art.replace(/^\n/, '').replace(/\s+$/, '').split('\n');
|
|
const indent = Math.min(...raw.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length));
|
|
const lines = raw.map((l) => l.slice(indent));
|
|
const rows = lines.length;
|
|
const cols = Math.max(...lines.map((l) => l.length));
|
|
|
|
const openGroups = new Set(opts.open ?? []);
|
|
const tiles = [];
|
|
let start = null;
|
|
let goal = null;
|
|
let switchSeq = 0;
|
|
|
|
const linksFor = (ch, fallback) => (opts.links?.[ch] ?? fallback).map((g) => `b${g}`);
|
|
|
|
for (let y = 0; y < rows; y++) {
|
|
for (let x = 0; x < lines[y].length; x++) {
|
|
const ch = lines[y][x];
|
|
if (ch === ' ') continue;
|
|
|
|
if (ch === 'S') { start = { x, y, orient: 'up' }; tiles.push({ x, y, type: 'floor' }); continue; }
|
|
if (ch === 'G') { goal = { x, y }; tiles.push({ x, y, type: 'goal' }); continue; }
|
|
if (ch === '.') { tiles.push({ x, y, type: 'floor' }); continue; }
|
|
if (ch === ':') { tiles.push({ x, y, type: 'floor', split: true }); continue; }
|
|
if (ch === '#') { tiles.push({ x, y, type: 'wall' }); continue; }
|
|
if (ch === '~') { tiles.push({ x, y, type: 'fragile' }); continue; }
|
|
|
|
if (BRIDGE_CHARS.includes(ch)) {
|
|
const group = BRIDGE_CHARS.indexOf(ch) + 1;
|
|
tiles.push({ x, y, type: 'bridge', bridgeId: `b${group}`, initiallyOpen: openGroups.has(group) });
|
|
continue;
|
|
}
|
|
if (TELEPORT_CHARS.includes(ch)) { tiles.push({ x, y, type: 'teleport', teleportId: ch }); continue; }
|
|
|
|
const asSwitch = (chars, requireOrientation, mode) => {
|
|
const group = chars.indexOf(ch) + 1;
|
|
switchSeq += 1;
|
|
tiles.push({
|
|
x, y, type: 'switch', switchId: `s${switchSeq}`,
|
|
linkedBridgeIds: linksFor(ch, [group]), requireOrientation, mode,
|
|
});
|
|
};
|
|
if (HEAVY_CHARS.includes(ch)) { asSwitch(HEAVY_CHARS, 'standing', 'toggle'); continue; }
|
|
if (SOFT_CHARS.includes(ch)) { asSwitch(SOFT_CHARS, 'any', 'toggle'); continue; }
|
|
if (HOLD_CHARS.includes(ch)) { asSwitch(HOLD_CHARS, 'lying', 'momentary'); continue; }
|
|
|
|
throw new Error(`L${n} "${name}": unknown map character '${ch}' at ${x},${y}`);
|
|
}
|
|
}
|
|
|
|
if (!start) throw new Error(`L${n} "${name}": no start (S)`);
|
|
if (!goal) throw new Error(`L${n} "${name}": no goal (G)`);
|
|
|
|
const teleports = new Map();
|
|
for (const t of tiles) {
|
|
if (t.type === 'teleport') teleports.set(t.teleportId, (teleports.get(t.teleportId) ?? 0) + 1);
|
|
}
|
|
for (const [id, count] of teleports) {
|
|
if (count !== 2) throw new Error(`L${n} "${name}": teleport '${id}' appears ${count}x, must be exactly 2`);
|
|
}
|
|
|
|
const splitCount = tiles.filter((t) => t.split).length;
|
|
if (splitCount % 2 !== 0) throw new Error(`L${n} "${name}": odd number of split tiles (${splitCount})`);
|
|
|
|
const bridgeGroups = new Set(tiles.filter((t) => t.type === 'bridge').map((t) => t.bridgeId));
|
|
const linked = new Map();
|
|
for (const t of tiles) {
|
|
if (t.type !== 'switch') continue;
|
|
for (const bid of t.linkedBridgeIds) {
|
|
if (!linked.has(bid)) linked.set(bid, new Set());
|
|
linked.get(bid).add(t.mode);
|
|
}
|
|
}
|
|
for (const bid of bridgeGroups) {
|
|
if (!linked.has(bid)) throw new Error(`L${n} "${name}": bridge ${bid} has no switch`);
|
|
if (linked.get(bid).size > 1) throw new Error(`L${n} "${name}": bridge ${bid} mixes toggle and momentary switches`);
|
|
}
|
|
for (const bid of linked.keys()) {
|
|
if (!bridgeGroups.has(bid)) throw new Error(`L${n} "${name}": switch wired to ${bid}, which has no bridge tiles`);
|
|
}
|
|
|
|
return { level: n, name, cols, rows, start, goal, tiles };
|
|
}
|
|
|
|
// ── Design gates ─────────────────────────────────────────────────────────────
|
|
|
|
const SOLVE_OPTS = { maxStates: 400000 };
|
|
const clone = (def) => JSON.parse(JSON.stringify(def));
|
|
|
|
export function parOf(def) {
|
|
return solve(loadLevel(def), SOLVE_OPTS).moves;
|
|
}
|
|
|
|
// A mechanic that the level can be finished without is just decoration. Each
|
|
// gate strips one mechanic and demands the level become unsolvable.
|
|
function withoutTiles(def, pred) {
|
|
const out = clone(def);
|
|
out.tiles = out.tiles.filter((t) => !pred(t));
|
|
return out;
|
|
}
|
|
|
|
function mapTiles(def, fn) {
|
|
const out = clone(def);
|
|
out.tiles = out.tiles.map(fn);
|
|
return out;
|
|
}
|
|
|
|
export function auditLevel(def) {
|
|
const problems = [];
|
|
const notes = [];
|
|
const has = (pred) => def.tiles.some(pred);
|
|
|
|
const par = parOf(def);
|
|
if (par < 0) { problems.push('unsolvable'); return { par, problems, notes }; }
|
|
|
|
if (has((t) => t.type === 'bridge')) {
|
|
// Bridges gone: the far side must be genuinely cut off.
|
|
if (parOf(withoutTiles(def, (t) => t.type === 'bridge')) >= 0) problems.push('bridges are optional');
|
|
}
|
|
if (has((t) => t.type === 'teleport')) {
|
|
if (parOf(mapTiles(def, (t) => (t.type === 'teleport' ? { x: t.x, y: t.y, type: 'floor' } : t))) >= 0) {
|
|
problems.push('teleporters are optional');
|
|
}
|
|
}
|
|
if (has((t) => t.split)) {
|
|
if (parOf(mapTiles(def, (t) => (t.split ? { ...t, split: false } : t))) >= 0) problems.push('split is optional');
|
|
}
|
|
if (has((t) => t.type === 'fragile')) {
|
|
// Fragile ground has to be load-bearing (you must cross it) AND has to
|
|
// actually constrain the route, or it is just orange-coloured floor.
|
|
if (parOf(withoutTiles(def, (t) => t.type === 'fragile')) >= 0) problems.push('fragile ground is optional');
|
|
const solid = parOf(mapTiles(def, (t) => (t.type === 'fragile' ? { x: t.x, y: t.y, type: 'floor' } : t)));
|
|
if (solid === par) notes.push('fragile ground does not change the optimal route');
|
|
}
|
|
if (has((t) => t.type === 'wall')) {
|
|
const noWalls = parOf(withoutTiles(def, (t) => t.type === 'wall'));
|
|
if (noWalls === par) notes.push('walls do not change the optimal route');
|
|
}
|
|
return { par, problems, notes };
|
|
}
|
|
|