monsterplex/src/editor/levelBuilder.js

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