feat(ticket-to-ride): Support curved routes with quadratic bezier curves
Introduce `ROUTE_CURVES` configuration and helper functions (`bezierPolyline`, `polylineLength`, `pointAtDist`) to sample and measure polylines. Update `routeSegments` and `routeMidpoint` to distribute cars and calculate midpoints along curved paths instead of straight lines, improving board visual fidelity and route alignment.
This commit is contained in:
parent
b6d85e30be
commit
a932cc5797
|
|
@ -180,18 +180,44 @@ const ROUTE_DEFS = [
|
|||
[25, 35, 6, 'red'], // New Orleans – Miami
|
||||
];
|
||||
|
||||
// Optional curve overrides for routes that should arc rather than run straight.
|
||||
// Key is 'minCityId-maxCityId'. dir is a cardinal screen direction; pct is the
|
||||
// control-point displacement as a fraction of the straight-line length.
|
||||
const ROUTE_CURVES = {
|
||||
'4-14': { dir: 'down', pct: 0.35 }, // Los Angeles – El Paso
|
||||
'5-6': { dir: 'up', pct: 0.25 }, // Calgary – Winnipeg
|
||||
'3-4': { dir: 'left', pct: 0.20 }, // San Francisco – Los Angeles
|
||||
'2-3': { dir: 'left', pct: 0.20 }, // Portland – San Francisco
|
||||
'11-13': { dir: 'left', pct: 0.20 }, // Phoenix – Denver
|
||||
'9-10': { dir: 'right', pct: 0.20 }, // Salt Lake City – Las Vegas
|
||||
'13-17': { dir: 'down', pct: 0.25 }, // Denver – Oklahoma City
|
||||
'13-16': { dir: 'down', pct: 0.10 }, // Denver – Kansas City
|
||||
'13-15': { dir: 'up', pct: 0.10 }, // Denver – Omaha
|
||||
'21-31': { dir: 'up', pct: 0.10 }, // Chicago – Pittsburgh
|
||||
'25-35': { dir: 'up', pct: 0.35 }, // New Orleans – Miami
|
||||
'26-35': { dir: 'right', pct: 0.20 }, // Atlanta – Miami
|
||||
'14-19': { dir: 'down', pct: 0.25 }, // El Paso – Houston
|
||||
'12-17': { dir: 'down', pct: 0.20 }, // Santa Fe – Oklahoma City
|
||||
'1-5': { dir: 'down', pct: 0.30 }, // Seattle – Calgary
|
||||
'23-28': { dir: 'up', pct: 0.25 }, // Sault Ste Marie – Montreal
|
||||
'27-28': { dir: 'up', pct: 0.20 }, // Toronto – Montreal
|
||||
'28-30': { dir: 'left', pct: 0.25 }, // Montreal – New York
|
||||
};
|
||||
|
||||
// Expand ROUTE_DEFS into the flat ROUTES array, generating ids, double-route
|
||||
// grouping, and parallelSide so the two strips of a double render side-by-side.
|
||||
export const ROUTES = (() => {
|
||||
const out = [];
|
||||
for (const [a, b, length, colour] of ROUTE_DEFS) {
|
||||
const curveKey = `${Math.min(a, b)}-${Math.max(a, b)}`;
|
||||
const curve = ROUTE_CURVES[curveKey] ?? null;
|
||||
if (Array.isArray(colour)) {
|
||||
const group = `${a}-${b}`;
|
||||
colour.forEach((c, i) => {
|
||||
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i });
|
||||
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i, curve });
|
||||
});
|
||||
} else {
|
||||
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0 });
|
||||
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0, curve });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -254,30 +280,66 @@ const CITY_MARGIN = 30; // keep car slots clear of the city dots
|
|||
const CAR_GAP = 6; // pixel gap between adjacent cars
|
||||
const CAR_WIDTH = 16; // perpendicular thickness of a car
|
||||
const DOUBLE_OFFSET = 12; // perpendicular shift for each strip of a double route
|
||||
const CURVE_SAMPLES = 10; // total bezier sample points (including endpoints)
|
||||
|
||||
// Returns one slot per train-length: { cx, cy, angle, w, h } rotated to the A→B
|
||||
// line. parallelSide shifts the whole strip perpendicular so double routes sit
|
||||
// side-by-side.
|
||||
// Samples a quadratic bezier through a displaced midpoint control, returning
|
||||
// CURVE_SAMPLES {x,y} points that form a smooth polyline.
|
||||
function bezierPolyline(A, B, curve) {
|
||||
const shift = curve.pct * Math.hypot(B.x - A.x, B.y - A.y);
|
||||
const C = {
|
||||
x: (A.x + B.x) / 2 + (curve.dir === 'right' ? shift : curve.dir === 'left' ? -shift : 0),
|
||||
y: (A.y + B.y) / 2 + (curve.dir === 'down' ? shift : curve.dir === 'up' ? -shift : 0),
|
||||
};
|
||||
const pts = [];
|
||||
for (let i = 0; i < CURVE_SAMPLES; i++) {
|
||||
const t = i / (CURVE_SAMPLES - 1);
|
||||
const mt = 1 - t;
|
||||
pts.push({ x: mt*mt*A.x + 2*mt*t*C.x + t*t*B.x, y: mt*mt*A.y + 2*mt*t*C.y + t*t*B.y });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function polylineLength(pts) {
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
|
||||
return len;
|
||||
}
|
||||
|
||||
// Returns {x, y, angle} at distance d along pts, clamped to the final segment.
|
||||
function pointAtDist(pts, d) {
|
||||
let acc = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const segLen = Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
|
||||
if (acc + segLen >= d || i === pts.length - 1) {
|
||||
const t = segLen > 0 ? Math.min((d - acc) / segLen, 1) : 0;
|
||||
return {
|
||||
x: pts[i-1].x + t * (pts[i].x - pts[i-1].x),
|
||||
y: pts[i-1].y + t * (pts[i].y - pts[i-1].y),
|
||||
angle: Math.atan2(pts[i].y - pts[i-1].y, pts[i].x - pts[i-1].x),
|
||||
};
|
||||
}
|
||||
acc += segLen;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns one slot per train-length: { cx, cy, angle, w, h }.
|
||||
// Curved routes distribute cars along a bezier polyline; each car is still a
|
||||
// straight rectangle aligned to its local polyline segment.
|
||||
export function routeSegments(route) {
|
||||
const A = CITIES[route.a];
|
||||
const B = CITIES[route.b];
|
||||
const dx = B.x - A.x;
|
||||
const dy = B.y - A.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len; // unit vector along the line
|
||||
const px = -uy, py = ux; // unit perpendicular
|
||||
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
|
||||
const span = len - CITY_MARGIN * 2;
|
||||
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
|
||||
const totalLen = polylineLength(pts);
|
||||
const n = route.length;
|
||||
const carLen = (span - CAR_GAP * (n - 1)) / n;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const carLen = (totalLen - CITY_MARGIN * 2 - CAR_GAP * (n - 1)) / n;
|
||||
const segs = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP);
|
||||
const pos = pointAtDist(pts, CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP));
|
||||
segs.push({
|
||||
cx: A.x + ux * t + px * off,
|
||||
cy: A.y + uy * t + py * off,
|
||||
angle,
|
||||
cx: pos.x - Math.sin(pos.angle) * off,
|
||||
cy: pos.y + Math.cos(pos.angle) * off,
|
||||
angle: pos.angle,
|
||||
w: carLen,
|
||||
h: CAR_WIDTH,
|
||||
});
|
||||
|
|
@ -289,15 +351,15 @@ export function routeSegments(route) {
|
|||
export function routeMidpoint(route) {
|
||||
const A = CITIES[route.a];
|
||||
const B = CITIES[route.b];
|
||||
const dx = B.x - A.x, dy = B.y - A.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const px = -dy / len, py = dx / len;
|
||||
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
|
||||
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
|
||||
const totalLen = polylineLength(pts);
|
||||
const pos = pointAtDist(pts, totalLen / 2);
|
||||
return {
|
||||
x: (A.x + B.x) / 2 + px * off,
|
||||
y: (A.y + B.y) / 2 + py * off,
|
||||
angle: Math.atan2(dy, dx),
|
||||
length: len - CITY_MARGIN * 2,
|
||||
x: pos.x - Math.sin(pos.angle) * off,
|
||||
y: pos.y + Math.cos(pos.angle) * off,
|
||||
angle: pos.angle,
|
||||
length: totalLen - CITY_MARGIN * 2,
|
||||
width: CAR_WIDTH + 8,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue