242 lines
9.9 KiB
JavaScript
242 lines
9.9 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|