feat(editor): add new terrain section types and fix physics body generation

Add four new section types to the level editor:
- dropOff: instant vertical drop with gentle downward tail
- gap: flat lead-in, x-gap, flat landing at same height
- lip: flat run into a steepening (t²) kicker ramp
- dropGap: like gap but landing is well below takeoff

Fix Terrain physics body generation to extrude straight down from
segment endpoints instead of using rotated rectangles. A previous
perpendicular-offset approach caused near-vertical segments (dropOff
cliffs) to balloon physics bodies sideways under adjacent terrain,
making the bus ride above the drawn ground.

Update level03 ("Mind the Gap") to use editor-generated terrain data.
Generalize levelBuilder smoothing to detect gap-shaped sections
(structurally via takeoffPoints/landingPoints) rather than by type
string, so future gap types work automatically.
This commit is contained in:
Brian Fertig 2026-08-20 16:32:20 -06:00
parent 6cf184fd4d
commit f298dc513c
5 changed files with 380 additions and 61 deletions

View File

@ -1,42 +1,241 @@
import { WORLD_SCALE } from '../../config.js';
// Hand-tuned profile: flat approach -> steep valley drop -> steep climb out ->
// a long stretch of tight, fast bumps that punish sloppy lean control.
function buildPoints() {
const step = 60 * WORLD_SCALE;
const points = [];
for (let x = -200 * WORLD_SCALE; x <= 3900 * WORLD_SCALE; x += step) {
let y;
if (x < 1200 * WORLD_SCALE) {
y = 420 * WORLD_SCALE + Math.sin(x / (500 * WORLD_SCALE)) * 10 * WORLD_SCALE;
} else if (x < 1500 * WORLD_SCALE) {
y = 420 * WORLD_SCALE + 220 * WORLD_SCALE * ((x - 1200 * WORLD_SCALE) / (300 * WORLD_SCALE));
} else if (x < 1650 * WORLD_SCALE) {
y = 640 * WORLD_SCALE;
} else if (x < 1950 * WORLD_SCALE) {
y = 640 * WORLD_SCALE - 260 * WORLD_SCALE * ((x - 1650 * WORLD_SCALE) / (300 * WORLD_SCALE));
} else if (x < 3650 * WORLD_SCALE) {
y = 380 * WORLD_SCALE + Math.sin(x / (150 * WORLD_SCALE)) * 45 * WORLD_SCALE;
} else {
y = 380 * WORLD_SCALE;
}
points.push({ x, y: Math.round(y) });
}
return points;
}
const END_X = 3900 * WORLD_SCALE;
// Generated by editor.html - review before dropping into src/data/levels/.
export default {
id: 'level03',
name: 'Tight Squeeze',
description: 'A steep valley and a long run of sharp bumps - lean early, lean often.',
kidsAboard: 4,
timeLimit: 20,
startPosition: { x: 100 * WORLD_SCALE, y: 350 * WORLD_SCALE },
id: "level03",
name: "Mind the Gap",
description: "TV smash!!!",
kidsAboard: 3,
timeLimit: 39,
startPosition: { x: 200, y: 700 },
startAngle: 0,
terrain: [{ points: buildPoints() }],
terrain: [
{ points: [
{ x: -400, y: 840 },
{ x: -330, y: 840 },
{ x: -260, y: 840 },
{ x: -190, y: 840 },
{ x: -120, y: 840 },
{ x: -50, y: 840 },
{ x: 20, y: 840 },
{ x: 90, y: 840 },
{ x: 160, y: 840 },
{ x: 230, y: 840 },
{ x: 300, y: 840 },
{ x: 370, y: 840 },
{ x: 440, y: 840 },
{ x: 510, y: 840 },
{ x: 580, y: 840 },
{ x: 650, y: 840 },
{ x: 720, y: 840 },
{ x: 790, y: 840 },
{ x: 860, y: 840 },
{ x: 930, y: 840 },
{ x: 1000, y: 840 },
{ x: 1070, y: 838 },
{ x: 1140, y: 834 },
{ x: 1210, y: 830 },
{ x: 1280, y: 822 },
{ x: 1350, y: 812 },
{ x: 1420, y: 802 },
{ x: 1490, y: 790 },
{ x: 1560, y: 776 },
{ x: 1630, y: 764 },
{ x: 1700, y: 750 },
{ x: 1770, y: 736 },
{ x: 1840, y: 724 },
{ x: 1910, y: 710 },
{ x: 1980, y: 698 },
{ x: 2050, y: 688 },
{ x: 2120, y: 678 },
{ x: 2190, y: 670 },
{ x: 2260, y: 666 },
{ x: 2330, y: 662 },
{ x: 2400, y: 660 },
{ x: 2470, y: 658 },
{ x: 2540, y: 652 },
{ x: 2610, y: 642 },
{ x: 2680, y: 626 },
{ x: 2750, y: 606 },
{ x: 2820, y: 582 },
{ x: 2890, y: 558 },
{ x: 2960, y: 534 },
{ x: 3030, y: 510 },
{ x: 3100, y: 486 },
{ x: 3170, y: 462 },
{ x: 3240, y: 438 },
{ x: 3310, y: 414 },
{ x: 3380, y: 390 },
{ x: 3450, y: 366 },
{ x: 3520, y: 342 },
{ x: 3590, y: 318 },
{ x: 3660, y: 294 },
{ x: 3730, y: 270 },
{ x: 3800, y: 246 },
{ x: 3870, y: 846 },
{ x: 3940, y: 852 },
{ x: 4010, y: 858 },
{ x: 4080, y: 864 },
{ x: 4150, y: 870 },
{ x: 4220, y: 876 },
{ x: 4290, y: 882 },
{ x: 4360, y: 888 },
{ x: 4430, y: 894 },
{ x: 4500, y: 900 },
{ x: 4570, y: 904 },
{ x: 4640, y: 910 },
{ x: 4710, y: 916 },
{ x: 4780, y: 922 },
{ x: 4850, y: 928 },
{ x: 4920, y: 934 },
{ x: 4990, y: 940 },
{ x: 5060, y: 946 },
{ x: 5130, y: 952 },
{ x: 5200, y: 956 },
{ x: 5270, y: 960 },
{ x: 5340, y: 966 },
{ x: 5410, y: 972 },
{ x: 5480, y: 980 },
{ x: 5550, y: 990 },
{ x: 5620, y: 1000 },
{ x: 5690, y: 1012 },
{ x: 5760, y: 1024 },
{ x: 5830, y: 1038 },
{ x: 5900, y: 1050 },
{ x: 5970, y: 1064 },
{ x: 6040, y: 1078 },
{ x: 6110, y: 1090 },
{ x: 6180, y: 1102 },
{ x: 6250, y: 1112 },
{ x: 6320, y: 1122 },
{ x: 6390, y: 1130 },
{ x: 6460, y: 1136 },
{ x: 6530, y: 1140 },
{ x: 6600, y: 1140 },
{ x: 6600, y: 1142 },
{ x: 6670, y: 1142 },
{ x: 6740, y: 1138 },
{ x: 6810, y: 1132 },
{ x: 6880, y: 1122 },
{ x: 6950, y: 1110 },
{ x: 7020, y: 1094 },
{ x: 7090, y: 1078 },
{ x: 7160, y: 1062 },
{ x: 7230, y: 1046 },
{ x: 7300, y: 1030 },
{ x: 7370, y: 1014 },
{ x: 7440, y: 998 },
{ x: 7510, y: 982 },
{ x: 7580, y: 966 },
{ x: 7650, y: 950 },
{ x: 7720, y: 934 },
{ x: 7790, y: 918 },
{ x: 7860, y: 902 },
{ x: 7930, y: 886 },
{ x: 8000, y: 870 },
{ x: 8070, y: 852 },
{ x: 8140, y: 828 },
{ x: 8210, y: 798 },
{ x: 8280, y: 760 },
{ x: 8350, y: 720 },
{ x: 8420, y: 682 },
{ x: 8490, y: 642 },
{ x: 8560, y: 602 },
{ x: 8630, y: 564 },
{ x: 8700, y: 524 },
{ x: 8770, y: 484 },
{ x: 8840, y: 444 },
{ x: 8880, y: 422 },
{ x: 8950, y: 422 },
{ x: 9020, y: 422 },
{ x: 9090, y: 422 },
{ x: 9160, y: 422 },
{ x: 9230, y: 422 },
{ x: 9300, y: 422 },
{ x: 9370, y: 422 },
{ x: 9440, y: 422 },
{ x: 9510, y: 422 },
{ x: 9580, y: 422 },
{ x: 9650, y: 422 },
{ x: 9720, y: 422 },
{ x: 9790, y: 422 },
{ x: 9860, y: 422 },
{ x: 9930, y: 422 },
{ x: 10000, y: 422 },
{ x: 10070, y: 422 },
{ x: 10140, y: 422 },
{ x: 10210, y: 422 },
{ x: 10280, y: 422 },
{ x: 10350, y: 422 },
{ x: 10420, y: 422 },
{ x: 10490, y: 422 },
{ x: 10560, y: 422 },
{ x: 10630, y: 422 },
{ x: 10700, y: 422 },
{ x: 10770, y: 422 },
{ x: 10840, y: 422 },
{ x: 10910, y: 422 },
{ x: 10980, y: 422 },
{ x: 11050, y: 422 },
{ x: 11120, y: 422 },
{ x: 11190, y: 422 },
{ x: 11260, y: 422 },
{ x: 11288, y: 422 },
{ x: 11358, y: 418 },
{ x: 11428, y: 404 },
{ x: 11498, y: 382 },
{ x: 11568, y: 350 },
{ x: 11638, y: 310 },
{ x: 11680, y: 282 },
{ x: 11750, y: 282 },
] },
{ points: [
{ x: 12800, y: 762 },
{ x: 12870, y: 762 },
{ x: 12940, y: 762 },
{ x: 13010, y: 762 },
{ x: 13080, y: 762 },
{ x: 13150, y: 762 },
{ x: 13220, y: 766 },
{ x: 13290, y: 772 },
{ x: 13360, y: 780 },
{ x: 13430, y: 790 },
{ x: 13500, y: 800 },
{ x: 13570, y: 812 },
{ x: 13640, y: 824 },
{ x: 13710, y: 838 },
{ x: 13780, y: 852 },
{ x: 13850, y: 866 },
{ x: 13920, y: 878 },
{ x: 13990, y: 890 },
{ x: 14060, y: 902 },
{ x: 14130, y: 914 },
{ x: 14200, y: 922 },
{ x: 14270, y: 930 },
{ x: 14340, y: 936 },
{ x: 14410, y: 940 },
{ x: 14480, y: 942 },
{ x: 14550, y: 942 },
{ x: 14620, y: 942 },
{ x: 14690, y: 942 },
{ x: 14760, y: 942 },
{ x: 14830, y: 942 },
{ x: 14900, y: 942 },
{ x: 14970, y: 942 },
{ x: 15040, y: 942 },
{ x: 15110, y: 942 },
{ x: 15180, y: 942 },
{ x: 15250, y: 942 },
{ x: 15320, y: 942 },
{ x: 15390, y: 942 },
{ x: 15460, y: 942 },
{ x: 15530, y: 942 },
{ x: 15600, y: 942 },
{ x: 15670, y: 942 },
{ x: 15740, y: 942 },
{ x: 15810, y: 942 },
{ x: 15880, y: 942 },
] },
],
obstacles: [],
goal: { x: END_X - 120 * WORLD_SCALE, y: 260 * WORLD_SCALE, width: 140 * WORLD_SCALE, height: 400 * WORLD_SCALE },
cameraBounds: { x: -300 * WORLD_SCALE, y: 0, width: END_X + 700 * WORLD_SCALE, height: 1000 * WORLD_SCALE },
goal: { x: 15640, y: 702, width: 280, height: 640 },
cameraBounds: { x: -600, y: -554, width: 17280, height: 2496 },
};

View File

@ -175,8 +175,12 @@ export function buildLevelData(sections, metadata) {
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
if (result.takeoffPoints) {
// Any generator producing a real x-gap (jump, gap, dropGap, ...) returns
// takeoffPoints/landingPoints instead of a single points run - checked
// structurally rather than by a specific type string so new gap-shaped
// section types don't need to masquerade as 'jump'.
// Only the takeoff (the section'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.

View File

@ -41,9 +41,9 @@ const downloadBtn = document.getElementById('download-btn');
const labelFor = (typeId) => SECTION_TYPES.find((t) => t.id === typeId)?.label || typeId;
// 'flat' has no rise/amplitude/depth for vScale to multiply - disable that
// control rather than leave it silently do nothing.
const FLAT_LIKE_TYPES = new Set(['flat']);
// 'flat' and 'gap' have no rise/amplitude/depth for vScale to multiply -
// disable that control rather than leave it silently do nothing.
const FLAT_LIKE_TYPES = new Set(['flat', 'gap']);
function buildPalette() {
paletteEl.innerHTML = '';

View File

@ -24,6 +24,10 @@ export const SECTION_TYPES = [
{ id: 'rollingHills', label: 'Rolling Hills' },
{ id: 'ditch', label: 'Ditch' },
{ id: 'jump', label: 'Jump' },
{ id: 'dropOff', label: 'Drop Off' },
{ id: 'gap', label: 'Gap' },
{ id: 'lip', label: 'Lip' },
{ id: 'dropGap', label: 'Drop Gap' },
];
const RISE_SMOOTH = 90;
@ -38,6 +42,35 @@ const JUMP_TAKEOFF_RISE = 140;
const JUMP_LANDING_DROP = 60;
const JUMP_LIP_LEN = POINT_STEP;
// "Instant" edges (a drop, or the start of a gap) happen within a single
// point-step so they render (and collide, via Terrain._buildSegment's
// per-point-pair rectangles) as a near-vertical wall/edge, rather than
// eating a chunk of the section's own width on a flat lead-in - callers are
// expected to place their own approach section before these if they want one.
const LEAD_LEN = POINT_STEP;
const DROP_OFF_DROP = 300;
const DROP_OFF_TAIL_RISE = 55;
// One lead-in point-step off the edge, a real x-gap wide enough that missing
// it is fatal (see jump's comment on how levelBuilder splits terrainSegments
// on takeoffPoints/landingPoints), then a flat landing back at the same
// height for the remainder of the width.
const GAP_GAP_FRACTION = 0.35;
// GAP_LANDING_FRACTION is implicit: 1 - GAP_GAP_FRACTION (minus LEAD_LEN)
// A flat run, then a short ramp eased with t*t (slope 0 at the flat, steepest
// right at the edge) so it bows below a straight line - the "under arched"
// kicker shape - rather than jump's smoothstep ramp.
const LIP_FLAT_FRACTION = 0.72;
const LIP_RISE = 70;
// Like gap, but the landing is a deliberately small flat stretch well below
// the takeoff instead of a same-height landing filling most of the width -
// the gap eats whatever's left after the lead-in and that small landing.
const DROP_GAP_LANDING_FRACTION = 0.2;
const DROP_GAP_DROP = 240;
function smoothstep(t) {
const c = Math.max(0, Math.min(1, t));
return c * c * (3 - 2 * c);
@ -134,6 +167,71 @@ function jump(startX, startY, width, vScale) {
return { type: 'jump', takeoffPoints, landingPoints, endY };
}
// An instant large drop (a near-vertical wall within one point-step) followed
// by a gentle downward-angled tail for the rest of the width - no gap, a
// single continuous point run.
function dropOff(startX, startY, width, vScale) {
const drop = DROP_OFF_DROP * vScale;
const tailRise = DROP_OFF_TAIL_RISE * vScale;
const dropX = startX + LEAD_LEN;
const dropY = startY + drop;
const points = [
{ x: startX, y: Math.round(startY) },
{ x: dropX, y: Math.round(dropY) },
];
const tail = sampleBlock(dropX, dropY, width - LEAD_LEN, (t) => tailRise * t);
points.push(...tail.slice(1));
return { type: 'simple', points, endY: dropY + tailRise };
}
// One lead-in point-step, a real x-gap, flat landing back at the same height
// for the rest of the width - falling short of the landing means falling out
// of the world (see PlayScene's cameraBounds fail checks). No approach: the
// caller is expected to place a flat/other section before this one.
function gap(startX, startY, width) {
const w1 = LEAD_LEN;
const w2 = width * GAP_GAP_FRACTION;
const w3 = width - w1 - w2;
const takeoffPoints = sampleBlock(startX, startY, w1, () => 0);
const landingStartX = startX + w1 + w2;
const landingPoints = sampleBlock(landingStartX, startY, w3, () => 0);
return { type: 'gap', takeoffPoints, landingPoints, endY: startY };
}
// Flat run into a short, steepening (t*t) kicker ramp - no gap, just a small
// launch off the end.
function lip(startX, startY, width, vScale) {
const rise = LIP_RISE * vScale;
const w1 = width * LIP_FLAT_FRACTION;
const w2 = width - w1;
const flatPoints = sampleBlock(startX, startY, w1, () => 0);
const rampPoints = sampleBlock(startX + w1, startY, w2, (t) => -rise * t * t);
const points = flatPoints.concat(rampPoints.slice(1));
return { type: 'simple', points, endY: startY - rise };
}
// One lead-in point-step, then a gap, then a small flat landing well below
// the takeoff instead of matching its height. No approach, same as gap.
function dropGap(startX, startY, width, vScale) {
const drop = DROP_GAP_DROP * vScale;
const w1 = LEAD_LEN;
const w3 = width * DROP_GAP_LANDING_FRACTION;
const w2 = width - w1 - w3;
const takeoffPoints = sampleBlock(startX, startY, w1, () => 0);
const landingStartX = startX + w1 + w2;
const landingY = startY + drop;
const landingPoints = sampleBlock(landingStartX, landingY, w3, () => 0);
return { type: 'gap', takeoffPoints, landingPoints, endY: landingY };
}
const GENERATORS = {
flat,
smoothIncline,
@ -143,6 +241,10 @@ const GENERATORS = {
rollingHills,
ditch,
jump,
dropOff,
gap,
lip,
dropGap,
};
export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) {

View File

@ -63,27 +63,41 @@ export default class Terrain {
const b = points[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (length < 1) continue;
if (Math.hypot(dx, dy) < 1) continue;
// A perfectly vertical a-b (dx === 0, seen in hand-authored data as
// float-rounding near-duplicate points, e.g. two points a couple of Y
// units apart at the same X) makes the straight-down-extruded quad
// below degenerate: shifting a vertical edge straight down keeps every
// corner at the same X, so it has zero width/area, which Matter's
// fromVertices can't turn into a body. Skip it - the segments on
// either side still meet at both of this pair's endpoints, so physics
// coverage stays continuous either way.
if (Math.abs(dx) < 1) continue;
// The rectangle's top edge needs to sit exactly on the a-b line, which
// means offsetting its center from the segment's own midpoint by
// TERRAIN_DEPTH/2 in the direction PERPENDICULAR to the segment - both
// an X and a Y component (-sin/cos of the segment's angle), not just
// straight down in world Y. Omitting the X term (as a prior version of
// this did) left every non-flat segment's physics body shifted
// sideways from its drawn line by TERRAIN_DEPTH/2 * sin(angle) - with
// TERRAIN_DEPTH deliberately huge (deep enough nothing ever tunnels
// through the bottom), that shift is large even at modest slopes,
// which is exactly what made the bus visibly ride above/through the
// drawn ground on anything but flat terrain.
const angle = Math.atan2(dy, dx);
const midX = (a.x + b.x) / 2 - (TERRAIN_DEPTH / 2) * Math.sin(angle);
const midY = (a.y + b.y) / 2 + (TERRAIN_DEPTH / 2) * Math.cos(angle);
// Built from the exact same 4 corners _fillFromTop draws (a, b, and
// both shifted straight down by TERRAIN_DEPTH) rather than a rotated
// rectangle, so the body's top edge is a-b itself and can never
// diverge from the drawn line. A previous version offset a rotated
// rectangle's center straight down, which shifted its top edge
// sideways from a-b by TERRAIN_DEPTH/2*sin(angle) on any slope; a
// later fix offset perpendicular to the segment instead, which kept
// the top edge glued to a-b but, on a near-vertical segment (a steep
// dropOff cliff), pointed that offset almost entirely sideways -
// ballooning the body out under whatever terrain preceded the cliff,
// well above where anything was ever drawn. Extruding straight down
// from the real points (matching the visual fill exactly, at every
// slope) has no such failure mode.
const cx = (a.x + b.x) / 2;
const cy = (a.y + b.y) / 2 + TERRAIN_DEPTH / 2;
const vertices = [
{ x: a.x, y: a.y },
{ x: b.x, y: b.y },
{ x: b.x, y: b.y + TERRAIN_DEPTH },
{ x: a.x, y: a.y + TERRAIN_DEPTH },
];
const body = this.scene.matter.add.rectangle(midX, midY, length, TERRAIN_DEPTH, {
const body = this.scene.matter.add.fromVertices(cx, cy, vertices, {
isStatic: true,
angle,
friction: 0.95,
label: 'terrain',
});