import { generateSection, POINT_STEP } from './sections.js'; import { WORLD_SCALE } from '../config.js'; // Base-unit constants mirroring the values every hand-written level file // (level01/02/03.js) already uses, so editor-built levels look/feel // consistent with the shipped ones. const TERRAIN_LEAD_IN = 200; const INITIAL_GROUND_Y = 420; const BUS_SPAWN_X = 100; const BUS_SPAWN_DROP = 70; const GOAL_WIDTH = 140; const GOAL_HEIGHT = 320; const GOAL_END_MARGIN = 120; const GOAL_HEIGHT_OFFSET = 120; const CAMERA_LEFT_MARGIN = 300; const CAMERA_RIGHT_MARGIN = 700; // Vertical camera bounds used to be a fixed 1000 regardless of the actual // terrain - fine for the hand-authored levels (which stay near a constant // baseline by design), but an editor sequence can chain multiple // inclines/declines/ditches with no cap on cumulative elevation change, so // a long run of declines could push the terrain (and the bus) below what a // fixed-height bound could ever scroll down to reach - the camera would // clamp at its bounds and the bus would drift off the bottom of the // screen. Bounds are now sized to the level's actual min/max terrain Y, // with generous padding, with this as just a floor for very flat levels. const CAMERA_VERTICAL_PADDING = 400; const CAMERA_MIN_HEIGHT = 1000; function scalePoint(p) { return { x: Math.round(p.x * WORLD_SCALE), y: Math.round(p.y * WORLD_SCALE) }; } // How far into a "smooth" section's own point run (base design units, same // space sections.js generates in) the blend runs before handing off to the // section's natural, unmodified curve - a fraction of the section's own // width, clamped so a very short section can't have the blend swallow its // whole point run, and a very long one doesn't get an absurdly long taper. const SMOOTH_BLEND_FRACTION = 0.3; const SMOOTH_BLEND_MIN = POINT_STEP * 2; const SMOOTH_BLEND_MAX = 220; function lastPair(points) { if (!points || points.length < 2) return null; return [points[points.length - 2], points[points.length - 1]]; } function smoothstep(u) { const c = Math.max(0, Math.min(1, u)); return c * c * (3 - 2 * c); } // Rounds off the sharp corner where a section's own point run meets // whatever terrain came immediately before it - from the BUS's // perspective, not the terrain's. A naive fix blends the *height* (fit a // curve through the two boundary points and their tangents), but a // position-matched curve can - and did - swing the instantaneous slope // PAST either boundary's angle to hit both the position and tangent // targets at once, which is a bigger jolt to the chassis than the sharp // corner it was meant to replace. // // This instead blends the ANGLE the bus actually feels: interpolate the // heading smoothly (via smoothstep, so the turn eases in/out rather than // snapping to a constant turn rate) from the incoming heading to the // section's own natural heading at the end of the blend window, then // integrate that heading back into y positions. The angle is then // guaranteed to move monotonically between the two endpoint angles - // no overshoot, so nowhere in the blend does the bus turn harder than the // sharper of the two corners it's smoothing between. // // Because the blended angle profile is only an approximation of the // original curve's true path between those two x's, the blended points // generally won't land exactly back on the original curve's height at the // blend's far end - so everything past the blend window (left untouched, // still the section's original shape) is rigidly shifted vertically by // that small residual to reconnect seamlessly. That shift preserves every // slope in the untouched tail exactly (a vertical translation doesn't // change slopes), so the returned `delta` just needs to be carried into // this section's endY too, since the section's actual endpoint moved by // the same amount. // // prevPair is [secondToLast, last] of whatever raw point run preceded this // one, or null for the very first section (nothing to blend against, so // this is a no-op). function smoothEntry(prevPair, points, width) { if (!prevPair || points.length < 3) return { points, delta: 0 }; const [p0, p1] = prevPair; if (p1.x === p0.x) return { points, delta: 0 }; const angleIn = Math.atan2(p1.y - p0.y, p1.x - p0.x); const blendLen = Math.min(SMOOTH_BLEND_MAX, Math.max(SMOOTH_BLEND_MIN, width * SMOOTH_BLEND_FRACTION)); const startX = points[0].x; let idxB = points.findIndex((p) => p.x - startX >= blendLen); if (idxB <= 0) idxB = points.length - 1; const a = points[0]; const b = points[idxB]; if (b.x === a.x) return { points, delta: 0 }; const prevB = points[Math.max(0, idxB - 1)]; const nextB = points[Math.min(points.length - 1, idxB + 1)]; const angleOut = prevB.x === nextB.x ? angleIn : Math.atan2(nextB.y - prevB.y, nextB.x - prevB.x); const blended = [{ x: a.x, y: a.y }]; let prevAngle = angleIn; for (let i = 1; i <= idxB; i++) { const p = points[i]; const u = (p.x - a.x) / (b.x - a.x); const angle = angleIn + (angleOut - angleIn) * smoothstep(u); // Trapezoidal step (average of this segment's start/end angle) for a // closer height estimate than a single-sample slope would give. const avgSlope = (Math.tan(prevAngle) + Math.tan(angle)) / 2; const prev = blended[blended.length - 1]; blended.push({ x: p.x, y: prev.y + avgSlope * (p.x - prev.x) }); prevAngle = angle; } const delta = blended[blended.length - 1].y - b.y; const result = blended.map((p) => ({ x: p.x, y: Math.round(p.y) })); for (let i = idxB + 1; i < points.length; i++) { result.push({ x: points[i].x, y: Math.round(points[i].y + delta) }); } return { points: result, delta }; } // Appends scaled points onto an accumulator array, skipping a leading point // that exactly duplicates the accumulator's current last point (happens at // every block boundary, since each block's first sample is the previous // block's last x/y by construction). function appendPoints(accumulator, points) { for (const p of points) { const scaled = scalePoint(p); const last = accumulator[accumulator.length - 1]; if (last && last.x === scaled.x && last.y === scaled.y) continue; accumulator.push(scaled); } } // sections: ordered array of { type, width, vScale, smooth } - type is an // id from sections.js's SECTION_TYPES, width/vScale independently stretch // or contract that section horizontally/vertically (see sections.js's // SECTION_WIDTH_MIN/MAX and SECTION_VSCALE_MIN/MAX for editor bounds), and // smooth rounds off the sharp corner where this section's terrain meets // the previous section's (see smoothEntry above) - off by default, since a // "Sharp Incline" or a jump's ramp is often supposed to look abrupt. // metadata: { id, name, description, kidsAboard }. // Returns a full level data object matching level01.js's shape, or null if // sections is empty. export function buildLevelData(sections, metadata) { if (!sections || sections.length === 0) return null; let baseX = -TERRAIN_LEAD_IN; let baseY = INITIAL_GROUND_Y; const terrainSegments = []; let currentPoints = []; // Raw (pre-scale) [secondToLast, last] points of whatever terrain run // immediately precedes the section currently being generated - the // reference smoothEntry blends a "smooth" section's start into. let prevPair = null; for (const section of sections) { const result = generateSection(section.type, baseX, baseY, section.width, section.vScale); let endY = result.endY; if (result.type === 'jump') { // Only the takeoff (the jump's own "beginning") gets smoothed - the // far side of the gap is a fresh terrain segment with no seam to // round off, and a takeoff shifted by a small delta doesn't need // correcting against the landing since nothing spans the gap. const takeoffPoints = section.smooth ? smoothEntry(prevPair, result.takeoffPoints, section.width).points : result.takeoffPoints; appendPoints(currentPoints, takeoffPoints); terrainSegments.push({ points: currentPoints }); currentPoints = []; appendPoints(currentPoints, result.landingPoints); prevPair = lastPair(result.landingPoints); } else { let points = result.points; if (section.smooth) { const smoothed = smoothEntry(prevPair, result.points, section.width); points = smoothed.points; endY += smoothed.delta; } appendPoints(currentPoints, points); prevPair = lastPair(points); } baseY = endY; baseX += section.width; } if (currentPoints.length > 0) { terrainSegments.push({ points: currentPoints }); } const endX = Math.round(baseX * WORLD_SCALE); const groundYAtEnd = Math.round(baseY * WORLD_SCALE); let minTerrainY = Infinity; let maxTerrainY = -Infinity; for (const segment of terrainSegments) { for (const p of segment.points) { minTerrainY = Math.min(minTerrainY, p.y); maxTerrainY = Math.max(maxTerrainY, p.y); } } const verticalPadding = Math.round(CAMERA_VERTICAL_PADDING * WORLD_SCALE); const cameraTop = Math.min(0, minTerrainY - verticalPadding); const cameraHeight = Math.max( Math.round(CAMERA_MIN_HEIGHT * WORLD_SCALE), maxTerrainY + verticalPadding - cameraTop ); return { id: metadata.id, name: metadata.name, description: metadata.description, kidsAboard: metadata.kidsAboard, startPosition: { x: Math.round(BUS_SPAWN_X * WORLD_SCALE), y: Math.round((INITIAL_GROUND_Y - BUS_SPAWN_DROP) * WORLD_SCALE), }, startAngle: 0, terrain: terrainSegments, obstacles: [], goal: { x: endX - Math.round(GOAL_END_MARGIN * WORLD_SCALE), y: groundYAtEnd - Math.round(GOAL_HEIGHT_OFFSET * WORLD_SCALE), width: Math.round(GOAL_WIDTH * WORLD_SCALE), height: Math.round(GOAL_HEIGHT * WORLD_SCALE), }, cameraBounds: { x: -Math.round(CAMERA_LEFT_MARGIN * WORLD_SCALE), y: cameraTop, width: endX + Math.round(CAMERA_RIGHT_MARGIN * WORLD_SCALE), height: cameraHeight, }, }; }