monsterplex/src/data/levels/positioning.js

93 lines
3.7 KiB
JavaScript

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