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