From f298dc513c7ba15ca105096110b73f0737ed0c7a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 20 Aug 2026 16:32:20 -0600 Subject: [PATCH] feat(editor): add new terrain section types and fix physics body generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/data/levels/level03.js | 275 ++++++++++++++++++++++++++++++++----- src/editor/levelBuilder.js | 8 +- src/editor/main.js | 6 +- src/editor/sections.js | 102 ++++++++++++++ src/entities/Terrain.js | 50 ++++--- 5 files changed, 380 insertions(+), 61 deletions(-) diff --git a/src/data/levels/level03.js b/src/data/levels/level03.js index ef7bb79..2f63de1 100644 --- a/src/data/levels/level03.js +++ b/src/data/levels/level03.js @@ -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 }, }; diff --git a/src/editor/levelBuilder.js b/src/editor/levelBuilder.js index e0bd4de..d2b1707 100644 --- a/src/editor/levelBuilder.js +++ b/src/editor/levelBuilder.js @@ -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. diff --git a/src/editor/main.js b/src/editor/main.js index 91ee668..228c4b0 100644 --- a/src/editor/main.js +++ b/src/editor/main.js @@ -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 = ''; diff --git a/src/editor/sections.js b/src/editor/sections.js index c23a6dc..a77788c 100644 --- a/src/editor/sections.js +++ b/src/editor/sections.js @@ -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) { diff --git a/src/entities/Terrain.js b/src/entities/Terrain.js index 94a191a..6c34b21 100644 --- a/src/entities/Terrain.js +++ b/src/entities/Terrain.js @@ -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', });