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