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
+157
View File
@@ -0,0 +1,157 @@
import { largeLevels } from './large-levels.js';
// Isolated experimental model. Cars occupy a small circular clearance envelope.
// The same sampled curves drive drawing, travel and collision checking.
export const key = (x, y) => `${x},${y}`;
export const directions = kind => kind === 'square'
? [[0, -1], [1, 0], [0, 1], [-1, 0]]
: [[0, -1], [1, -1], [1, 0], [0, 1], [-1, 1], [-1, 0]];
export function center(kind, x, y) {
return kind === 'square' ? { x, y } : { x: x * Math.sqrt(3) / 2, y: y + x / 2 };
}
export function port(kind, d) {
const angle = -Math.PI / 2 + d * Math.PI * 2 / directions(kind).length;
return { x: Math.cos(angle) / 2, y: Math.sin(angle) / 2 };
}
export function curve(kind, cell, a, b) {
const c = center(kind, cell.x, cell.y), p = port(kind, a), q = port(kind, b);
return Array.from({ length: 19 }, (_, i) => {
const t = i / 18, u = 1 - t;
return {
x: c.x + u ** 3 * p.x + 3 * u * u * t * p.x * .35 + 3 * u * t * t * q.x * .35 + t ** 3 * q.x,
y: c.y + u ** 3 * p.y + 3 * u * u * t * p.y * .35 + 3 * u * t * t * q.y * .35 + t ** 3 * q.y,
};
});
}
export function rotate(board, turns = 1) {
const n = directions(board.kind).length, count = ((turns % n) + n) % n;
let cells = board.cells;
for (let i = 0; i < count; i++) cells = cells.map(c => {
const [x, y] = board.kind === 'square' ? [-c.y, c.x] : [-c.y, c.x + c.y];
const tracks = [];
c.tracks.forEach((b, a) => { tracks[(a + 1) % n] = (b + 1) % n; });
return { x: x || 0, y: y || 0, tracks };
});
return { ...board, cells };
}
function segmentHit(a, b, c, radius) {
const dx = b.x - a.x, dy = b.y - a.y, length2 = dx * dx + dy * dy;
if (!length2) return null;
const px = a.x - c.x, py = a.y - c.y;
const v = px * dx + py * dy, w = px * px + py * py - radius * radius;
if (w <= 0) return 0;
const disc = v * v - length2 * w;
if (disc < 0) return null;
const t = (-v - Math.sqrt(disc)) / length2;
return t >= 0 && t <= 1 ? t : null;
}
export function trace(board, car, cars = board.cars) {
const dirs = directions(board.kind), n = dirs.length;
const cells = new Map(board.cells.map(c => [key(c.x, c.y), c]));
let x = car.x, y = car.y, d = car.dir, bends = 0;
const start = center(board.kind, x, y), p = port(board.kind, d);
let points = [start, { x: start.x + p.x, y: start.y + p.y }];
const seen = new Set();
let status = 'loop';
for (let step = 0; step <= board.cells.length * n; step++) {
const [dx, dy] = dirs[d]; x += dx; y += dy;
const cell = cells.get(key(x, y));
if (!cell) { status = 'exit'; break; }
const a = (d + n / 2) % n, b = cell.tracks[a];
const signature = `${x},${y},${a}`;
if (seen.has(signature)) break;
seen.add(signature);
if (b !== d) bends++;
points.push(...curve(board.kind, cell, a, b)); d = b;
}
// Short vehicles fit inside radius .36; two cars require .72 clearance.
for (let i = 1; i < points.length; i++) {
let hit = null;
for (const other of cars) {
if (other.id === car.id) continue;
const t = segmentHit(points[i - 1], points[i], center(board.kind, other.x, other.y), .72);
if (t !== null && (!hit || t < hit.t)) hit = { t, id: other.id };
}
if (hit) {
const a = points[i - 1], b = points[i];
points = [...points.slice(0, i), { x: a.x + (b.x - a.x) * hit.t, y: a.y + (b.y - a.y) * hit.t }];
return { status: 'blocked', points, blocker: hit.id, bends, steps: seen.size };
}
}
return { status, points, bends, steps: seen.size, exitDir: d };
}
function rng(seed) {
return () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
}
export function generate(kind, seed = 71, target = 14, radius = 3) {
const random = rng(seed), n = directions(kind).length, cells = [];
for (let x = -radius; x <= radius; x++) for (let y = -radius; y <= radius; y++) {
if (kind === 'hex' && Math.abs(x + y) > radius) continue;
const ports = Array.from({ length: n }, (_, i) => i), tracks = [];
while (ports.length) {
const a = ports.shift(), b = ports.splice(Math.floor(random() * ports.length), 1)[0];
tracks[a] = b; tracks[b] = a;
}
cells.push({ x, y, tracks });
}
const board = { kind, cells, cars: [], seed, radius }, boards = Array.from({ length: n }, (_, i) => rotate(board, i));
// Reverse insertion across orientations guarantees a solution, without forbidding loops
// in other orientations. Bias towards real, multi-bend routes rather than decoration.
for (let i = 0; i < target; i++) {
const occupied = new Set(board.cars.map(c => key(c.x, c.y))), candidates = [];
for (const cell of cells) {
if (occupied.has(key(cell.x, cell.y))) continue;
for (let dir = 0; dir < n; dir++) {
const car = { id: `c${i}`, x: cell.x, y: cell.y, dir, color: i % 7 };
const orientation = Math.floor(random() * n);
const route = trace(boards[orientation], car, board.cars);
if (route.status === 'exit' && route.steps <= (radius > 3 ? 80 : 24)) {
candidates.push({ car, weight: Math.min(route.bends, 8) * 2 + Math.min(route.steps, 14) * .3 + random() * 4 });
}
}
}
if (!candidates.length) break;
candidates.sort((a, b) => b.weight - a.weight);
board.cars.push(candidates[0].car);
}
return board;
}
export function suggestion(board, cars) {
const n = directions(board.kind).length;
for (const turn of [0, 1, -1, 2, -2, 3].slice(0, n)) {
const candidate = rotate(board, turn);
for (const car of cars) if (trace(candidate, car, cars).status === 'exit') return { turn, id: car.id };
}
return null;
}
export function solve(board) {
let state = board, cars = [...board.cars];
const actions = [], paths = [];
while (cars.length) {
const next = suggestion(state, cars);
if (!next) return { solvable: false, actions, paths };
if (next.turn) { state = rotate(state, next.turn); actions.push({ turn: next.turn }); }
const car = cars.find(c => c.id === next.id);
paths.push(trace(state, car, cars)); actions.push({ id: car.id });
cars = cars.filter(c => c.id !== car.id);
}
return { solvable: true, actions, paths };
}
export function generateSample(kind, scenario = 1, variation = 0) {
if (scenario === 3) return rotate(structuredClone(largeLevels[kind]), variation % directions(kind).length);
const target = [10, 16, 22][scenario];
const base = (kind === 'square' ? [59, 107, 107] : [71, 233, 907])[scenario];
if (kind !== 'square' || variation === 0) return generate(kind, base + variation * 37, target);
let best = null, score = -Infinity;
// Screen real solution travel rather than counting decorative road curves.
for (let i = 0; i < 16; i++) {
const candidate = generate(kind, base + variation * 37 + i * 17, target);
const solution = solve(candidate);
const mean = solution.paths.reduce((sum,p) => sum+p.bends,0) / target;
const maximum = Math.max(...solution.paths.map(p => p.bends));
if (!solution.solvable || candidate.cars.length !== target || mean < 6 || maximum < 12) continue;
const value = mean + solution.paths.filter(p => p.bends >= 5).length / target;
if (value > score) { score = value; best = candidate; }
}
return best || generate(kind, 107, target);
}