Testing/Maneuvers/SlalomManeuver.cs

A maneuver component for a vehicle test that steers a car through a slalom of cones. It computes a position-locked look-ahead target alternating left/right, applies a pure-pursuit steer with yaw-rate dependent gain damping, controls throttle, counts cone strikes by proximity to scene objects named "Slalom Cone", and reports results.

File Access
namespace VehicleProto;

/// <summary>Position-locked pure-pursuit weave through alternating cones (NOT the old open-loop
/// sin(time) steer that drifted out of phase and plowed a cone). Tracks forward progress from the
/// spawn frame and steers a look-ahead line that passes each cone on its open side; completes a full
/// gate past the last cone. Counts cone strikes by proximity to live "Slalom Cone" objects.</summary>
public sealed class SlalomManeuver : ManeuverBase
{
	public override string Name => "slalom";

	bool _coneHitThisTick;

	public override void Start( ManeuverContext ctx ) => _coneHitThisTick = false;

	public override bool Tick( ManeuverContext ctx, float dt )
	{
		_coneHitThisTick = false; // per-tick edge for cone-proximity detection

		float spacing = ctx.Param( "coneSpacingM", 18f );
		float coneCount = ctx.Param( "coneCount", 8f );
		// Cones sit only ±2.5 m off a clear centerline, so a SMALL weave already passes each on its
		// open side; a big swing at gate spacing would demand >2 g lateral (far beyond ~0.7 g grip)
		// and the car would understeer wide into the cone. Keep amplitude/cruise inside tire grip.
		float amp = ctx.Param( "weaveAmpM", 1.0f );
		float lookahead = ctx.Param( "lookaheadM", 7f );
		float cruise = ctx.Param( "cruiseSpeedMs", 16f );   // ~58 km/h; a ±1.0 m weave is grip-followable here

		var carPosM = ctx.Car.WorldPosition * Units.UnitsToMeters;
		var rel = (carPosM - ctx.Telemetry.StartPosM).WithZ( 0 );
		float fwd = Vector3.Dot( rel, ctx.Telemetry.StartFwd );      // forward progress from spawn (m)

		// Cones sit at forward f = spacing*(i+1), alternating +lateral (even i) / -lateral (odd i) along
		// the start-frame LEFT axis. Target the side OPPOSITE the cone nearest the look-ahead point — a
		// square target the pure-pursuit smooths into a weave. Keyed to POSITION (not sin(time)).
		float fAhead = fwd + lookahead;
		int coneIdx = (int)MathF.Round( (fAhead - spacing) / spacing );
		coneIdx = Math.Clamp( coneIdx, 0, (int)coneCount - 1 );
		float targetLat = (coneIdx % 2 == 0) ? -amp : amp;   // opposite the cone's own side
		var targetPt = ctx.Telemetry.StartPosM + ctx.Telemetry.StartFwd * fAhead + ctx.Telemetry.StartLeft * targetLat;

		var toTarget = (targetPt - carPosM).WithZ( 0 );
		var carFwd = ctx.Car.WorldRotation.Forward.WithZ( 0 ).Normal;
		float cross = carFwd.x * toTarget.y - carFwd.y * toTarget.x; // +z = target to the LEFT (CCW)
		float dot = carFwd.x * toTarget.x + carFwd.y * toTarget.y;
		float angErr = MathF.Atan2( cross, dot );                    // rad; + = steer LEFT (+Steer)

		// Pilot hardening (2026-07-13): the fixed high-gain pursuit (angErr*2.0) drove the
		// light twitchy RWD kart at its stability boundary, so its cold-first-play sample diverged from
		// the warm attractor (18.5 s / 309 deg/s vs 16.1 s / 180). A yaw-rate backoff (drift-catch-style
		// damping, PILOT-ONLY — no CarDefinition dial touched, per the standing rule that the harness may
		// not mask car character) bleeds steer gain as |yawRate| climbs PAST a gate set ABOVE every stable
		// car's peak (hatch 36 / pickup 72 / coupe 149 deg/s at baseline), so hatch/coupe/pickup steer
		// BIT-IDENTICALLY (excess=0 -> gain=baseGain) while the kart's fishtail excursions are arrested
		// and its cold==warm outcome converges. The gate stays >150 so the kart still reads its twitchy
		// signature (not flattened below ~150). Defaults leave every existing spec row unchanged; all
		// three knobs are Param-tunable.
		float baseGain = ctx.Param( "steerGain", 2.0f );
		float yawGate = ctx.Param( "steerYawDampGateDeg", 155f );    // below this: no damping (stable cars)
		float yawDampRef = ctx.Param( "steerYawDampRefDeg", 160f );  // larger excess -> more gain backoff
		float yawNowDeg = ctx.Body.IsValid() ? MathF.Abs( ctx.Body.AngularVelocity.z.RadianToDegree() ) : 0f;
		float yawExcess = MathF.Max( 0f, yawNowDeg - yawGate );
		float gain = baseGain / (1f + yawExcess / MathF.Max( yawDampRef, 1e-3f ));
		float steer = Math.Clamp( angErr * gain, -1f, 1f );

		float throttle = VehicleBridge.SpeedMs < cruise ? 0.7f : 0.15f;
		ctx.Drive( throttle, steer, false );
		CountConeStrikes( ctx );

		// done once the car is a full gate past the last cone (cone N-1 is at (coneCount)*spacing).
		return fwd > (coneCount + 1f) * spacing;
	}

	void CountConeStrikes( ManeuverContext ctx )
	{
		// proximity to any live "Slalom Cone" GameObject (no TestTrack change needed). A car within
		// ~1.2 m of a cone centre this tick counts as a strike edge.
		if ( _coneHitThisTick ) return;
		float m = Units.MetersToUnits;
		float rM = 1.2f;
		var carPos = ctx.Car.WorldPosition;
		foreach ( var go in ctx.Car.Scene.GetAllObjects( true ) )
		{
			if ( go.Name is null || !go.Name.StartsWith( "Slalom Cone" ) ) continue;
			if ( (go.WorldPosition.WithZ( carPos.z ) - carPos).Length < rM * m )
			{
				VehicleBridge.ConeStrikes++;
				_coneHitThisTick = true;
				break;
			}
		}
	}

	public override void Report( RunVerdict v )
	{
		v.Add( $"coneStrikes={VehicleBridge.ConeStrikes}", "Cone strikes", $"{VehicleBridge.ConeStrikes}" );
		v.Add( $"elapsedS={VehicleBridge.ElapsedS:F1}", "Elapsed", $"{VehicleBridge.ElapsedS:F1} s" );
		v.Add( $"yawRatePeakDeg={VehicleBridge.YawRatePeakDeg:F1}", "Yaw rate peak", $"{VehicleBridge.YawRatePeakDeg:F1} deg/s" );
	}
}