diff --git a/src/editor/sections.js b/src/editor/sections.js index 09b09fe..d67feba 100644 --- a/src/editor/sections.js +++ b/src/editor/sections.js @@ -31,6 +31,8 @@ export const SECTION_TYPES = [ { id: 'gap', label: 'Gap' }, { id: 'lip', label: 'Lip' }, { id: 'dropGap', label: 'Drop Gap' }, + { id: 'halfpipe', label: 'Halfpipe' }, + { id: 'reverseHalfpipe', label: 'Reverse Halfpipe' }, ]; const RISE_SMOOTH = 90; @@ -297,6 +299,130 @@ function dropGap(startX, startY, width, vScale) { return { type: 'gap', takeoffPoints, landingPoints, endY: landingY }; } +// "Halfpipe" - a backwards J. One smooth profile, left to right: +// a near-vertical drop straight down (the tangent at the entrance is +// exactly vertical) that eases into a rounded, halfpipe-style bottom, +// then a near-vertical climb (again vertical at the very end) up to the +// exit. +// +// The exit is deliberately BELOW the entrance: the climb is only HALF of +// the drop, so the section is a net decline (a half-pipe's exit wall is +// lower than its entrance wall) and the profile reads as a backwards J - +// tall stem on the left, round hook at the bottom, short stub climbing +// back up on the right. +// +// Built as a single cubic Bezier with vertical tangents at both ends +// (control handles hanging straight down from the entrance and the exit). +// A circular arc can't do this job - a circle tangent to both walls +// vertically would force the two junctions to the same height (a +// half-circle), so the different wall heights need a cubic, which is also +// the shape a vert-ramp transition actually uses. +// +// P0 = entrance P1 = (P0.x, P0.y + drop) [entry handle] +// P3 = exit P2 = (P3.x, P3.y + climb) [exit handle] +// +// Both handles hang straight down from their endpoint (entry handle from +// the entrance by `drop`, exit handle from the exit by `climb`), so both +// end tangents are vertical. Note P2 = (W, drop) - at the 2:1 ratio both +// handles happen to reach the same depth. +// +// x(t) = W * t^2 * (3 - 2t) (all x-control points in [0, W] => +// strictly monotonic in x, so the terrain stays a valid y(x) curve) +// y(t) = 3*drop*t*(1-t)^2 + 3*drop*t^2*(1-t) + (drop - climb)*t^3 +// +// drop/climb (both vScale-scaled) are the end-handle lengths: they set how +// long the near-vertical ends are and how deep the rounded bottom sits +// (the bottom lands ~0.83 * drop below the entrance at the 2:1 ratio, +// ~13% further right of center). At the default 700-wide section the +// profile falls ~350 base units ("drops for a ways") and the exit ends +// ~210 below the entrance ("significantly lower"). The very first/last +// segment is steep but finite; the sample count is chosen so that even at +// the editor's minimum width the end segments stay wider than +// Terrain._buildSegment's |dx| < 1 skip threshold. +const HALFPIPE_DROP = 420; +const HALFPIPE_CLIMB_RATIO = 0.5; +const HALFPIPE_SAMPLES = 24; + +function halfpipe(startX, startY, width, vScale) { + const drop = HALFPIPE_DROP * vScale; + const climb = HALFPIPE_CLIMB_RATIO * drop; + const net = drop - climb; // entrance -> exit (positive: exit is lower) + + const endY = startY + net; + const points = []; + for (let i = 0; i <= HALFPIPE_SAMPLES; i++) { + const t = i / HALFPIPE_SAMPLES; + const x = startX + width * t * t * (3 - 2 * t); + const y = startY + + 3 * drop * t * (1 - t) * (1 - t) + + 3 * drop * t * t * (1 - t) + + net * t * t * t; + points.push({ x: Math.round(x), y: Math.round(y) }); + } + // Snap the ends exactly so the section joins its neighbors seamlessly. + points[0] = { x: startX, y: Math.round(startY) }; + points[points.length - 1] = { x: startX + width, y: Math.round(endY) }; + + return { type: 'simple', points, endY }; +} + +// "Reverse Halfpipe" - the halfpipe flipped upside down (an upside-down J). +// One smooth profile, left to right: a near-vertical climb (vertical tangent +// at the entrance) that arcs up and over like a rainbow, then a +// near-vertical drop (vertical tangent at the exit) that ends BELOW the +// entrance. +// +// It's the exact vertical mirror of halfpipe about the line +// y = startY + 176 (the midpoint of its own end-to-end span): same x(t), +// same Bezier family, signs flipped, and the two ends swapped. Where +// halfpipe's entry is the long leg (drop) and exit the short one (climb), +// here the entry is the short leg (rise, ~half the drop) and the exit the +// long one (drop), so the section still nets lower on the right - the +// mirror of halfpipe's net-decline shape. +// +// Control handles stick straight UP from both endpoints (mirror of +// halfpipe's hanging handles): +// +// P0 = entrance P1 = (P0.x, P0.y - rise) [entry handle] +// P3 = exit P2 = (P3.x, P3.y - drop) [exit handle] +// +// x(t) = W * t^2 * (3 - 2t) (same as halfpipe, monotonic in x) +// y(t) = -3*rise*t*(1-t)^2 - 3*rise*t^2*(1-t) + (drop - rise)*t^3 +// +// rise/drop (both vScale-scaled) are the end-handle lengths. At the +// default 700-wide section the arch crests ~176 base units above the +// entrance ("arcs up and over"), and the exit ends ~210 below it ("ends +// lower than the entrance") - the mirror image of halfpipe's 348-deep, +// 210-net-decline profile. End-segment widths stay above +// Terrain._buildSegment's |dx| < 1 skip threshold even at the editor's +// minimum width. +const REVERSE_HALFPIPE_RISE = 210; +const REVERSE_HALFPIPE_DROP = 420; // = 2 * RISE, the longer leg +const REVERSE_HALFPIPE_SAMPLES = 24; + +function reverseHalfpipe(startX, startY, width, vScale) { + const rise = REVERSE_HALFPIPE_RISE * vScale; + const drop = REVERSE_HALFPIPE_DROP * vScale; + const net = drop - rise; // positive: exit is lower than entrance + + const endY = startY + net; + const points = []; + for (let i = 0; i <= REVERSE_HALFPIPE_SAMPLES; i++) { + const t = i / REVERSE_HALFPIPE_SAMPLES; + const x = startX + width * t * t * (3 - 2 * t); + const y = startY + - 3 * rise * t * (1 - t) * (1 - t) + - 3 * rise * t * t * (1 - t) + + net * t * t * t; + points.push({ x: Math.round(x), y: Math.round(y) }); + } + // Snap the ends exactly so the section joins its neighbors seamlessly. + points[0] = { x: startX, y: Math.round(startY) }; + points[points.length - 1] = { x: startX + width, y: Math.round(endY) }; + + return { type: 'simple', points, endY }; +} + const GENERATORS = { flat, smoothIncline, @@ -313,6 +439,8 @@ const GENERATORS = { gap, lip, dropGap, + halfpipe, + reverseHalfpipe, }; export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) {