Files
HexTrails/src/route-grammar.js
T

314 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DIRS, hexagon, key, opposite, random } from './core.js';
import { FLOWER_LEVEL, flowerTrackEmphasis } from './flower-study.js';
// Each grammar reserves real port pairs before the unused ports are completed.
// The seed moves gates and folds within that grammar; it never draws random rails.
export const ROUTE_FAMILIES = [
{ id: 'petals', name: '花瓣', description: '叶瓣回到花心,再沿另一片叶瓣离场。' },
{ id: 'orbits', name: '环绕', description: '同心环接入开放的径向通道,逐圈展开。' },
{ id: 'spiral', name: '螺旋', description: '连续深入的螺旋接回出盘通道。' },
{ id: 'switchback', name: '回折', description: '长通道在相邻分区折返,局部闸口改变顺序。' },
{ id: 'weave', name: '编织', description: '连续交替转向的带状路线跨过盘面。' },
{ id: 'crossing', name: '双叶跨区', description: '两片回环在中央交织,共用远端开放出口。' },
];
const mod = (value, base = 6) => (value % base + base) % base;
const rotate = ([q, r], turns) => {
for (let i = 0; i < mod(turns); i++) [q, r] = [-r || 0, q + r || 0];
return [q, r];
};
const direction = (a, b) => DIRS.findIndex(([q, r]) => b[0] - a[0] === q && b[1] - a[1] === r);
const step = ([q, r], d) => [q + DIRS[d][0], r + DIRS[d][1]];
const pairKey = (q, r, a, b) => `${q},${r}:${Math.min(a, b)},${Math.max(a, b)}`;
function ring(radius, [q, r] = [0, 0]) {
const result = [];
let at = [q, r + radius];
for (let d = 0; d < 6; d++) for (let i = 0; i < radius; i++) {
result.push(at);
at = step(at, (d + 5) % 6);
}
return result;
}
function draft(radius) {
const cells = hexagon(radius).map(cell => ({ ...cell, tracks: Array(6).fill(null) }));
const board = new Map(cells.map(cell => [key(cell.q, cell.r), cell]));
const featuredPorts = new Set();
function pair(at, a, b) {
const cell = board.get(key(...at));
if (!cell || a < 0 || b < 0 || a === b) throw new Error(`Invalid route pair at ${at}: ${a},${b}`);
if ((cell.tracks[a] !== null && cell.tracks[a] !== b) || (cell.tracks[b] !== null && cell.tracks[b] !== a)) {
throw new Error(`Route grammar overlaps a port at ${at}: ${a},${b}`);
}
cell.tracks[a] = b; cell.tracks[b] = a;
featuredPorts.add(`${at}:${a}`); featuredPorts.add(`${at}:${b}`);
}
function replace(at, pairs) {
const cell = board.get(key(...at));
for (const port of pairs.flat()) {
const partner = cell.tracks[port];
if (partner !== null) cell.tracks[partner] = null;
cell.tracks[port] = null;
}
for (const [a, b] of pairs) pair(at, a, b);
}
function path(points, entry, exit) {
for (let i = 0; i < points.length; i++) {
pair(points[i], i ? direction(points[i], points[i - 1]) : entry,
i + 1 < points.length ? direction(points[i], points[i + 1]) : exit);
}
}
function loop(points) {
for (let i = 0; i < points.length; i++) pair(points[i], direction(points[i], points[mod(i - 1, points.length)]), direction(points[i], points[(i + 1) % points.length]));
}
function ray(at, dir) {
for (let next = step(at, dir); board.has(key(...next)); next = step(next, dir)) pair(next, opposite(dir), dir);
}
return { cells, board, featuredPorts, pair, replace, path, loop, ray };
}
const centerWirings = [[1, 0, 3, 2, 5, 4], [3, 4, 5, 0, 1, 2], [1, 0, 4, 5, 2, 3]];
function petals(design, radius, variant) {
if (radius === 5) {
const emphasized = flowerTrackEmphasis();
for (const source of FLOWER_LEVEL.cells) {
const cell = design.board.get(key(source.q, source.r));
cell.tracks = [...source.tracks];
for (let port = 0; port < 6; port++) if (emphasized(source, port)) design.featuredPorts.add(`${source.q},${source.r}:${port}`);
}
const wiring = centerWirings[mod(variant, centerWirings.length)];
design.replace([0, 0], wiring.flatMap((b, a) => a < b ? [[a, b]] : []));
// One gate can be rethreaded while all six petal outlines remain intact.
if (mod(variant, 4) > 1) {
const at = rotate([0, -2], mod(variant, 6));
const cell = design.board.get(key(...at));
const pairs = cell.tracks.flatMap((b, a) => a < b ? [[a, b]] : []);
const [[a, b], [c, d]] = pairs;
design.replace(at, [[a, c], [b, d]]);
}
return;
}
if (radius === 2) {
// Six triangular petals: every drawn lobe belongs to one open tour.
const circuit = [];
for (let sector = 0; sector < 6; sector++) circuit.push(...[[0, -1], [0, -2], [1, -2], [0, -1]].map(point => rotate(point, sector)));
const first = 1;
const points = [...circuit.slice(first), ...circuit.slice(0, first), circuit[first]];
design.path(points, 0, 1);
for (let sector = 0; sector < 6; sector++) if (variant & (1 << sector)) {
// Reverse one lobe through its two gates, preserving the open full tour.
design.replace(rotate([0, -1], sector), [[4, 1], [0, 2]].map(pair => pair.map(port => mod(port + sector))));
}
return;
}
const rootDistance = radius - 2;
for (let sector = 0; sector < 6; sector++) {
const lobe = ring(1, [0, -(radius - 1)]).map(point => rotate(point, sector));
design.loop(lobe);
for (let distance = 1; distance <= radius; distance++) {
const at = rotate([0, -distance], sector);
design.pair(at, sector, (sector + 3) % 6);
}
const root = rotate([0, -rootDistance], sector);
const gate = variant & (1 << sector) ? [[1, 0], [5, 3]] : [[1, 3], [5, 0]];
design.replace(root, gate.map(pair => pair.map(port => mod(port + sector))));
}
const wiring = centerWirings[mod(variant, centerWirings.length)];
for (let a = 0; a < 6; a++) if (a < wiring[a]) design.pair([0, 0], a, wiring[a]);
}
function orbits(design, radius, variant) {
for (let distance = 1; distance <= radius; distance++) design.loop(ring(distance));
for (let r = -radius; r <= radius; r++) design.pair([0, r], 0, 3);
for (let distance = 1; distance <= radius; distance++) {
// Switching one ring's gate to the opposite side changes traversal order.
const south = mod(variant, 2 ** radius) & (1 << (distance - 1));
const turn = south ? 3 : 0;
const at = rotate([0, -distance], turn);
const pairs = mod(variant + distance, 3) === 0 ? [[0, 4], [3, 2]] : [[0, 2], [3, 4]];
design.replace(at, pairs.map(pair => pair.map(port => mod(port + turn))));
}
}
// Complete a partly reserved route through unused ports. A cell is visited only
// once in this extension, so no new pair can consume its own earlier exit port.
function openExtension(design, start, entry, rng) {
const queue = [{ at: start, entry, points: [start] }], seen = new Set([key(...start)]);
for (let head = 0; head < queue.length; head++) {
const node = queue[head], cell = design.board.get(key(...node.at));
const options = [0, 1, 2, 3, 4, 5].filter(port => port !== node.entry && cell.tracks[port] === null);
const phase = Math.floor(rng() * options.length);
options.push(...options.splice(0, phase));
for (const exit of options) {
const next = step(node.at, exit), nextCell = design.board.get(key(...next));
if (!nextCell) return { points: node.points, exit };
if (nextCell.tracks[opposite(exit)] !== null || seen.has(key(...next))) continue;
seen.add(key(...next));
queue.push({ at: next, entry: opposite(exit), points: [...node.points, next] });
}
}
return null;
}
function spiral(design, radius, rng, variant) {
const points = ring(radius);
for (let distance = radius - 1; distance >= 1; distance--) {
const next = ring(distance), last = points[points.length - 1];
const gates = next.flatMap((point, index) => direction(last, point) >= 0 ? [index] : []);
const gate = gates[mod(variant + Math.floor(rng() * gates.length), gates.length)];
points.push(...next.slice(gate), ...next.slice(0, gate));
}
points.push([0, 0]);
// Reserve the inward tour, leaving its last entry to be paired with an exit.
for (let i = 0; i + 1 < points.length; i++) design.pair(points[i], i ? direction(points[i], points[i - 1]) : 3, direction(points[i], points[i + 1]));
const entry = direction([0, 0], points[points.length - 2]);
const extension = openExtension(design, [0, 0], entry, rng);
if (!extension) throw new Error('Spiral has no free outward connector');
design.path(extension.points, entry, extension.exit);
}
// Reversing a short strip moves its two gates without changing cell coverage.
// This preserves the recognisable lanes and gives a true route-order variant.
function shiftFolds(points, rng, variant, radius) {
for (let change = 0; change < mod(variant, 4); change++) {
const candidates = [];
for (let i = 0; i + 4 < points.length; i++) for (let j = i + 3; j < Math.min(points.length - 1, i + radius * 4 + 4); j++) {
if (direction(points[i], points[j]) >= 0 && direction(points[i + 1], points[j + 1]) >= 0) candidates.push([i, j]);
}
if (candidates.length) {
const [i, j] = candidates[Math.floor(rng() * candidates.length)];
points.splice(i + 1, j - i, ...points.slice(i + 1, j + 1).reverse());
} else {
// A triangular corner lets the connection enter the next lane one cell
// earlier. The omitted cell keeps complete secondary tracks below.
const corners = points.flatMap((point, i) => i > 0 && i + 1 < points.length && direction(points[i - 1], points[i + 1]) >= 0 ? [i] : []);
if (!corners.length) break;
points.splice(corners[Math.floor(rng() * corners.length)], 1);
}
}
return points;
}
function boundaryPort(design, at, preferred) {
return [preferred, ...[0, 1, 2, 3, 4, 5].filter(port => port !== preferred)].find(port => !design.board.has(key(...step(at, port))));
}
function switchback(design, radius, rng, variant) {
const points = [];
for (let q = -radius; q <= radius; q++) {
const lane = design.cells.filter(cell => cell.q === q).map(cell => [cell.q, cell.r]);
if (mod(q + radius, 2)) lane.reverse();
points.push(...lane);
}
shiftFolds(points, rng, variant, radius);
design.path(points, boundaryPort(design, points[0], 5), boundaryPort(design, points[points.length - 1], 2));
}
function weave(design, radius, rng, variant) {
const points = [], bottom = -Math.ceil(radius / 2), top = Math.floor(radius / 2);
for (let row = bottom; row <= top; row++) {
const lane = [];
for (let q = -radius; q <= radius; q++) lane.push([q, row - Math.floor(q / 2)]);
if (mod(row - bottom, 2)) lane.reverse();
points.push(...lane);
}
shiftFolds(points, rng, variant, radius);
design.path(points, boundaryPort(design, points[0], 5), boundaryPort(design, points[points.length - 1], 2));
}
function crossing(design, radius, variant) {
const north = Math.floor(radius / 2), south = radius >= 4 && mod(variant, 3) === 1 ? 1 : north;
design.loop(ring(north, [0, -north]));
design.loop(ring(south, [0, south]));
design.replace([0, 0], mod(variant, 2) ? [[1, 4], [5, 2]] : [[1, 2], [5, 4]]);
const gate = [0, -north * 2];
design.replace(gate, [[2, 0], [4, 1]]);
design.ray(gate, 0); design.ray(gate, 1);
}
function completePorts(design, family, variant) {
for (const cell of design.cells) {
const free = cell.tracks.flatMap((value, port) => value === null ? [port] : []);
// Periodic completion keeps secondary rails ordered instead of random noise.
const phase = mod(cell.q - cell.r + variant, 3);
while (free.length) {
const a = free.shift();
const preferred = family === 'weave' && phase === 0 ? mod(a + 1) : opposite(a);
const b = free.splice(free.includes(preferred) ? free.indexOf(preferred) : mod(phase, free.length), 1)[0];
cell.tracks[a] = b; cell.tracks[b] = a;
}
}
}
function describeRoutes(design) {
const candidates = [];
const reachablePairs = new Set();
for (const start of design.cells) for (let dir = 0; dir < 6; dir++) {
let q = start.q, r = start.r, exit = dir, bends = 0, steps = 1;
const seen = new Set([`${q},${r}:${exit}`]), pairs = [];
let status = 'loop';
for (let count = 0; count <= design.cells.length * 6; count++) {
q += DIRS[exit][0]; r += DIRS[exit][1];
const cell = design.board.get(key(q, r));
if (!cell) { status = 'exit'; break; }
const enter = opposite(exit); exit = cell.tracks[enter];
if (opposite(enter) !== exit) bends++;
steps++;
const signature = `${q},${r}:${exit}`;
if (seen.has(signature)) break;
seen.add(signature);
if (design.featuredPorts.has(`${q},${r}:${enter}`) && design.featuredPorts.has(`${q},${r}:${exit}`)) pairs.push(pairKey(q, r, enter, exit));
}
if (status !== 'exit') continue;
for (const pair of pairs) reachablePairs.add(pair);
if (pairs.length) {
const uniquePairs = [...new Set(pairs)];
candidates.push({ q: start.q, r: start.r, dir, steps, bends, featuredPairs: uniquePairs.length, pairs: uniquePairs });
}
}
const featured = design.cells.flatMap(cell => cell.tracks.flatMap((b, a) => a < b && reachablePairs.has(pairKey(cell.q, cell.r, a, b)) ? [{ q: cell.q, r: cell.r, a, b }] : []));
candidates.sort((a, b) => b.featuredPairs - a.featuredPairs || b.bends - a.bends || b.steps - a.steps || a.q - b.q || a.r - b.r || a.dir - b.dir);
// Keep witnesses for every open branch, rather than filling the list with
// offsets along just the longest branch of a multi-petal design.
const selected = [], uncovered = new Set(reachablePairs);
while (uncovered.size) {
let best = null, gain = 0;
for (const candidate of candidates) {
const extra = candidate.pairs.reduce((count, pair) => count + Number(uncovered.has(pair)), 0);
if (extra > gain) { best = candidate; gain = extra; }
}
if (!best) break;
selected.push(best);
for (const pair of best.pairs) uncovered.delete(pair);
}
for (const candidate of candidates) {
if (selected.length >= 36) break;
if (!selected.includes(candidate)) selected.push(candidate);
}
return { featured, heroStarts: selected.map(({ pairs, ...candidate }) => candidate) };
}
export function createRouteBoard(seed, { family = 'petals', radius = 3, variant = 0 } = {}) {
const spec = ROUTE_FAMILIES.find(candidate => candidate.id === family);
if (!spec) throw new RangeError(`Unknown route family: ${family}`);
if (!Number.isInteger(radius) || radius < 2 || radius > 5) throw new RangeError('Route radius must be 2–5');
if (!Number.isInteger(variant)) throw new RangeError('Route variant must be an integer');
const rng = random(seed), design = draft(radius);
const gateVariant = mod(variant + (seed >>> 0), 96);
if (family === 'petals') petals(design, radius, gateVariant);
if (family === 'orbits') orbits(design, radius, gateVariant);
if (family === 'spiral') spiral(design, radius, rng, gateVariant);
if (family === 'switchback') switchback(design, radius, rng, gateVariant);
if (family === 'weave') weave(design, radius, rng, gateVariant);
if (family === 'crossing') crossing(design, radius, gateVariant);
completePorts(design, family, gateVariant);
const { featured, heroStarts } = describeRoutes(design);
return {
version: 1, id: `route-${family}-${radius}-${seed}-${variant}`, name: `${spec.name} · 路线草图`,
cells: design.cells, tiles: [], moveLimit: 1, rotationEnabled: true,
routeDesign: { family, variant, seed, radius, gateVariant, featured, heroStarts },
};
}