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