feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+313
View File
@@ -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 },
};
}