feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Car Loop Lab
|
||||
|
||||
This is an isolated gameplay experiment. Keep its experimental grid model, geometry,
|
||||
rendering and controls inside this directory; do not change the production hex rules.
|
||||
Use dependency-free ES modules. Node tests belong in tests/car-loop.test.js.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 环路车场 / Car Loop Lab
|
||||
|
||||
运行根目录 `npm run dev`,访问 <http://localhost:4173/labs/car-loop/index.html>。
|
||||
|
||||
**研发状态(2026-09-10):转盘发车玩法暂缓。** 保留当前 Demo 和测试。研发演进、试玩反馈、已验证内容及尚未实施的优化方向见 [研发记录与阶段结论](../../docs/car-loop-research.md)。当前阶段判断:通行与表现已改善,但策略乐趣尚未成立。
|
||||
|
||||
## 操作
|
||||
|
||||
- **点击即刻出发**,不加入发车队列,也不等上一辆离盘。多辆车同时行驶。
|
||||
- 未点击的车停在原位;已出发车辆会在前车或交叉冲突前刹停,通行空间清空后,从停下的位置自动继续。
|
||||
- 路线冲突按让路依赖确定通行优先级,保护尚未通过的交叉区域;不允许高速穿车。
|
||||
- 六角 37 格 / 方格 7×7 切换,各有 10、16、22 辆车样本。方格选图额外筛选实际长绕行,默认 #107 的参考解平均 9.4 弯、最长 20 弯。
|
||||
- 方格每次旋转 90°,六角 60°。盘内行驶车辆在换路前归位,转好后自动重新出发;已离盘车辆继续在固定环路上行驶。闭环路线提示换路,不自动选方向。
|
||||
- 环路贴合棋盘轮廓:方形配方形,六角配六角,小圆角平滑过弯。离盘经短引道顺时针汇流,进入右上角终点。
|
||||
- 盘内速度 480 画布像素/秒,环路及并线 360 像素/秒;碰撞细分最大 1.5 像素,速度提高不减少检测精度。
|
||||
- 悬停可查看路径;路线提示和自动演示用于验证;车辆列表支持键盘发车。
|
||||
|
||||
## 设计
|
||||
|
||||
采用只读参考 `../hex-video/designv2.md` 的视觉方向:近白环境、冷灰底板及 HUD、七色车辆和白箭头、红橙旋转器、蓝色提示器、绿色主操作。到达后切换蓝青奖励背景、金黄完成标记和再玩按钮。
|
||||
|
||||
车辆绘制使用 `theme.js` 的独立色族 face / side / light,保留一条薄侧边,不使用叠片或实体拖尾。道路、车辆、即时交通及终点都是本 Lab 的玩法适配,并非参考视频的原始规则。
|
||||
|
||||
## 结构与验证
|
||||
|
||||
- `model.js`:网格、配对轨道、连续曲线、静态碰撞、关卡生成与求解。
|
||||
- `motion.js`:轨迹采样、实时碰撞停车、交叉通行优先级。
|
||||
- `perimeter.js`:顺时针方形/六边形环路、距离采样及出盘射线交点。
|
||||
- `app.js`:并行车辆运行、并线、操作与 Canvas 表现。
|
||||
|
||||
测试:`node --test tests/car-loop.test.js`,完整回归:`npm test`。`npm run build` 会打包本实验及设计参考 HTML。
|
||||
|
||||
车辆采用半径 0.36 格的统一净空,未模拟不同车长与真实转弯半径。路口保护采用保守的路线通行权,允许车辆提前停在交叉冲突区外;不将路线交叉画成可穿过其他车辆的立交。换路时归位是实验规则,避免在正在变化的路面上保留失效轨迹。
|
||||
|
||||
独立实验不修改主游戏规则、主线关卡或存档。
|
||||
|
||||
### 超大桌面关卡
|
||||
|
||||
打开 `index.html?size=xl&kind=hex`(169 格)或 `index.html?size=xl&kind=square`(15×15 格)。各有 96 辆车,保留即时发车、路口等候和道路旋转。棋盘自动适配窗口剩余空间,顶部状态与底部操作浮于画布角落,页面和棋盘均无滚动条。预生成样本位于 `large-levels.js`,来自 `generate(kind, 107, 96, 7)`;“换个朝向”切换道路的初始方向。静态道路使用离屏缓存。
|
||||
|
||||
### 转盘发车 Demo
|
||||
|
||||
`index.html?mode=batch&kind=hex` 直接进入新玩法,也可在页面“玩法”中切换回自由点车。
|
||||
|
||||
- 底部为左转 / 发车 / 右转。每轮一次安排当前朝向下可放行的全部车辆,互不冲突的车并行出发,有冲突的车在原位倒计时后出发,途中连续通行,系统不会转盘。
|
||||
- `schedule.js` 为实际盘内曲线、汇入口、外环和车库预订通行时段,通过连续线段的相对运动计算车距。调度包含上一轮仍在外环的车辆;统一模拟时钟保证 1–3× 调速后时段和间隔不变。待发车辆仍计入“待出发”,状态单列待发数量。
|
||||
- 当前无车可驶出时停下并提示玩家转盘。转盘本身不触发发车;已进入外环的车继续前往终点。
|
||||
- 采用原来的绿色悬停提示样式统一显示盘内路线,驶出棋盘立即收起,外环不绘制尾迹。状态明确区分可发车、放行中、需转盘和等待到站。
|
||||
- 每种棋盘有 3 个、各 16 辆的测试关卡。`batch.js` 按“单次左/右转 + 当前朝向放行”做广度优先搜索,得出目标次数。所有关卡的第一次左转与右转的最少总次数不同,第二关需要折返转向。目标只作评价,不限制尝试。
|
||||
- 真实程序帧循环测试覆盖卡点停止、禁止自动转盘、转后等待再次发车及全部到站。
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,39 @@
|
||||
import { rotate, trace, directions } from './model.js';
|
||||
|
||||
// A run only removes cars reachable in the current orientation. It never turns.
|
||||
export function currentDeparture(board, cars) {
|
||||
return cars.find(car => trace(board, car, cars).status === 'exit') || null;
|
||||
}
|
||||
export function drainOrientation(board, cars = board.cars) {
|
||||
let remaining = [...cars];
|
||||
const departed = [];
|
||||
for (let car; (car = currentDeparture(board, remaining));) {
|
||||
departed.push(car.id); remaining = remaining.filter(c => c.id !== car.id);
|
||||
}
|
||||
return { remaining, departed };
|
||||
}
|
||||
|
||||
// Every edge is one player turn followed by a run. Search both directions to
|
||||
// measure actual player effort, rather than counting rotations in a car solver.
|
||||
export function rotationPlan(board, cars = board.cars) {
|
||||
const n = directions(board.kind).length;
|
||||
const initial = drainOrientation(board, cars);
|
||||
const queue = [{ orientation: 0, remaining: initial.remaining, turns: [], waves: [initial.departed.length] }];
|
||||
const visited = new Set();
|
||||
for (let head = 0; head < queue.length; head++) {
|
||||
const state = queue[head];
|
||||
if (!state.remaining.length) return state;
|
||||
for (const turn of [-1, 1]) {
|
||||
const orientation = (state.orientation + turn + n) % n;
|
||||
const next = drainOrientation(rotate(board, orientation), state.remaining);
|
||||
const key = `${orientation}:${next.remaining.map(c => c.id).sort().join(',')}`;
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
queue.push({ orientation, remaining: next.remaining, turns: [...state.turns, turn], waves: [...state.waves, next.departed.length] });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const batchTitles = ['转向开路', '折返放行', '连续调度'];
|
||||
export const batchSeeds = { hex: [3, 14, 8], square: [6, 12, 19] };
|
||||
@@ -0,0 +1,15 @@
|
||||
# Car Loop · Video Lab v2 适配
|
||||
|
||||
本次采用只读参考 `../hex-video/designv2.md`(2026-09-09),将其视觉体系用于现有环路车场。没有修改参考 Lab。
|
||||
|
||||
- 解谜:背景 `#F2F2F4`,顶部灰色过渡;底格 `#A6AAB9`,HUD `#B2B4B8`,计数白底 `#F7F9FD` / 深灰数字 `#62635E`。
|
||||
- 车辆:`theme.js` 定义七个明确色族,面色、薄侧边和窄高光分开;方向独立于颜色,固定粗白箭头,暗边增强浅色车身上的可读性。
|
||||
- 道路:浅冷灰路线与较暗边缘;固定成对通行,交叉显示连续线,不增加可选岔路。道路配色属于此 Lab 适配,并非视频取样对象。
|
||||
- 控件:圆形薄倒角底座、独立小标签;红橙旋转轮、浅蓝提示灯;侧栏绿色切角演示按钮、车速滑杆。
|
||||
- 完成:蓝青背景与浅青放射光,金黄完成标记和再玩按钮。展示真实到达车数,不伪造金币奖励。
|
||||
- 布局:桌面左侧实验控件、右侧竖向游戏预览;窄屏将棋盘类型、关卡、车速和演示收拢在棋盘上方。游戏内 HUD 与实际状态对应。
|
||||
- 操作:点击马上沿路线行驶,接近前车和冲突区域时等待,不在起点排发车队列。停等用小暂停标记表示,不给整车叠大面积光晕。
|
||||
- 速度:默认 2×,盘内 480 / 环路 360 画布像素每秒;滑杆 1–3×。细分检测步长不随车速放大。
|
||||
- 减少动态效果:CSS 关闭领奖光束旋转;车辆路线运动保留以解释碰撞与方向。
|
||||
|
||||
- 六角外缘:不绘制伸向环路的引道线,轨道笔画裁剪到实际底格边界,避免圆端点伸出;路线预览也不画外接虚线。车辆并线轨迹保持连续。
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>环路车场 · HEX TRAILS Lab</title><link rel="stylesheet" href="./style.css"></head>
|
||||
<body>
|
||||
<header class="lab-header"><a href="/">HEX TRAILS <span>/ CAR LAB</span></a><a href="../hex-video/designv2.html">Video Lab Design v2 ↗</a></header>
|
||||
<main class="workspace">
|
||||
<aside class="inspector">
|
||||
<p class="eyebrow">PLAY EXPERIMENT / 02</p><h1>环路车场</h1><p class="intro">点一下,即刻出发。<br>解开弯路,让小车一路开到终点。</p>
|
||||
<label class="section-title" for="mode">玩法</label><select id="mode" aria-label="玩法"><option value="free">自由点车</option><option value="batch">转盘发车 · 新玩法</option></select>
|
||||
<p class="section-title">棋盘形状</p>
|
||||
<div class="switch" aria-label="棋盘类型"><button data-kind="square" class="active" aria-pressed="true"><span class="shape square"></span>方格路网</button><button data-kind="hex" aria-pressed="false"><span class="shape hex"></span>六角路网</button></div>
|
||||
<div class="settings-body">
|
||||
<label class="section-title" for="scenario">关卡样本</label><select id="scenario"><option value="0">01 · 连弯初见 / 10 辆</option><option value="1" selected>02 · 交织长路 / 16 辆</option><option value="2">03 · 满盘绕行 / 22 辆</option><option value="3">04 · 超大桌面 / 96 辆</option></select>
|
||||
<div class="actions"><button id="new">换张路网</button><button id="restart">重新开始</button></div>
|
||||
<button class="primary" id="auto" aria-pressed="false">▶ 自动演示</button>
|
||||
<label class="speed-control" for="speed"><span>行驶速度 <output id="speed-label">2×</output></span><input id="speed" type="range" min="1" max="3" step="0.5" value="2" aria-label="行驶速度倍率"></label>
|
||||
<label class="check"><input type="checkbox" id="preview" checked> 悬停查看完整路线</label>
|
||||
<div id="batch-help" hidden><strong>转盘 → 发车 → 再转盘</strong><p>点击发车,本轮车辆自动错峰,互不冲突的车一起走。倒计时结束后出发,途中连续通行。</p><p>绿色提示线标出盘内路线,驶入外环后收起。看到“需转盘”时,由你决定转向。</p></div>
|
||||
<details class="guide"><summary>怎么玩</summary><p>点车立即出发,多辆车可以同时行驶。遇到前车或交叉冲突时会停下,路空后自行继续。</p><p>弯道自动转向,交叉处不换线。转动底盘时,盘内行驶车辆先归位,换路后再出发。</p><p>车从边缘汇入同形环路,顺时针开到右上角终点。闭环路线需要先换路。</p></details>
|
||||
<details class="car-access"><summary>车辆操作列表</summary><p>也可以用键盘选择车辆,立即发车。</p><div id="car-list"></div></details>
|
||||
<div class="footnote"><span class="live-dot"></span> 独立玩法实验 · 本地运行<p id="stats"></p></div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="preview-area" aria-label="环路车场游戏">
|
||||
<div class="preview-label"><span>PLAY VIEW</span><span id="board-label">7 × 7 · 四向道路</span></div>
|
||||
<div class="stage">
|
||||
<div class="ice" aria-hidden="true"></div>
|
||||
<div class="game-hud">
|
||||
<div class="level-progress"><span id="level-label">关卡 2</span><div aria-hidden="true"><b>1</b><i></i><b class="current">2</b><i></i><b>3</b><i></i><b>4</b></div></div>
|
||||
<div class="remaining"><span>待出发</span><strong id="remaining">16</strong></div>
|
||||
<button class="reset-icon" id="reset" aria-label="重新开始本关"><svg viewBox="0 0 32 32" aria-hidden="true"><path d="M25 13A10 10 0 1 0 26 20M25 5v8h-8" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||
</div>
|
||||
<div class="trip-hud"><span><i class="travel-dot"></i>行驶中 <strong id="transit">0</strong></span><span>已抵达 <strong id="arrived">0</strong> <span class="arrival-mark">✓</span></span></div>
|
||||
<div class="batch-hud" id="batch-hud" hidden><span id="turn-count">已转 0 次</span><span id="turn-goal"></span><span id="batch-result">点击发车开始</span></div>
|
||||
<div class="meter" aria-hidden="true"><i id="meter"></i></div>
|
||||
<div class="board-scroll"><canvas id="game" width="768" height="740" aria-label="交互车场,点击车辆立即发车。也可用车辆操作列表。"></canvas></div>
|
||||
<div class="status" id="status" role="status" aria-live="polite">点击出发,遇车等候,路空继续。</div>
|
||||
<div class="controls">
|
||||
<button id="left" aria-label="逆时针旋转内部道路"><svg viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="24" fill="#747B87"/><circle cx="30" cy="29" r="18" fill="#F56B37" stroke="#BC451E" stroke-width="3"/><path d="M20 21h10a10 10 0 1 1-10 10M25 16l-5 5 5 5" fill="none" stroke="#FFF1D5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg><small>左转底盘</small></button>
|
||||
<button id="hint" aria-label="查看路线提示"><svg id="hint-icon" viewBox="0 0 60 60" aria-hidden="true"><path d="M21 38c0-6-7-8-7-17a16 16 0 0 1 32 0c0 9-7 11-7 17" fill="#64BDF8" stroke="#438BD0" stroke-width="3"/><path d="M23 39h14v6H23zM26 49h8" fill="#B0BDCD" stroke="#738498" stroke-width="3" stroke-linecap="round"/><path d="M25 29l5 6 5-6" fill="none" stroke="white" stroke-width="3" stroke-linecap="round"/></svg><svg id="run-icon" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="23" fill="#378EE9" stroke="#2468B0" stroke-width="3"/><path id="run-symbol" d="M24 17L43 30 24 43Z" fill="#fff"/></svg><small id="hint-label">路线提示</small></button>
|
||||
<button id="right" aria-label="顺时针旋转内部道路"><svg viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="24" fill="#747B87"/><circle cx="30" cy="29" r="18" fill="#F56B37" stroke="#BC451E" stroke-width="3"/><path d="M40 21H30a10 10 0 1 0 10 10M35 16l5 5-5 5" fill="none" stroke="#FFF1D5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg><small>右转底盘</small></button>
|
||||
</div>
|
||||
<section class="win" id="win" hidden aria-label="全部车辆已抵达">
|
||||
<div class="sunburst" aria-hidden="true"></div><h2>全部抵达!</h2><p>弯弯绕绕,顺利到站</p><div class="win-medal" aria-hidden="true">✓</div><strong id="win-count">16 辆小车,全部到齐</strong><button id="play-again" class="gold-button">再玩一局</button>
|
||||
</section>
|
||||
</div>
|
||||
<div class="preview-footer"><span>点击即走 · 碰撞等候 · 顺时针归途</span><span>DESIGN V2</span></div>
|
||||
</section>
|
||||
</main><script type="module" src="./app.js"></script>
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Shared distance-based trajectories and local traffic clearance, in canvas pixels.
|
||||
export function measure(points) {
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
|
||||
return { points, lengths, length: lengths.at(-1) };
|
||||
}
|
||||
export function at(path, distance) {
|
||||
const d = Math.max(0, Math.min(path.length, distance));
|
||||
let i = path.lengths.findIndex((v, index) => index && v >= d);
|
||||
if (i < 1) i = path.points.length - 1;
|
||||
const a = path.points[i - 1], b = path.points[i], len = path.lengths[i] - path.lengths[i - 1];
|
||||
const t = len ? (d - path.lengths[i - 1]) / len : 0;
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, angle: Math.atan2(b.y - a.y, b.x - a.x) };
|
||||
}
|
||||
export function pointSegmentDistance(p, a, b) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
|
||||
const t = length ? Math.max(0, Math.min(1, ((p.x-a.x)*dx+(p.y-a.y)*dy)/length)) : 0;
|
||||
return Math.hypot(p.x-a.x-dx*t,p.y-a.y-dy*t);
|
||||
}
|
||||
export function hasPriority(a, b) {
|
||||
if (a.yieldingTo === b) return false;
|
||||
if (b.yieldingTo === a) return true;
|
||||
return a.order < b.order;
|
||||
}
|
||||
// A car reserves road conflicts only after departure. Parked cars are physical
|
||||
// obstacles, not reservations for every road they may eventually travel through.
|
||||
export function activeCorridors(traffic, actor) {
|
||||
return traffic.filter(other => other !== actor
|
||||
&& ['board', 'waiting', 'joining'].includes(other.stage)
|
||||
&& other.distance < other.boardLength && hasPriority(other, actor));
|
||||
}
|
||||
// Only the untraveled part of a priority vehicle's route reserves a conflict area.
|
||||
// Other cars travel up to that area, wait there, and resume as it clears.
|
||||
export function advance(path, current, target, obstacles, corridors, clearance) {
|
||||
let distance = current;
|
||||
while (distance < target - 1e-6) {
|
||||
const next = Math.min(target, distance + 1.5), p = at(path, next);
|
||||
if (obstacles.some(o => Math.hypot(p.x-o.x,p.y-o.y) < clearance)) break;
|
||||
let conflict = false;
|
||||
for (const corridor of corridors) {
|
||||
let a = at(corridor.path, corridor.distance);
|
||||
for (let i = 1; i < corridor.path.points.length; i++) {
|
||||
if (corridor.path.lengths[i] <= corridor.distance) continue;
|
||||
if (corridor.path.lengths[i-1] > corridor.boardLength + clearance) break;
|
||||
const b = corridor.path.points[i];
|
||||
if (pointSegmentDistance(p,a,b) < clearance) { conflict = true; break; }
|
||||
a = b;
|
||||
}
|
||||
if (conflict) break;
|
||||
}
|
||||
if (conflict) break;
|
||||
distance = next;
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
// Stable passage priority respects the parked-car dependency graph, avoiding
|
||||
// circular reservations when several intersecting cars are clicked together.
|
||||
export function passageOrder(board, cars, trace) {
|
||||
let remaining = [...cars], rank = 0;
|
||||
const order = new Map();
|
||||
while (remaining.length) {
|
||||
const free = remaining.filter(car => trace(board, car, remaining).status === 'exit');
|
||||
if (!free.length) { remaining.forEach(car => order.set(car.id, rank++)); break; }
|
||||
const ids = new Set(free.map(car => car.id));
|
||||
free.forEach(car => order.set(car.id, rank++));
|
||||
remaining = remaining.filter(car => !ids.has(car.id));
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// Back out of a mutual standstill, using the same swept body clearance as forward travel.
|
||||
// The yielding car keeps its priority override until the other has left the board.
|
||||
export function resolveGridlock(traffic, fixedObstacles, positionOf, clearance, step) {
|
||||
const reversed = new Set();
|
||||
const internal = traffic.filter(t => t.stage === 'board');
|
||||
for (const actor of internal) {
|
||||
if (actor.yieldingTo && !internal.includes(actor.yieldingTo)) {
|
||||
actor.yieldingTo = null; actor.retreating = false;
|
||||
}
|
||||
}
|
||||
const retreat = actor => {
|
||||
const obstacles = [...fixedObstacles, ...traffic.filter(o => o !== actor && o.stage !== 'done').map(positionOf)];
|
||||
let distance = actor.distance;
|
||||
const target = Math.max(0, distance - step);
|
||||
while (distance > target + 1e-6) {
|
||||
const next = Math.max(target, distance - 1.5), p = at(actor.path, next);
|
||||
if (obstacles.some(o => Math.hypot(p.x - o.x, p.y - o.y) < clearance)) break;
|
||||
distance = next;
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
for (let i = 0; i < internal.length; i++) for (let j = i + 1; j < internal.length; j++) {
|
||||
const a = internal[i], b = internal[j];
|
||||
if (a.yieldingTo || b.yieldingTo || !a.stopped || !b.stopped || Math.min(a.blockedFor || 0, b.blockedFor || 0) < .5) continue;
|
||||
const p = positionOf(a), q = positionOf(b);
|
||||
if (Math.hypot(p.x - q.x, p.y - q.y) > clearance * 1.6) continue;
|
||||
const candidates = hasPriority(a, b) ? [b, a] : [a, b];
|
||||
const loser = candidates.find(t => retreat(t) < t.distance - .01);
|
||||
if (loser) { loser.yieldingTo = loser === a ? b : a; loser.retreating = true; }
|
||||
}
|
||||
for (const actor of internal) {
|
||||
if (!actor.retreating || !actor.yieldingTo) continue;
|
||||
// A short probe tests whether the whole body has cleared the winning route.
|
||||
const p = positionOf(actor), probe = measure([p, { x: p.x + .01, y: p.y }]);
|
||||
if (advance(probe, 0, probe.length, [], [actor.yieldingTo], clearance + 3) >= probe.length) {
|
||||
actor.retreating = false;
|
||||
continue;
|
||||
}
|
||||
const next = retreat(actor);
|
||||
actor.distance = next; actor.stopped = true; reversed.add(actor);
|
||||
if (next <= 0) actor.retreating = false;
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Clockwise, rounded polygon lane; normalized parameters measure distance, not polar angle.
|
||||
const TAU = Math.PI * 2;
|
||||
export function createPerimeter(kind, cx, cy, size, radius = 3) {
|
||||
const vertices = kind === 'square'
|
||||
? [[1, -1], [1, 1], [-1, 1], [-1, -1]].map(([x, y]) => ({ x: cx + x * ((radius + .5) * size + 32), y: cy + y * ((radius + .5) * size + 32) }))
|
||||
: Array.from({ length: 6 }, (_, i) => { const a = -Math.PI / 6 + i * TAU / 6; return { x: cx + Math.cos(a) * size * (radius + 1.12), y: cy + Math.sin(a) * size * (radius + 1.12) }; });
|
||||
const points = [], inset = 15;
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const p = vertices[i], before = vertices[(i + vertices.length - 1) % vertices.length], after = vertices[(i + 1) % vertices.length];
|
||||
const toward = q => { const d = Math.hypot(q.x - p.x, q.y - p.y); return { x: p.x + (q.x - p.x) * inset / d, y: p.y + (q.y - p.y) * inset / d }; };
|
||||
const a = toward(before), b = toward(after);
|
||||
for (let j = 0; j <= 16; j++) { const t = j / 16, u = 1 - t; points.push({ x: u * u * a.x + 2 * u * t * p.x + t * t * b.x, y: u * u * a.y + 2 * u * t * p.y + t * t * b.y }); }
|
||||
}
|
||||
points.push(points[0]);
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
|
||||
const length = lengths.at(-1), unit = length / TAU;
|
||||
function at(parameter) {
|
||||
const distance = ((parameter % TAU + TAU) % TAU) * unit;
|
||||
let i = lengths.findIndex((v, j) => j > 0 && v >= distance); if (i < 1) i = 1;
|
||||
const a = points[i - 1], b = points[i], t = (distance - lengths[i - 1]) / (lengths[i] - lengths[i - 1]);
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, angle: Math.atan2(b.y - a.y, b.x - a.x) };
|
||||
}
|
||||
function intersect(origin, direction) {
|
||||
let nearest = null;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1], b = points[i], sx = b.x - a.x, sy = b.y - a.y;
|
||||
const cross = direction.x * sy - direction.y * sx; if (Math.abs(cross) < 1e-9) continue;
|
||||
const dx = a.x - origin.x, dy = a.y - origin.y;
|
||||
const ray = (dx * sy - dy * sx) / cross, t = (dx * direction.y - dy * direction.x) / cross;
|
||||
if (ray >= 0 && t >= 0 && t <= 1 && (!nearest || ray < nearest.distance)) nearest = { distance: ray, parameter: (lengths[i - 1] + t * (lengths[i] - lengths[i - 1])) / unit };
|
||||
}
|
||||
if (!nearest) throw new Error('Exit ray must intersect perimeter');
|
||||
return nearest;
|
||||
}
|
||||
return { points, length, unit, at, intersect };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { port } from './model.js';
|
||||
import { measure } from './motion.js';
|
||||
|
||||
function bezier(a, b, c, d) {
|
||||
return Array.from({ length: 37 }, (_, i) => {
|
||||
const t = i / 36, u = 1 - t;
|
||||
return { x: u ** 3 * a.x + 3 * u * u * t * b.x + 3 * u * t * t * c.x + t ** 3 * d.x,
|
||||
y: u ** 3 * a.y + 3 * u * u * t * b.y + 3 * u * t * t * c.y + t ** 3 * d.y };
|
||||
});
|
||||
}
|
||||
export function departure(route, kind, screen, perimeter) {
|
||||
const points = route.points.map(screen), end = points.at(-1), dir = port(kind, route.exitDir);
|
||||
const direction = { x: dir.x * 2, y: dir.y * 2 };
|
||||
const hit = perimeter.intersect(end, direction);
|
||||
const a = hit.parameter + 26 / perimeter.unit;
|
||||
const entry = perimeter.at(a), tangent = { x: Math.cos(entry.angle), y: Math.sin(entry.angle) };
|
||||
const join = bezier(end, { x: end.x + direction.x * hit.distance * .65, y: end.y + direction.y * hit.distance * .65 },
|
||||
{ x: entry.x - tangent.x * 18, y: entry.y - tangent.y * 18 }, entry);
|
||||
return { path: measure([...points, ...join.slice(1)]), boardLength: measure(points).length, entry: a, connector: join };
|
||||
}
|
||||
|
||||
export function garagePath(perimeter, width) {
|
||||
const finish = perimeter.at(0);
|
||||
return measure(bezier(finish,
|
||||
{ x: finish.x + Math.cos(finish.angle) * 22, y: finish.y + Math.sin(finish.angle) * 22 },
|
||||
{ x: width - 76, y: finish.y - 8 }, { x: width - 62, y: finish.y - 36 }));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { measure, at, pointSegmentDistance } from './motion.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const mod = value => (value % TAU + TAU) % TAU;
|
||||
|
||||
// One continuous trip includes the merge, perimeter and garage. Booking only
|
||||
// the internal road would move the same queue to the perimeter entrance.
|
||||
export function journey(departure, perimeter, finishPath) {
|
||||
const lane = measure(perimeter.points);
|
||||
const ringLength = mod(-departure.entry) * perimeter.unit;
|
||||
const entryDistance = mod(departure.entry) * perimeter.unit;
|
||||
const lanePoints = lane.points.slice(0, -1).map((point, i) => ({
|
||||
point, ahead: mod((lane.lengths[i] - entryDistance) / perimeter.unit) * perimeter.unit,
|
||||
})).filter(p => p.ahead > 1e-6 && p.ahead < ringLength - 1e-6).sort((a, b) => a.ahead - b.ahead);
|
||||
const points = [...departure.path.points, ...lanePoints.map(p => p.point), ...finishPath.points];
|
||||
// Remove coincident track endpoints so every timed segment has a duration.
|
||||
const path = measure(points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 1e-7));
|
||||
const boardLength = departure.boardLength;
|
||||
const timeAt = d => Math.min(d, boardLength) / 240 + Math.max(0, d - boardLength) / 180;
|
||||
return { path, boardLength, ringStart: departure.path.length,
|
||||
finishStart: departure.path.length + ringLength,
|
||||
times: path.lengths.map(timeAt), duration: timeAt(path.length) };
|
||||
}
|
||||
|
||||
export function journeyPosition(trip, elapsed) {
|
||||
const boardTime = trip.boardLength / 240;
|
||||
const distance = Math.min(trip.path.length, Math.max(0, Math.min(elapsed, boardTime) * 240 + Math.max(0, elapsed - boardTime) * 180));
|
||||
return { ...at(trip.path, distance), distance };
|
||||
}
|
||||
|
||||
function cursor(actor, time) {
|
||||
const elapsed = time - actor.start, times = actor.journey.times;
|
||||
let lo = 0, hi = times.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (times[mid] <= elapsed + 1e-9) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
function position(actor, i, time) {
|
||||
const { path, times } = actor.journey;
|
||||
if (!i) return path.points[0];
|
||||
const a = path.points[i - 1], b = path.points[i];
|
||||
const t = (time - actor.start - times[i - 1]) / (times[i] - times[i - 1]);
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
// Exact closest approach on each overlapping pair of linear time segments.
|
||||
// This checks the swept interval, including between rendered frames at 3×.
|
||||
export function tripsConflict(a, b, now, clearance, parkedClearance = clearance) {
|
||||
const end = Math.min(a.start + a.journey.duration, b.start + b.journey.duration);
|
||||
let time = now, i = cursor(a, time), j = cursor(b, time);
|
||||
while (time < end - 1e-8) {
|
||||
const nextA = a.start + a.journey.times[i], nextB = b.start + b.journey.times[j];
|
||||
const next = Math.min(end, nextA, nextB);
|
||||
const p = position(a, i, time), q = position(b, j, time);
|
||||
const r = position(a, i, next), s = position(b, j, next);
|
||||
const gap = time < a.start - 1e-8 || time < b.start - 1e-8 ? parkedClearance : clearance;
|
||||
if (pointSegmentDistance({ x: 0, y: 0 }, { x: p.x - q.x, y: p.y - q.y }, { x: r.x - s.x, y: r.y - s.y }) < gap) return true;
|
||||
time = next;
|
||||
if (nextA <= next + 1e-8) i++;
|
||||
if (nextB <= next + 1e-8) j++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The caller supplies the parked-car dependency order. Each trip gets the first
|
||||
// free time slot; unrelated trips can share a start or overlap most of a trip.
|
||||
// All times use one simulation clock, so changing playback speed preserves gaps.
|
||||
export function scheduleDepartures(candidates, { now = 0, active = [], parked = [], clearance, parkedClearance = clearance }) {
|
||||
const planned = [], reservations = [...active];
|
||||
const released = new Set();
|
||||
for (const candidate of candidates) {
|
||||
released.add(candidate.car.id);
|
||||
const fixed = parked.filter(p => !released.has(p.id));
|
||||
const points = candidate.journey.path.points;
|
||||
if (fixed.some(p => points.some((b, i) => i && pointSegmentDistance(p, points[i - 1], b) < parkedClearance))) {
|
||||
throw new Error(`Departure order crosses a parked car: ${candidate.car.id}`);
|
||||
}
|
||||
const actor = { ...candidate, start: now };
|
||||
// After the last reserved arrival, a statically clear route is always free.
|
||||
const horizon = Math.max(now, ...reservations.map(r => r.start + r.journey.duration));
|
||||
while (actor.start < horizon && reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
|
||||
actor.start = Math.min(horizon, actor.start + .06);
|
||||
}
|
||||
if (reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
|
||||
throw new Error(`Existing traffic crosses a waiting car: ${candidate.car.id}`);
|
||||
}
|
||||
planned.push(actor); reservations.push(actor);
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
// Adapted from labs/hex-video/designv2.md; roads and vehicle geometry are Lab additions.
|
||||
export const carPaints = [
|
||||
{ face: '#4787ED', side: '#1E6AD5', light: '#80B6FF' },
|
||||
{ face: '#7BE637', side: '#66C617', light: '#ADF474' },
|
||||
{ face: '#F9BE02', side: '#D89600', light: '#FFE271' },
|
||||
{ face: '#C460FD', side: '#A538E9', light: '#DF9FFF' },
|
||||
{ face: '#E4413F', side: '#C81C1D', light: '#FF8883' },
|
||||
{ face: '#FE8A16', side: '#DB6008', light: '#FFB966' },
|
||||
{ face: '#00C6FE', side: '#009ED3', light: '#78E5FF' },
|
||||
];
|
||||
export const theme = {
|
||||
background: '#F2F2F4', board: '#D1D7DF', boardEdge: '#D1D7DF',
|
||||
road: '#EDF1F5', roadEdge: '#BDC7D2', roadMark: '#B5BDCC',
|
||||
ring: '#A3AFBE', ringEdge: '#D8DEE6', ringMark: '#EAF0F6',
|
||||
ink: '#62635E', blue: '#4787ED', arrow: '#F9FFFF',
|
||||
};
|
||||
Reference in New Issue
Block a user