feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user