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); }