Add halfpipe and reverseHalfpipe section generators

- Register `halfpipe` and `reverseHalfpipe` in SECTION_TYPES and GENERATORS
- Implement both as single cubic Bezier curves with vertical tangents at entrance/exit:
  - halfpipe: near-vertical drop into a rounded bottom, then a shorter climb; exit ends below entrance (net decline)
  - reverseHalfpipe: vertical mirror of halfpipe — short rise arcing up and over, then a longer drop; exit also ends below entrance
- Use monotonic x(t) = W * t^2 * (3 - 2t) to keep terrain valid as y(x)
- Sample each profile with 24 points and snap endpoints exactly for seamless section joins
- Scale vertical magnitudes by vScale; chosen defaults yield ~350 base-unit drop and ~210 net decline at default width
This commit is contained in:
Brian Fertig 2026-08-23 13:57:18 -06:00
parent b8b6e74c8d
commit fba3ed040d
1 changed files with 128 additions and 0 deletions

View File

@ -31,6 +31,8 @@ export const SECTION_TYPES = [
{ id: 'gap', label: 'Gap' }, { id: 'gap', label: 'Gap' },
{ id: 'lip', label: 'Lip' }, { id: 'lip', label: 'Lip' },
{ id: 'dropGap', label: 'Drop Gap' }, { id: 'dropGap', label: 'Drop Gap' },
{ id: 'halfpipe', label: 'Halfpipe' },
{ id: 'reverseHalfpipe', label: 'Reverse Halfpipe' },
]; ];
const RISE_SMOOTH = 90; const RISE_SMOOTH = 90;
@ -297,6 +299,130 @@ function dropGap(startX, startY, width, vScale) {
return { type: 'gap', takeoffPoints, landingPoints, endY: landingY }; 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 = { const GENERATORS = {
flat, flat,
smoothIncline, smoothIncline,
@ -313,6 +439,8 @@ const GENERATORS = {
gap, gap,
lip, lip,
dropGap, dropGap,
halfpipe,
reverseHalfpipe,
}; };
export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) { export function generateSection(typeId, startX, startY, width = SECTION_WIDTH, vScale = 1) {