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