// Layout math for campaign map node positions. All coordinates are in 960x540 // design units (the scene multiplies by WORLD_SCALE when drawing). // // Node badges render at ~58-71 design units in diameter (see nodeDisplaySize // in util/mapArt.js). The free band of the map (below the title plate, whose // ink ends at y≈158, and above the bottom chrome at y≈490) is ~330 units // tall, which leaves room for two lanes of nodes with the ribbon's loop // between them: // // * lane 1 (top, y=268): runs RIGHT -> LEFT, ending short of the left edge // * lane 2 (bottom, y=412): runs LEFT -> RIGHT, ending short of the right // edge so the finish flag fits in the bottom-right corner past it // // ...which reads as one continuous road: start top-right, across the top, // down the left side, across the bottom, flag at the bottom-right. Both // lanes use ~660 of the 780 usable width; the 120-unit ends are what make // the left connector and the right flag fit without touching a node. // Comfortable for up to 12 levels (13 starts getting tight - give explicit // `positions` for exotic counts). const MAP_WIDTH = 960; const LANE_TOP = 268; const LANE_BOTTOM = 412; // Usable span of each lane (keeps a 120-unit end zone free, see above). const LANE_SPAN = 660; // Where each lane starts. Lane 1 starts at the top-right, lane 2 at the // bottom-left. const LANE1_X0 = 870; // lane 1 runs from here LEFTWARDS const LANE2_X0 = 90; // lane 2 runs from here RIGHTWARDS // Auto-places a campaign's levels along the 2-lane circuit. Handles any // count >= 1. For a bespoke look, give explicit `positions` in index.js - // those are used verbatim. export function autoLayoutPoints(count) { if (count <= 0) return []; if (count === 1) return [{ x: 480, y: Math.round((LANE_TOP + LANE_BOTTOM) / 2) }]; // Two levels: one per lane, offset toward each other so the connector is // a diagonal (not a vertical pair, which would be closer than the badges // are wide). if (count === 2) { return [ { x: (LANE1_X0 + LANE2_X0) / 2 - 60, y: LANE_TOP }, { x: (LANE1_X0 + LANE2_X0) / 2 + 60, y: LANE_BOTTOM }, ]; } const firstRow = Math.ceil(count / 2); const secondRow = count - firstRow; const points = []; // Lane 1: right -> left. for (let k = 0; k < firstRow; k++) { const t = firstRow === 1 ? 0 : k / (firstRow - 1); points.push({ x: Math.round(LANE1_X0 - LANE_SPAN * t), y: LANE_TOP }); } // Lane 2: left -> right. for (let k = 0; k < secondRow; k++) { const t = secondRow === 1 ? 0 : k / (secondRow - 1); points.push({ x: Math.round(LANE2_X0 + LANE_SPAN * t), y: LANE_BOTTOM }); } return points; } // Catmull-Rom spline sampled into a polyline. `points` must have >= 2 entries; // the first/last are duplicated so the curve passes exactly through every // node (standard end-anchor trick). export function sampleSpline(points, samplesPerSegment = 14) { if (points.length < 2) return []; const pts = [points[0], ...points, points[points.length - 1]]; const out = []; for (let i = 0; i < pts.length - 3; i++) { const [p0, p1, p2, p3] = [pts[i], pts[i + 1], pts[i + 2], pts[i + 3]]; for (let s = 0; s < samplesPerSegment; s++) { const t = s / samplesPerSegment; const t2 = t * t; const t3 = t2 * t; const x = 0.5 * (2 * p1.x + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3); const y = 0.5 * (2 * p1.y + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3); out.push({ x, y }); } } out.push(points[points.length - 1]); return out; }