Files

458 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { drainOrientation, rotationPlan, batchTitles, batchSeeds } from './batch.js';
import { departure as buildDeparture, garagePath } from './routes.js';
import { journey, journeyPosition, scheduleDepartures } from './schedule.js';
import { carPaints, theme } from './theme.js';
import { at, advance, activeCorridors, passageOrder, resolveGridlock } from './motion.js';
import { createPerimeter } from './perimeter.js';
import { center, port, curve, rotate, trace, generateSample, generate, suggestion, solve, directions } from './model.js';
const $ = id => document.getElementById(id);
const canvas = $('game');
let ctx = canvas.getContext('2d'), backdrop = null;
let W = 768, H = 740, CX = 384, CY = 360;
const TAU = Math.PI * 2, FINISH = 0;
const params = new URLSearchParams(location.search);
let mode = params.get('mode') === 'batch' ? 'batch' : 'free';
let turnCount = 0, turnGoal = 0, roundStarted = 0, batchAvailable = [];
let batchClock = 0, pendingDepartures = [];
const freeOptions = ['01 · 连弯初见 / 10 辆', '02 · 交织长路 / 16 辆', '03 · 满盘绕行 / 22 辆', '04 · 超大桌面 / 96 辆'];
let kind = params.get('kind') === 'hex' ? 'hex' : 'square', scenario = params.get('size') === 'xl' ? 3 : 1, variation = 0, board, cars, total, delivered = 0;
if (mode === 'batch') scenario = 0;
let resumeAfterTurn = [], templates = new Map();
let traffic = [], hover = null, selected = null, auto = false, lastAuto = 0, speed = 2;
let turnAnimation = null, last = performance.now(), stats, idleFor = 0;
const mod = a => (a % TAU + TAU) % TAU;
const scale = () => kind === 'square' ? 68 : 74;
const screen = p => ({ x: CX + p.x * scale(), y: CY + p.y * scale() });
const carCenter = car => screen(center(kind, car.x, car.y));
let perimeter, finishPath;
const ring = a => perimeter.at(a);
function poly(points, color, width = 1, dash = []) {
if (!points.length) return;
ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.setLineDash(dash); ctx.stroke(); ctx.setLineDash([]);
}
function roundRect(x, y, w, h, r, fill, stroke) {
ctx.beginPath(); ctx.roundRect(x, y, w, h, r);
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
function text(value, x, y, size = 12, color = '#6e8574', align = 'center') {
ctx.fillStyle = color; ctx.font = `500 ${size}px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif`; ctx.textAlign = align; ctx.fillText(value, x, y);
}
const departure = route => buildDeparture(route, kind, screen, perimeter);
function busy() { return !!turnAnimation || pendingDepartures.length > 0 || traffic.some(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27); }
function say(message) { $('status').textContent = message; }
function ui() {
$('remaining').textContent = cars.length; $('transit').textContent = traffic.length; $('arrived').textContent = delivered;
$('meter').style.width = `${delivered / total * 100}%`;
$('left').disabled = $('right').disabled = (mode === 'batch' && busy()) || !!turnAnimation || (!cars.length && !traffic.some(t => t.distance < t.boardLength + 27));
if (mode === 'batch') batchStatus();
$('auto').textContent = auto ? 'Ⅱ 暂停演示' : '▶ 自动演示'; $('auto').setAttribute('aria-pressed', String(auto));
}
function batchStatus() {
const internal = traffic.filter(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27);
const outside = traffic.length - internal.length;
let phase, label, message;
if (turnAnimation) {
phase = 'turning'; label = '转动中'; message = '道路转动中,转好后即可查看能放行的车辆。';
} else if (pendingDepartures.length || internal.length) {
phase = 'running'; label = '放行中';
message = `本轮放行 ${roundStarted} 辆 · 待发 ${pendingDepartures.length} 辆 · 盘内 ${internal.length} 辆 · 外环 ${outside} 辆。已错开交汇时间,出发后连续通行。`;
} else if (!cars.length) {
phase = traffic.length ? 'arriving' : 'complete'; label = traffic.length ? '到站中' : '已清空';
message = traffic.length ? `棋盘已清空,外环还有 ${traffic.length} 辆正在前往终点。` : '全部抵达!';
} else if (batchAvailable.length) {
phase = 'ready'; label = '发车'; message = `当前方向可放行 ${batchAvailable.length} 辆,点击中间按钮统一放行,自动错开路线冲突。`;
} else {
phase = 'blocked'; label = '需转盘'; message = `当前方向无车可走,剩余 ${cars.length} 辆。请左转或右转棋盘。`;
}
$('batch-hud').dataset.phase = phase;
$('hint-label').textContent = label;
$('hint').disabled = phase !== 'ready';
$('hint').setAttribute('aria-label', phase === 'ready' ? `统一发车 ${batchAvailable.length} 辆` : label);
$('hint').setAttribute('aria-pressed', String(phase === 'running'));
$('run-symbol').setAttribute('d', 'M24 17L43 30 24 43Z');
$('turn-count').textContent = `已转 ${turnCount} 次`;
$('batch-result').textContent = phase === 'running' ? `放行中 · 待发 ${pendingDepartures.length} / 盘内 ${internal.length} / 外环 ${outside}`
: phase === 'ready' ? `可发车 ${batchAvailable.length} 辆` : message;
if ($('status').textContent !== message) say(message);
}
function listCars() {
$('car-list').replaceChildren(...cars.map(car => {
const button = document.createElement('button'); button.textContent = `车 ${Number(car.id.slice(1)) + 1}`;
button.addEventListener('click', () => { if (mode === 'batch') selected = car.id; else fire(car.id); }); return button;
}));
}
function cacheRoutes() {
const order = passageOrder(board, board.cars, trace);
templates = new Map(board.cars.map(car => {
const route = trace(board, car, []);
return [car.id, route.status === 'exit' ? { car, ...departure(route), distance: 0, order: order.get(car.id) } : null];
}));
}
function reset() {
const batch = mode === 'batch';
const large = !batch && scenario === 3;
W = large ? 1320 : 768; H = large ? 1380 : 740; CX = W / 2; CY = large ? 690 : 360;
canvas.width = W; canvas.height = H; backdrop = null;
document.body.classList.toggle('desktop-level', large || batch);
document.body.classList.toggle('batch-mode', batch);
canvas.style.setProperty('--board-aspect', String(W / H));
$('mode').value = mode;
canvas.setAttribute('aria-label', batch ? '转盘发车棋盘,点击车辆查看路线,点击中间发车按钮统一放行。' : '交互车场,点击车辆立即发车。也可用车辆操作列表。');
const labels = batch ? batchTitles.map((title, i) => `0${i + 1} · ${title} / 16 辆`) : freeOptions;
$('scenario').replaceChildren(...labels.map((label, i) => { const option = document.createElement('option'); option.value = String(i); option.textContent = label; return option; }));
$('auto').hidden = batch; $('batch-help').hidden = $('batch-hud').hidden = !batch;
$('hint').disabled = false;
$('hint-label').textContent = batch ? '发车' : '路线提示';
$('hint').setAttribute('aria-label', batch ? '统一发车' : '查看路线提示');
turnCount = 0; roundStarted = 0;
batchClock = 0; pendingDepartures = [];
$('batch-result').textContent = '点击发车开始';
$('scenario').value = String(scenario);
$('new').textContent = batch ? '下一关' : large ? '换个朝向' : '换张路网';
perimeter = createPerimeter(kind, CX, CY, scale(), large ? 7 : 3);
finishPath = garagePath(perimeter, W);
board = batch ? generate(kind, batchSeeds[kind][scenario], 16) : generateSample(kind, scenario, variation);
if (batch) { turnGoal = rotationPlan(board).turns.length; $('turn-goal').textContent = `目标 ${turnGoal} 次`; }
cars = [...board.cars]; batchAvailable = batch ? drainOrientation(board, cars).departed : []; total = cars.length; traffic = []; resumeAfterTurn = []; idleFor = 0; delivered = 0; hover = selected = null; auto = false; turnAnimation = null;
cacheRoutes();
$('win').hidden = true;
$('level-label').textContent = batch ? batchTitles[scenario] : scenario === 3 ? '超大桌面关卡' : `关卡 ${scenario + 1}`;
document.querySelectorAll('.level-progress b').forEach((b,i) => b.classList.toggle('current', i === scenario));
const solution = solve(board), multi = solution.paths.filter(p => p.bends >= 2).length;
stats = { mean: (solution.paths.reduce((sum,p) => sum+p.bends,0)/total).toFixed(1), multi, max: Math.max(...solution.paths.map(p => p.bends)), solvable: solution.solvable };
$('stats').textContent = `参考路线:平均 ${stats.mean} 弯,最长 ${stats.max} 弯;${multi}/${total} 辆经过多弯。路网 #${board.seed}。`;
$('board-label').textContent = kind === 'square' ? `${large ? 15 : 7} × ${large ? 15 : 7} · 四向道路` : `${board.cells.length} 格 · 六向道路`;
document.querySelectorAll('[data-kind]').forEach(b => { b.classList.toggle('active', b.dataset.kind === kind); b.setAttribute('aria-pressed', String(b.dataset.kind === kind)); });
say(batch ? '点击中间发车;没有可通行车辆时,由你转动棋盘,再继续发车。' : '点击立即开车;遇到前车或交叉冲突会刹停,路空后自动继续。'); listCars(); ui();
}
function fire(id) {
const car = cars.find(c => c.id === id); if (!car) return;
if (turnAnimation) { if (!resumeAfterTurn.includes(id)) resumeAfterTurn.push(id); return; }
// Occupancy never prevents departure: contact is checked against actual positions while driving.
const route = trace(board, car, []);
if (route.status !== 'exit') {
selected = id; say('这条路线是闭环,请转动底盘换路。'); return;
}
traffic.push({ ...templates.get(id), car, stage: 'board', stopped: false });
cars = cars.filter(c => c.id !== id); selected = hover = null;
say(mode === 'batch' ? '沿绿色提示路线发车中;本轮不会自动转动棋盘。' : '已经出发,可以继续点其他车;前方有车时会停下等候。');
listCars(); ui();
}
function turn(direction) {
if (turnAnimation || (mode === 'batch' && busy())) return;
if (mode === 'batch' && cars.length) { auto = false; turnCount++; $('batch-result').textContent = '已调整方向,点击发车'; }
// Re-seat cars still inside the rotating panel before changing its route geometry.
const returning = traffic.filter(t => ['board','waiting','joining'].includes(t.stage) && t.distance < t.boardLength + 27);
resumeAfterTurn = returning.map(t => t.car.id);
cars.push(...returning.map(t => t.car));
traffic = traffic.filter(t => !returning.includes(t));
if (!cars.length) return;
listCars();
turnAnimation = { from: board, direction, time: 0 };
selected = hover = null;
say(mode === 'batch' ? '道路转动中,转好后点击中间发车。' : `内部道路${direction > 0 ? '顺' : '逆'}时针转动 ${kind === 'square' ? 90 : 60}°;盘内行驶车辆归位,转好后重新出发。`); ui();
}
function startBatch() {
if (turnAnimation || busy() || !batchAvailable.length) return;
const ids = batchAvailable;
const candidates = ids.map(id => ({ ...templates.get(id), journey: journey(templates.get(id), perimeter, finishPath) }));
pendingDepartures = scheduleDepartures(candidates, {
now: batchClock, active: traffic, parked: cars.map(car => ({ id: car.id, ...carCenter(car) })),
clearance: scale() * .92 + 2, parkedClearance: scale() * .8 + 1,
});
batchAvailable = []; roundStarted = ids.length; auto = true; selected = hover = null;
tickBatch(0); listCars(); ui();
}
function tickBatch(dt) {
batchClock += dt * speed;
const starting = pendingDepartures.filter(t => t.start <= batchClock + 1e-8);
if (starting.length) {
const ids = new Set(starting.map(t => t.car.id));
cars = cars.filter(c => !ids.has(c.id));
pendingDepartures = pendingDepartures.filter(t => !ids.has(t.car.id));
traffic.push(...starting); listCars();
}
for (const actor of traffic) {
const elapsed = batchClock - actor.start;
actor.pose = journeyPosition(actor.journey, elapsed);
actor.distance = actor.pose.distance;
actor.stage = actor.distance < actor.journey.ringStart ? 'board'
: actor.distance < actor.journey.finishStart ? 'ring' : 'finish';
if (elapsed >= actor.journey.duration) { actor.stage = 'done'; delivered++; }
}
}
function hint() {
if (busy() || !cars.length) return;
const next = suggestion(board, cars);
if (!next) { say('没有可用路线,请换张路网。'); return; }
if (next.turn) { turn(Math.sign(next.turn)); return; }
selected = next.id;
const car = cars.find(c => c.id === next.id), route = trace(board, car, cars);
say(`已标出车 ${Number(car.id.slice(1)) + 1} 的路线 · ${route.bends} 处弯道。点击这辆车发车。`);
}
function drawCar(car, x, y, angle, glow = false) {
const size = scale() / 37 * 1.1;
const paint = carPaints[car.color % carPaints.length];
ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.scale(size, size);
if (glow) { ctx.beginPath(); ctx.arc(0, 0, 18, 0, TAU); ctx.fillStyle = '#ffcc0030'; ctx.fill(); ctx.strokeStyle = '#f5bd1d'; ctx.lineWidth = 1.4; ctx.stroke(); }
// Symmetric top-down silhouette: no offset colored slab beneath the tires.
// Rounded body and compact wheels still fit the model's .36-radius envelope.
for (const side of [-1, 1]) for (const axle of [-5.5, 5.5]) roundRect(axle - 2.5, side * 7 - 2, 5, 4, 1.5, '#354841');
roundRect(-11.5, -7, 23, 14, 4, paint.side);
roundRect(-11, -6.5, 22, 13, 3.5, paint.face);
poly([{x:-6,y:-5},{x:7,y:-5}], paint.light, 1.5);
roundRect(-5, -4.5, 11, 9, 2.5, paint.side);
roundRect(2, -4, 4, 8, 1.2, '#355578');
roundRect(-4, -4, 3, 8, 1, paint.light);
for (const sy of [-4, 3]) roundRect(10, sy, 1, 2, .5, '#fffbd6');
// High-contrast forward arrow stays legible on every body color.
ctx.beginPath();
[[-7,-2],[1,-2],[1,-5],[8,0],[1,5],[1,2],[-7,2]].forEach(([px,py], i) => i ? ctx.lineTo(px,py) : ctx.moveTo(px,py));
ctx.closePath(); ctx.fillStyle = '#F9FFFF'; ctx.fill(); ctx.strokeStyle = '#56637b'; ctx.lineWidth = .7; ctx.stroke();
ctx.restore();
}
function drawRing() {
const points = perimeter.points;
poly(points, theme.ringEdge, 48); poly(points, theme.ring, 38); poly(points, theme.ringMark, 1.3, [16, 24]);
for (let i = 0; i < 12; i++) {
const a = i / 12 * TAU, p = ring(a); ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.angle);
poly([{ x: -5, y: -4 }, { x: 0, y: 0 }, { x: -5, y: 4 }], theme.ringMark, 2); ctx.restore();
}
poly(finishPath.points, theme.ringEdge, 42); poly(finishPath.points, theme.ring, 32);
poly(finishPath.points, theme.ringMark, 1.5, [6, 9]);
text('ONE WAY / 顺时针', CX, H - 28, 11, '#8e97a6');
}
function drawRamps() {
if (kind === 'hex') return;
// Fixed exterior ramps cover every boundary port in both rotationally symmetric grids.
const occupied = new Set(board.cells.map(c => `${c.x},${c.y}`));
for (const cell of board.cells) directions(kind).forEach(([dx, dy], d) => {
if (occupied.has(`${cell.x + dx},${cell.y + dy}`)) return;
const c = center(kind, cell.x, cell.y), p = port(kind, d);
const route = { points: [c, { x: c.x + p.x, y: c.y + p.y }], exitDir: d };
const ramp = departure(route).connector;
poly(ramp, theme.ringEdge, 12); poly(ramp, theme.roadEdge, 8);
});
}
function clipHexRoads() {
// Keep road strokes and round caps inside the actual outer tile boundary.
if (kind !== 'hex') return;
ctx.beginPath();
for (const cell of board.cells) {
const c = screen(center(kind, cell.x, cell.y)), radius = scale() / Math.sqrt(3);
for (let i = 0; i < 6; i++) {
const x = c.x + Math.cos(i * TAU / 6) * radius;
const y = c.y + Math.sin(i * TAU / 6) * radius;
if (i) ctx.lineTo(x,y); else ctx.moveTo(x,y);
}
ctx.closePath();
}
ctx.clip();
}
function drawBoard() {
ctx.save();
// The rotating road layer remains underneath the fixed, close-fitting frame.
ctx.beginPath();
perimeter.points.forEach((p, i) => {
const x = CX + (p.x - CX) * (scenario === 3 ? .96 : .9), y = CY + (p.y - CY) * (scenario === 3 ? .96 : .9);
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.closePath(); ctx.clip();
if (turnAnimation) {
const t = Math.min(1, turnAnimation.time / .45), ease = t * t * (3 - 2 * t);
ctx.translate(CX, CY); ctx.rotate(ease * turnAnimation.direction * TAU / directions(kind).length); ctx.translate(-CX, -CY);
}
const s = scale();
for (const cell of board.cells) {
const c = screen(center(kind, cell.x, cell.y));
if (kind === 'square') roundRect(c.x - s / 2 + .25, c.y - s / 2 + .25, s - .5, s - .5, 2, theme.board, theme.boardEdge);
else {
const points = Array.from({ length: 7 }, (_, i) => ({ x: c.x + Math.cos(i * TAU / 6) * (s / Math.sqrt(3) - .15), y: c.y + Math.sin(i * TAU / 6) * (s / Math.sqrt(3) - .15) }));
ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.fillStyle = theme.board; ctx.fill(); ctx.strokeStyle = theme.boardEdge; ctx.lineWidth = 1; ctx.stroke();
}
}
const tracks = [];
for (const cell of board.cells) cell.tracks.forEach((b, a) => {
if (a < b) tracks.push(curve(kind, cell, a, b).map(screen));
});
clipHexRoads();
// Draw each paired lane with its own outline. At crossings, the small overpass
// edge keeps the two connections visually separate rather than a four-way junction.
for (const points of tracks) {
poly(points, theme.roadEdge, 10); poly(points, theme.road, 8);
}
ctx.restore();
}
function drawPreview() {
if (turnAnimation) return;
const id = hover || selected, car = cars.find(c => c.id === id); if (!car) return;
const route = trace(board, car, cars);
const color = mode === 'batch' || route.status === 'exit' ? '#329378' : '#cc725d';
const points = route.points.map(screen);
ctx.save(); clipHexRoads();
poly(points, `${color}25`, 17); poly(points, color, 4);
ctx.restore();
if (route.status === 'exit' && kind !== 'hex') poly(departure(route).connector, '#32937880', 3, [5, 7]);
const p = points.at(-1); ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, TAU); ctx.fillStyle = color; ctx.fill();
if (route.blocker) {
const c = carCenter(cars.find(c => c.id === route.blocker)); ctx.beginPath(); ctx.arc(c.x, c.y, 21, 0, TAU); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke();
}
}
function drawActiveRoutes() {
if (mode !== 'batch' || turnAnimation) return;
for (const actor of traffic) {
if (actor.stage !== 'board' || actor.distance >= actor.boardLength) continue;
const color = '#329378';
const points = actor.path.points.filter((p, i) => actor.path.lengths[i] <= actor.boardLength);
ctx.save(); clipHexRoads();
// Match the hover guide: a soft halo and one solid line, without a moving tail.
poly(points, `${color}25`, 17); poly(points, color, 4);
const end = points.at(-1);
ctx.beginPath(); ctx.arc(end.x, end.y, 5, 0, TAU); ctx.fillStyle = color; ctx.fill();
ctx.restore();
}
}
function drawTerminal() {
const end = finishPath.points.at(-1), x = end.x - 33, y = end.y - 32;
roundRect(x, y, 66, 49, 9, '#4787ED');
roundRect(x + 8, y + 19, 50, 30, 4, '#2456a7');
for (let i = 0; i < 6; i++) for (let j = 0; j < 2; j++) roundRect(x + 9 + i * 8, y + 19 + j * 7, 8, 7, 0, (i + j) % 2 ? '#f9ffff' : '#3273d1');
text('终 点', end.x, y - 9, 16, '#4787ED');
text('GARAGE', end.x, y + 13, 8, '#ffffff');
}
function draw(now) {
ctx.clearRect(0, 0, W, H);
if (turnAnimation) { drawRamps(); drawRing(); drawBoard(); }
else {
if (!backdrop) {
backdrop = document.createElement('canvas'); backdrop.width = W; backdrop.height = H;
const main = ctx; ctx = backdrop.getContext('2d');
drawRamps(); drawRing(); drawBoard(); ctx = main;
}
ctx.drawImage(backdrop, 0, 0);
}
drawPreview();
drawActiveRoutes();
for (const car of cars) {
const c = carCenter(car), angle = -Math.PI / 2 + car.dir * TAU / directions(kind).length;
drawCar(car, c.x, c.y, angle, car.id === selected || car.id === hover);
const pending = pendingDepartures.find(t => t.car.id === car.id);
if (pending) {
const countdown = Math.max(0, (pending.start - batchClock) / speed).toFixed(1);
roundRect(c.x - 20, c.y - 37, 40, 17, 6, '#F9FFFFe8');
text(`${countdown}s`, c.x, c.y - 25, 11, '#4882B3');
}
}
for (const t of traffic) {
const p = t.pose || (t.stage === 'ring' ? ring(t.angle)
: at(t.stage === 'finish' ? finishPath : t.path, t.distance));
drawCar(t.car, p.x, p.y, p.angle);
if (t.stopped) text(t.retreating ? '↶' : 'Ⅱ', p.x, p.y - 29, 14, '#b96549');
}
drawTerminal();
}
function tickFree(dt) {
const reversed = resolveGridlock(traffic, cars.map(carCenter), t => t.stage === 'ring' ? ring(t.angle) : at(t.stage === 'finish' ? finishPath : t.path, t.distance), scale() * .72 + 1, dt * 140 * speed);
const lane = traffic.filter(t => t.stage === 'ring' || t.stage === 'joining');
for (const t of traffic) {
if (t.stage === 'board') {
if (reversed.has(t)) continue;
const obstacles = cars.map(carCenter);
// Parked cars occupy their bodies, never roads they might use later.
const corridors = activeCorridors(traffic, t);
for (const other of traffic) {
if (other === t || other.stage === 'done') continue;
obstacles.push(other.stage === 'ring' ? ring(other.angle) : at(other.stage === 'finish' ? finishPath : other.path, other.distance));
}
const target = Math.min(t.path.length - 34, t.distance + dt * 240 * speed);
const next = advance(t.path, t.distance, target, obstacles, corridors, scale() * .72 + 1);
t.stopped = next < target - .1;
t.blockedFor = t.stopped ? (t.blockedFor || 0) + dt : 0;
t.distance = next;
if (t.distance >= t.path.length - 34 - .01) { t.stage = 'waiting'; t.stopped = false; }
} else if (t.stage === 'waiting') {
const clear = lane.every(o => Math.min(mod(o.angle - t.entry), mod(t.entry - o.angle)) * perimeter.unit > 98);
if (clear) { t.stage = 'joining'; t.angle = t.entry; lane.push(t); }
} else if (t.stage === 'joining') {
t.distance += dt * 180 * speed;
if (t.distance >= t.path.length) { t.stage = 'ring'; t.angle = t.entry; t.remaining = mod(FINISH - t.entry) * perimeter.unit; }
} else if (t.stage === 'ring') {
let advance = dt * 180 * speed;
for (const other of lane) if (other !== t) advance = Math.min(advance, Math.max(0, mod(other.angle - t.angle) * perimeter.unit - 80));
advance = Math.min(advance, t.remaining); t.angle += advance / perimeter.unit; t.remaining -= advance;
if (t.remaining <= .01) { t.stage = 'finish'; t.distance = 0; }
} else if (t.stage === 'finish') {
t.distance += dt * 180 * speed;
if (t.distance >= finishPath.length) { t.stage = 'done'; delivered++; }
}
}
}
function tick(dt, now) {
const before = traffic.filter(t => t.stage === 'board').reduce((sum,t) => sum+t.distance,0);
if (turnAnimation) {
turnAnimation.time += dt;
if (turnAnimation.time >= .45) {
board = rotate(board, turnAnimation.direction); backdrop = null; cacheRoutes(); turnAnimation = null;
const ids = resumeAfterTurn; resumeAfterTurn = []; ids.forEach(fire);
if (mode === 'batch') batchAvailable = drainOrientation(board, cars).departed;
}
}
if (mode === 'batch') tickBatch(dt); else tickFree(dt);
const progressed = traffic.filter(t => t.stage === 'board').reduce((sum,t) => sum+t.distance,0) > before + .1;
idleFor = progressed ? 0 : idleFor + dt;
traffic = traffic.filter(t => t.stage !== 'done');
if (delivered === total && traffic.length === 0) { say('全部抵达!'); $('win-count').textContent = mode === 'batch' ? `${total} 辆全部抵达 · 转动 ${turnCount} 次 / 目标 ${turnGoal} 次` : `${total} 辆小车,全部到齐`; $('win').hidden = false; auto = false; }
// Recover a pre-existing manual pile-up even after every car has been clicked.
// Re-seat only internal vehicles; delivered cars and perimeter traffic continue.
if (mode === 'free' && auto && !turnAnimation && busy() && idleFor > 3) {
const returning = traffic.filter(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27);
if (returning.length) {
cars.push(...returning.map(t => t.car));
traffic = traffic.filter(t => !returning.includes(t));
idleFor = 0; listCars();
say('检测到盘内拥堵,车辆归位后按可通行顺序继续演示。');
}
}
if (mode === 'batch' && auto && !busy()) auto = false;
// Free-play demonstration keeps its automatic rotation and sequential dispatch.
if (mode === 'free' && auto && !turnAnimation && !busy() && now - lastAuto > 220) {
const next = suggestion(board, cars); lastAuto = now;
if (!next) { if (!traffic.length) auto = false; }
else if (next.turn) turn(Math.sign(next.turn));
else fire(next.id);
}
ui();
}
canvas.addEventListener('pointermove', e => {
if (!$('preview').checked || e.pointerType === 'touch') return;
const rect = canvas.getBoundingClientRect(), x = (e.clientX - rect.left) * W / rect.width, y = (e.clientY - rect.top) * H / rect.height;
hover = cars.find(car => { const c = carCenter(car); return Math.hypot(x - c.x, y - c.y) < scale() * .39; })?.id || null;
canvas.style.cursor = hover ? 'pointer' : 'default';
});
canvas.addEventListener('pointerleave', () => { hover = null; });
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect(), x = (e.clientX - rect.left) * W / rect.width, y = (e.clientY - rect.top) * H / rect.height;
const closest = cars.map(car => ({ car, distance: Math.hypot(x-carCenter(car).x,y-carCenter(car).y) })).sort((a,b) => a.distance-b.distance)[0];
const car = closest && closest.distance < Math.max(scale()*.42, 22*W/rect.width) ? closest.car : null;
if (car) { if (mode === 'batch') { selected = car.id; say('已标出这辆车的路线;点击中间按钮统一发车。'); } else fire(car.id); }
});
$('left').onclick = () => turn(-1); $('right').onclick = () => turn(1); $('hint').onclick = () => mode === 'batch' ? startBatch() : hint();
$('reset').onclick = $('restart').onclick = $('play-again').onclick = reset; $('new').onclick = () => { if (mode === 'batch') scenario = (scenario + 1) % batchTitles.length; else variation++; reset(); };
$('mode').onchange = e => { mode = e.target.value; scenario = mode === 'batch' ? 0 : 1; variation = 0; reset(); };
$('scenario').onchange = e => { scenario = Number(e.target.value); variation = 0; reset(); };
$('speed').oninput = e => { speed = Number(e.target.value); $('speed-label').textContent = `${speed}×`; };
$('preview').onchange = () => { hover = null; };
$('auto').onclick = () => { auto = !auto && (cars.length > 0 || traffic.length > 0); ui(); };
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => { kind = b.dataset.kind; reset(); });
reset();
requestAnimationFrame(function frame(now) { const dt = Math.min(.05, (now - last) / 1000); last = now; tick(dt, now); draw(now); requestAnimationFrame(frame); });