Playing on a phone, the boards read as a grid of long straight lines crossing everywhere — "一看到这个画面,我脑袋就大". The cause is one line in the filler. Each family lays its signature routes over part of the board and leaves the rest to completePorts, which paired every leftover port with the one straight across. On a switchback board that made 76% of all track pairs diameters and left 43 of 61 cells containing nothing but diameters. Two diameters in one cell always cross, so the straightness and the crossings were never two problems. Preferring a bend halves both, in every family: switchback 76% → 40% straight and 2.38 → 0.92 crossings per cell, crossing 86% → 10%, weave 37% → 5%. It was also quietly suppressing 反差, the swing between a piece looking hopeless and becoming trivial, which the design brief calls the point of the game. Straight routes barely change between orientations. With bends, petals goes from 4.2 to 14.0 and crossing from 4.8 to 28.8 — both had been failing the ≥12 gate and were absent from the level pool entirely. Petals is the family the user singles out as the good-looking one. Generation yield rises with it: four seeds now grow 24 playable levels against 3, at greater rotation depth. createRouteBoard keeps 'straight' as its default so nothing already built moves; the sweep opts in. labs/route-gallery draws both, with the real renderer, so the choice is made by looking rather than by reading a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
16 KiB
JavaScript
323 lines
16 KiB
JavaScript
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);
|
||
}
|
||
|
||
// Every family lays its signature routes over part of the board and leaves the
|
||
// rest to be filled in. The filler used to pair a leftover port with the one
|
||
// straight across, which is why a board reads as a grid of long straight lines
|
||
// crossing everywhere: on a switchback board 76% of all track pairs were
|
||
// diameters and 43 of 61 cells were nothing but diameters. Two diameters in one
|
||
// cell always cross, so straight filler buys crossings as well as straightness.
|
||
// `fill` picks what a leftover port prefers instead; 'curve' asks for a bend.
|
||
function completePorts(design, family, variant, fill = 'straight') {
|
||
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 bend = mod(a + 1 + phase % 2); // 60° or 120°, varied by phase
|
||
const preferred = fill === 'curve' ? bend
|
||
: 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, fill = 'straight' } = {}) {
|
||
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, fill);
|
||
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 },
|
||
};
|
||
}
|