fertig-classic-games/tools/bloxorzMap.js

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