158 lines
7.1 KiB
JavaScript
158 lines
7.1 KiB
JavaScript
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);
|
|
}
|