World/PlaygroundBuilder.cs

Scene builder for the Playground world. It creates a flat pad or gentle terrain, places runways, kicker ramps, rhythm lines, opposed gap pairs, a big-air set-piece, boxes, a ball pit, a banked bowl and perimeter walls, and returns spawn position/facing.

File AccessNative Interop
namespace VehicleProto;

/// <summary>Layout result for the playground world — where the default car spawns.</summary>
public sealed class PlaygroundLayout
{
	public Vector3 SpawnPosition { get; set; }
	public Rotation SpawnFacing { get; set; } = Rotation.Identity;
}

/// <summary>
/// The PLAYGROUND world: a pure STUNT PARK (build 9 redesign, pass 2). No town, no buildings, no city
/// road network — just a big flat playfield MAXED OUT with jumps of every size (small pops up to
/// huge-air), chained rhythm lines, side-by-side opposed gap jumps, a big-air launch, a jump-onto-box,
/// a ball pit, and a few blockout stunt extras, so it reads as a distinct map from the proving-grounds
/// measurement scene. Kept from the old layout: the flat ground core + outer rolling-hills terrain
/// (<see cref="PlaygroundTerrain"/>), the banked bowl, and the sealing perimeter wall.
///
/// Every solid feature follows the standing DRIVABILITY LAW: entry tangent to grade (no wheel-catching
/// lip), closed underside (no drive-under trap), collision that follows the actual curved shape (the
/// <see cref="RampKicker"/> segmented tangent-box collider). Curved kickers come from RampKicker.
///
/// ORIENTATION LAW (pass 2): jumps along one DRIVE LINE all face the SAME way (a rhythm run you link);
/// jumps that sit SIDE BY SIDE (adjacent, not in-line) face OPPOSITE ways — an opposed gap pair whose
/// two lips point at each other — so from any approach heading a player meets a launch FACE, never just
/// a wedge back. Directional set-pieces (the height ladder, the big-air) are the deliberate exception:
/// each has its own one-way runway. Deterministic: fixed layout + a fixed ball table, no RNG.
/// </summary>
public static class PlaygroundBuilder
{
	const float M = Units.MetersToUnits;

	// palette (plain greybox stunt-park)
	static readonly Color GroundTan = new( 0.62f, 0.58f, 0.48f );
	static readonly Color RampGrey = new( 0.52f, 0.52f, 0.58f );
	static readonly Color BoxGrey = new( 0.55f, 0.55f, 0.60f );
	static readonly Color BowlGrey = new( 0.46f, 0.48f, 0.54f );
	static readonly Color RunwayGrey = new( 0.30f, 0.30f, 0.33f );

	// The five showcase kicker heights (metres). Distinct, clearly-different pops spread small→huge;
	// colour-coded green→red so a player reads the height at a glance, with a matching marker pylon
	// beside each ladder kicker. The tall end is the ~2–3× scale-up (pass 2): 4.5 m is a big-air pop.
	static readonly float[] LadderHeights = { 0.6f, 1.2f, 2.0f, 3.0f, 4.5f };

	public const float GroundHalf = 260f;   // m — flat pad half-extent (terrain flat core ±172 m sits inside)

	static Scene _scene;
	static GameObject _root;
	static bool _terrain;

	// running census (reported at the end)
	static int _ramps, _bowlSegs, _balls, _boxes;

	/// <summary>Build the playground. <paramref name="terrain"/> = W4 Option B: replace the flat pad
	/// with a gentle heightfield (structures still sit on its flat core). Default false = Option A
	/// (flat plane), the accepted fallback and the safe raycast-wheel baseline.</summary>
	public static PlaygroundLayout Build( Scene scene, bool terrain = false )
	{
		_scene = scene;
		_root = scene.CreateObject();
		_root.Name = "Playground";
		_terrain = terrain;
		_ramps = _bowlSegs = _balls = _boxes = 0;

		BuildGround();

		// ---- runup guides (flush, non-colliding dark strips that read as "build speed here") ----
		Runway( new Vector2( -150f, 0f ), new Vector2( -40f, 0f ), 16f );     // spawn → kicker ladder
		Runway( new Vector2( -150f, 90f ), new Vector2( -30f, 90f ), 12f );   // west feed → rhythm field
		Runway( new Vector2( 5f, -130f ), new Vector2( 95f, -130f ), 14f );   // big-air runway (south-east)

		BuildKickerLadder( new Vector2( -60f, 0f ) );   // the FIVE showcase heights, side by side (one-way)
		BuildRhythmLines();                             // chained same-direction kicker lines you rhythm
		BuildBigAir( new Vector2( 95f, -130f ) );       // huge 6 m launch + landing ramp down the runway
		BuildOpposedPairs();                            // side-by-side opposed gap jumps (bidirectional)
		BuildScatterSingles();                          // more coverage singles across the open field
		BuildJumpOntoBox( new Vector2( 40f, -25f ) );   // launch → land on an elevated box → ramp down
		BuildBallPit( new Vector2( 110f, 85f ) );
		BuildStuntExtras();                             // tabletop, step-stairs, wall-ride

		BuildBankedBowl( new Vector2( -125f, -125f ), radiusM: 34f, bankDeg: 26f );  // SW corner, clear of the ladder lanes
		BuildPerimeterWall();

		var layout = new PlaygroundLayout
		{
			// open west apron, facing east straight down the ladder runway
			SpawnPosition = new Vector3( -150f, 0f, 0f ),
			SpawnFacing = Rotation.Identity,
		};

		Log.Info( $"[vp] playground (stunt park) built: {_ramps} kickers, {_boxes} boxes/platforms, " +
			$"banked bowl {_bowlSegs} segs, {_balls} balls" );
		return layout;
	}

	// ---------------------------------------------------------------- ground

	static void BuildGround()
	{
		if ( _terrain )
		{
			// W4 Option B: gentle heightfield (all stunt features sit on its flat core). Tripwire: if
			// raycast wheels misbehave on the collision, flip vp_terrain off → this flat plane returns.
			PlaygroundTerrain.Build( _scene, _root, GroundHalf );
			return;
		}

		var go = Child( "Ground" );
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/plane.vmdl" );
		renderer.Tint = GroundTan;
		go.LocalScale = new Vector3( GroundHalf * 2f * M / 100f, GroundHalf * 2f * M / 100f, 1f );

		var collider = go.Components.Create<BoxCollider>();
		collider.Scale = new Vector3( 100f, 100f, 200f );
		collider.Center = Vector3.Down * 100f;
		collider.Static = true;
		go.Tags.Add( "road" );
	}

	// ---------------------------------------------------------------- kicker ladder (5 heights)

	/// <summary>The FIVE distinct heights laid out side by side, all launching +X, colour-coded by
	/// height with a matching marker pylon — the "pick your height" showcase. Long open runup west of
	/// it (the spawn apron + runway) so a car arrives with speed on any lane.</summary>
	static void BuildKickerLadder( Vector2 baseAtM )
	{
		float laneGap = 24f;                       // lateral spacing between the five lanes
		float y0 = -laneGap * (LadderHeights.Length - 1) * 0.5f;
		for ( int i = 0; i < LadderHeights.Length; i++ )
		{
			float h = LadderHeights[i];
			var at = new Vector2( baseAtM.x, baseAtM.y + y0 + i * laneGap );
			Kicker( at, 0f, lenM: 5f * h, widthM: 8f, heightM: h, HeightColor( h ) );
			// marker pylon off to the +Y side of the lane (out of the drive path)
			MarkerPost( new Vector2( at.x, at.y + 5.5f ), h );
		}
	}

	// ---------------------------------------------------------------- chained rhythm lines

	/// <summary>Several CHAINED lines: kickers in a row all facing +X (the ORIENTATION LAW's same-way
	/// drive line) at jumpable spacing so a car can rhythm multiple jumps. Spacing derived from the
	/// launch arc under the 1.1 g gravity: low kickers land ~14 m past the lip, mid ~19 m, the big
	/// ones farther — bases sit lip + air + a little flat before the next base.</summary>
	static void BuildRhythmLines()
	{
		// mid line: four 1.0 m kickers, ~28 m base-to-base (arc lands ~19 m past a 5 m-long ramp lip)
		ChainLine( new Vector2( -10f, 55f ), 1.0f, count: 4, spacingM: 28f, widthM: 7f );
		// fast-low line: three 0.6 m kickers, tight ~22 m spacing — quick pop-pop-pop
		ChainLine( new Vector2( -20f, 95f ), 0.6f, count: 3, spacingM: 22f, widthM: 7f );
		// BIG line (scaled ~2×): three 2.5 m kickers, ~46 m spacing so you must carry real speed to link
		ChainLine( new Vector2( -25f, 135f ), 2.5f, count: 3, spacingM: 46f, widthM: 9f );
		// south rhythm: three 1.2 m kickers for southern-field coverage, ~30 m spacing
		ChainLine( new Vector2( -30f, -45f ), 1.2f, count: 3, spacingM: 30f, widthM: 7f );
	}

	static void ChainLine( Vector2 firstBaseM, float heightM, int count, float spacingM, float widthM )
	{
		var col = HeightColor( heightM );
		for ( int i = 0; i < count; i++ )
			Kicker( new Vector2( firstBaseM.x + i * spacingM, firstBaseM.y ), 0f,
				lenM: 5f * heightM, widthM: widthM, heightM: heightM, col );
	}

	// ---------------------------------------------------------------- scattered singles

	/// <summary>Coverage singles filling the open CORNERS/EDGES, each facing OUTWARD toward its corner so
	/// its launch face meets the only realistic approach (from open field) and its back sits against the
	/// wall where nobody drives — the lone-jump form of the orientation law. Fixed placements (determinism
	/// law). Anything adjacent to another jump is an opposed pair instead (see BuildOpposedPairs).</summary>
	static void BuildScatterSingles()
	{
		// (x, y, yawDeg-facing-the-corner, heightM), spread small→large
		var singles = new (float x, float y, float yaw, float h)[]
		{
			(  150f,  150f,  45f, 2.0f ),   // NE corner
			( -140f,  140f, 135f, 1.5f ),   // NW corner
			(  150f, -150f, 315f, 3.0f ),   // SE corner (big)
			( -140f, -140f, 225f, 1.0f ),   // SW corner
			(  165f,   10f,   0f, 1.2f ),   // E edge, faces the wall
		};
		foreach ( var s in singles )
			Kicker( new Vector2( s.x, s.y ), s.yaw, lenM: 5f * s.h, widthM: 7f, heightM: s.h, HeightColor( s.h ) );
	}

	// ---------------------------------------------------------------- big air (scaled-up set-piece)

	/// <summary>The BIG-AIR launch: a huge 6 m kicker at the end of its own long runway, then a matching
	/// DOWN landing ramp across a gap so a fast car sends it and rolls out clean. A directional
	/// set-piece (one-way, like the ladder) — approached only down the runway, so a same-way launch face
	/// is correct here. The scaled-up 3× jump of the park.</summary>
	static void BuildBigAir( Vector2 baseAtM )
	{
		const float launchH = 6f, landH = 3f;
		float launchLen = 5f * launchH;                 // = 30 m base (long, gentle tangent)
		float lipX = baseAtM.x + launchLen;             // launch edge
		float gap = 34f;                                // clear-air gap over the flat
		float landLipX = lipX + gap;                    // landing ramp's high lip
		float landLen = 5f * landH;

		// launch (car flies off this, +X)
		Kicker( baseAtM, 0f, launchLen, 10f, launchH, HeightColor( launchH ) );
		// landing DOWN-ramp: yaw-180 kicker, high lip at landLipX facing the incoming car, descending +X
		Kicker( new Vector2( landLipX + landLen, baseAtM.y ), 180f, landLen, 10f, landH, HeightColor( landH ) );
	}

	// ---------------------------------------------------------------- opposed gap pairs (bidirectional)

	/// <summary>The ORIENTATION LAW's side-by-side case: pairs of kickers that sit near each other with
	/// their lips pointed AT each other across a gap, one launching +X and one launching −X. From either
	/// heading you meet a launch face — a proper double-sided jump, never a wedge back. Spread across the
	/// open mid-field for coverage from every direction.</summary>
	static void BuildOpposedPairs()
	{
		// (centreX, centreY, gapM, heightM)
		var pairs = new (float cx, float cy, float gap, float h)[]
		{
			(   10f,  20f, 20f, 1.0f ),
			(   25f, -105f, 24f, 1.5f ),
			(  -50f,  60f, 20f, 1.2f ),
			(  120f,  40f, 26f, 2.0f ),
		};
		foreach ( var p in pairs )
			OpposedGapPair( new Vector2( p.cx, p.cy ), p.gap, p.h );
	}

	/// <summary>Two kickers of equal height facing each other across a gap centred on
	/// <paramref name="centreM"/>: a +X kicker on the west side and a −X (yaw-180) kicker on the east
	/// side, both lips pointing into the gap.</summary>
	static void OpposedGapPair( Vector2 centreM, float gapM, float heightM )
	{
		float len = 5f * heightM;
		var col = HeightColor( heightM );
		// west kicker: launches +X, high lip at centre − gap/2
		Kicker( new Vector2( centreM.x - gapM * 0.5f - len, centreM.y ), 0f, len, 7f, heightM, col );
		// east kicker: launches −X (yaw 180), high lip at centre + gap/2 (base = lip + len further east)
		Kicker( new Vector2( centreM.x + gapM * 0.5f + len, centreM.y ), 180f, len, 7f, heightM, col );
	}

	// ---------------------------------------------------------------- jump onto a box

	/// <summary>A 2.2 m launch kicker, then an AIR GAP, then an elevated drivable box whose top sits a
	/// bit below the jump apex (landable at speed, missable when slow), then a curved kicker DOWN off
	/// the far edge so you are never stranded. Launch arc at ~20 m/s apexes ~4.9 m ≈ 13 m past the lip;
	/// the box top at 3.2 m is under that apex, so a fast car drops onto it and a slow car lands short
	/// on the flat.</summary>
	static void BuildJumpOntoBox( Vector2 baseAtM )
	{
		const float launchH = 2.2f, boxTop = 3.2f, boxDepth = 15f, boxWidth = 12f, downLen = 11f;
		float launchLen = 5f * launchH;                         // = 11 m
		float lipX = baseAtM.x + launchLen;                     // where the launch kicker throws you
		float boxFrontX = lipX + 12f;                           // 12 m air gap over the flat
		float boxCx = boxFrontX + boxDepth * 0.5f;
		float boxBackX = boxFrontX + boxDepth;

		// the launch kicker (car flies off this, unconnected to the box)
		Kicker( baseAtM, 0f, launchLen, boxWidth, launchH, HeightColor( launchH ) );

		// the box: a solid closed block resting on grade, drivable flat top at boxTop
		Block( new Vector3( boxCx, baseAtM.y, boxTop * 0.5f ) * M,
			new Vector3( boxDepth, boxWidth, boxTop ), BoxGrey, name: "JumpBox" );
		_boxes++;

		// down-ramp off the far edge: a yaw-180 kicker whose lip (height boxTop) lands exactly on the
		// box's back-top edge and descends +X back to grade — seamless roll-off, sealed underside.
		Kicker( new Vector2( boxBackX + downLen, baseAtM.y ), 180f, downLen, boxWidth, boxTop, BoxGrey );
	}

	// ---------------------------------------------------------------- ball pit

	/// <summary>A scatter of large dynamic spheres in a designated field — cars punt them around. Two
	/// sizes/colours, masses tuned so a ~1.2 t car visibly launches them (light enough to fly) without
	/// them jittering on the ground (heavy enough to settle). Fixed table = deterministic. 12 balls.</summary>
	static void BuildBallPit( Vector2 centreM )
	{
		// (dx, dy) offsets in metres, big? , within a ~±26 m field
		var slots = new (float dx, float dy, bool big)[]
		{
			( -22f, -14f, true ),  (  -8f,  -20f, false ), (   6f, -12f, true ),
			(  20f, -18f, false ), (  24f,   2f, true ),   (  10f,   8f, false ),
			(  -4f,  16f, true ),  ( -18f,   6f, false ),  ( -26f,  18f, true ),
			(  16f,  20f, false ), (   0f,  -2f, false ),  ( -12f,  -4f, true ),
		};
		foreach ( var s in slots )
		{
			float dia = s.big ? 2.4f : 1.4f;
			float mass = s.big ? 45f : 20f;
			var col = s.big ? new Color( 0.85f, 0.35f, 0.30f ) : new Color( 0.30f, 0.55f, 0.80f );
			DynamicBall( new Vector2( centreM.x + s.dx, centreM.y + s.dy ), dia, mass, col );
		}
	}

	static void DynamicBall( Vector2 atM, float diamM, float massKg, Color color )
	{
		float radiusM = diamM * 0.5f;
		var go = Child( "Ball" );
		go.WorldPosition = new Vector3( atM.x, atM.y, radiusM ) * M;   // rest on the ground
		go.LocalScale = Vector3.One * (diamM * M / 100f);             // sphere.vmdl is 100 u dia at scale 1

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/sphere.vmdl" );
		renderer.Tint = color;

		var collider = go.Components.Create<SphereCollider>();
		collider.Radius = 50f;   // base sphere radius in units; scales with LocalScale → radiusM

		var rb = go.Components.Create<Rigidbody>();
		rb.MassOverride = massKg;
		rb.LinearDamping = 0.2f;
		rb.AngularDamping = 0.4f;
		_balls++;
	}

	// ---------------------------------------------------------------- stunt extras

	static void BuildStuntExtras()
	{
		BuildTabletop( new Vector2( -10f, -75f ) );
		BuildStepStairs( new Vector2( 100f, -50f ) );
		BuildWallRide( new Vector2( 125f, 20f ) );
	}

	/// <summary>Classic tabletop: up-kicker → flat deck → down-kicker, deck top and both lips share
	/// deckTop so the run is seamless. Launch off the front, land on the deck or clear it to the far
	/// down-ramp.</summary>
	static void BuildTabletop( Vector2 centreM )
	{
		const float deckHalf = 7f, kickLen = 8f, deckTop = 1.4f, w = 9f;
		Kicker( new Vector2( centreM.x - deckHalf - kickLen, centreM.y ), 0f, kickLen, w, deckTop, RampGrey );
		Block( new Vector3( centreM.x, centreM.y, deckTop * 0.5f ) * M,
			new Vector3( deckHalf * 2f, w, deckTop ), RampGrey, name: "TabletopDeck" );
		_boxes++;
		Kicker( new Vector2( centreM.x + deckHalf + kickLen, centreM.y ), 180f, kickLen, w, deckTop, RampGrey );
	}

	/// <summary>A drivable staircase: shallow 0.5 m steps a car climbs at speed up to a high deck, then
	/// a curved kicker DOWN the far side. Steps are solid boxes resting on grade (no underside gap).</summary>
	static void BuildStepStairs( Vector2 baseAtM )
	{
		const int steps = 5;
		const float rise = 0.5f, depth = 4.5f, w = 9f;
		for ( int i = 0; i < steps; i++ )
		{
			float top = (i + 1) * rise;
			// each step spans grade→top and is depth deep; place its centre so the front face is climbable
			float cx = baseAtM.x + i * depth + depth * 0.5f;
			Block( new Vector3( cx, baseAtM.y, top * 0.5f ) * M, new Vector3( depth, w, top ), BoxGrey, name: "Step" );
			_boxes++;
		}
		float deckTopH = steps * rise;                       // = 2.5 m
		float deckBackX = baseAtM.x + steps * depth;
		// roll-off the back: yaw-180 kicker landing on the top step's back edge, descending +X to grade
		Kicker( new Vector2( deckBackX + 10f, baseAtM.y ), 180f, 10f, w, deckTopH, BoxGrey );
	}

	/// <summary>A banked wall-ride strip: a long slab rolled steeply so a car can carry along its face
	/// (like a straightened bowl segment). Its lower edge meets grade tangent-ish; purely a play toy.</summary>
	static void BuildWallRide( Vector2 centreM )
	{
		const float lenM = 34f, faceM = 7f, thickM = 0.5f, rollDeg = 48f;
		var go = Child( "WallRide" );
		// lift so the rolled slab's lower edge sits near grade
		go.WorldPosition = new Vector3( centreM.x, centreM.y, MathF.Sin( rollDeg.DegreeToRadian() ) * faceM * 0.5f ) * M;
		go.WorldRotation = Rotation.FromYaw( 90f ) * Rotation.FromRoll( -rollDeg );
		go.LocalScale = new Vector3( faceM, lenM, thickM ) * M / 50f;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/box.vmdl" );
		renderer.Tint = BowlGrey;

		var collider = go.Components.Create<BoxCollider>();
		collider.Scale = new Vector3( 50f, 50f, 50f );
		collider.Static = true;
		_boxes++;
	}

	// ---------------------------------------------------------------- banked bowl (kept)

	static void BuildBankedBowl( Vector2 centreM, float radiusM, float bankDeg )
	{
		// a velodrome-style ring: N wide segments tilted to bank inward, forming a circular banked
		// wall the car can carve around. Flat centre stays drivable.
		const int segs = 24;
		float segLen = 2f * MathF.PI * radiusM / segs * 1.15f; // slight overlap, no gaps
		for ( int i = 0; i < segs; i++ )
		{
			float a = i / (float)segs * MathF.PI * 2f;
			float cx = centreM.x + MathF.Cos( a ) * radiusM;
			float cy = centreM.y + MathF.Sin( a ) * radiusM;
			float yawToCentre = MathF.Atan2( centreM.y - cy, centreM.x - cx ).RadianToDegree();

			var go = Child( "BowlSeg" );
			go.WorldPosition = new Vector3( cx, cy, MathF.Sin( bankDeg.DegreeToRadian() ) * 3.0f ) * M;
			go.WorldRotation = Rotation.FromYaw( yawToCentre ) * Rotation.FromRoll( -bankDeg );
			go.LocalScale = new Vector3( 6f, segLen, 0.4f ) * M / 50f;

			var renderer = go.Components.Create<ModelRenderer>();
			renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
			renderer.Model = Model.Load( "models/dev/box.vmdl" );
			renderer.Tint = BowlGrey;

			var collider = go.Components.Create<BoxCollider>();
			collider.Scale = new Vector3( 50f, 50f, 50f );
			collider.Static = true;
			_bowlSegs++;
		}
	}

	// ---------------------------------------------------------------- perimeter wall (kept)

	/// <summary>Solid wall enclosing the whole drivable playground (the world must NOT be drivable-off).
	/// 8 m tall, 2 m thick, sunk 3 m so it seals any gentle-terrain dip at the edge; static colliders no
	/// car can jump. Sits inside the ground pad and well outside every stunt feature.</summary>
	static void BuildPerimeterWall()
	{
		const float edge = GroundHalf - 6f;   // just inside the flat pad edge
		const float wallH = 8f, wallThick = 2f, sink = 3f;
		float len = edge * 2f + wallThick * 2f;
		float cz = (wallH - sink) * 0.5f;      // centre so the wall spans z[-sink, wallH]
		var wall = new Color( 0.50f, 0.50f, 0.54f );
		var cap = new Color( 0.38f, 0.38f, 0.42f );

		foreach ( var (x, y, sx, sy, nm) in new[]
		{
			( 0f, edge, len, wallThick, "Perimeter Wall N" ),
			( 0f, -edge, len, wallThick, "Perimeter Wall S" ),
			( edge, 0f, wallThick, len, "Perimeter Wall E" ),
			( -edge, 0f, wallThick, len, "Perimeter Wall W" ),
		} )
		{
			Block( new Vector3( x, y, cz ) * M, new Vector3( sx, sy, wallH + sink ), wall, collide: true, name: nm );
			Block( new Vector3( x, y, wallH + 0.15f ) * M, new Vector3( sx + 0.5f, sy + 0.5f, 0.3f ), cap, collide: false, name: nm + " Cap" );
		}
	}

	// ---------------------------------------------------------------- helpers

	/// <summary>Colour a feature by launch height, green (low) → red (tall), so heights read at a
	/// glance across the whole park.</summary>
	static Color HeightColor( float h )
	{
		// clamp to the height range and lerp green→yellow→red (biggest air = reddest)
		float t = MathX.Clamp( (h - 0.6f) / (4.5f - 0.6f), 0f, 1f );
		var green = new Color( 0.35f, 0.75f, 0.35f );
		var yellow = new Color( 0.92f, 0.80f, 0.28f );
		var red = new Color( 0.85f, 0.27f, 0.24f );
		return t < 0.5f ? Color.Lerp( green, yellow, t * 2f ) : Color.Lerp( yellow, red, (t - 0.5f) * 2f );
	}

	/// <summary>A tall thin colour-coded marker pylon (solid) placed OUT of the drive path to label a
	/// ladder kicker's height.</summary>
	static void MarkerPost( Vector2 atM, float heightM )
	{
		Block( new Vector3( atM.x, atM.y, 2f ) * M, new Vector3( 0.4f, 0.4f, 4f ), HeightColor( heightM ), name: "HeightPost" );
	}

	/// <summary>A flush, non-colliding dark ribbon that visually invites a speed runup (pure dressing,
	/// carries no collision — the flat pad/terrain carries the ground).</summary>
	static void Runway( Vector2 fromM, Vector2 toM, float widthM )
	{
		var mid = (fromM + toM) * 0.5f;
		var d = toM - fromM;
		float len = d.Length;
		float yaw = MathF.Atan2( d.y, d.x ).RadianToDegree();
		var go = Child( "Runway" );
		go.WorldPosition = new Vector3( mid.x, mid.y, 0.02f ) * M;
		go.WorldRotation = Rotation.FromYaw( yaw );
		go.LocalScale = new Vector3( len, widthM, 0.02f ) * M / 50f;
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/box.vmdl" );
		renderer.Tint = RunwayGrey;
	}

	/// <summary>Place a curved solid launch kicker (base tangent to grade → no lip; collision follows
	/// the curved face; closed underside → no drive-under gap).</summary>
	static void Kicker( Vector2 baseAtM, float yawDeg, float lenM, float widthM, float heightM, Color color )
	{
		RampKicker.Build( _scene, _root, new Vector3( baseAtM.x, baseAtM.y, 0f ), yawDeg, lenM, widthM, heightM, color );
		_ramps++;
	}

	static GameObject Block( Vector3 position, Vector3 sizeMeters, Color color, bool collide = true, string name = "Block" )
	{
		var go = Child( name );
		go.WorldPosition = position;
		go.LocalScale = sizeMeters * M / 50f;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/box.vmdl" );
		renderer.Tint = color;

		if ( collide )
		{
			var collider = go.Components.Create<BoxCollider>();
			collider.Scale = new Vector3( 50f, 50f, 50f );
			collider.Static = true;
		}
		return go;
	}

	static GameObject Child( string name )
	{
		var go = _scene.CreateObject();
		go.Name = name;
		go.SetParent( _root, true );
		return go;
	}
}