Code/VehicleFactory.cs

Factory that spawns a drivable vehicle GameObject from a CarDefinition at runtime. It creates the root, rigidbody and collider, wheels and wheel visuals, optional modeled blockout body or primitive box/kart bodies, driver avatar and audio components, and configures VehicleController and wheel data.

File AccessNetworking
namespace FieldGuide.VehiclePhysics;

/// <summary>
/// Builds a drivable blockout car from a CarDefinition at runtime: box body, sphere wheels
/// (30-minute blockouts that genuinely fit the art style). No prefab assets needed; swap to real
/// models later without touching physics via the <see cref="CustomBodyBuilder"/> seam.
/// </summary>
public static class VehicleFactory
{
	/// <summary>
	/// Optional custom body builder seam (spec 3.1). When set, it is called with
	/// (scene, carRoot, def) to build a custom VISUAL body under the car root; return true if it built
	/// successfully. Null (default) or a false return uses the primitive blockout body path. Physics,
	/// raycast wheels, wheel visuals, the citizen driver, and audio are always factory-built regardless
	/// of the body path — the seam swaps only the visual body, so a consumer (for example a part-kit
	/// assembler) can drop real models in without touching physics. The builder may read
	/// <see cref="CarDefinition.BodyManifest"/> to decide what to assemble.
	/// </summary>
	public static Func<Scene, GameObject, CarDefinition, bool> CustomBodyBuilder { get; set; }

	// Default citizen driver seat point in the car-root frame (metres), used by the blockout kart body
	// and by a custom body that opts into the shared driver. A custom builder may seat its own driver.
	static readonly Vector3 DefaultDriverLocalM = new( 0.05f, 0f, 0.06f );

	// ---- blockout model assets (owner-approved art pass; tools/vehicle_blockouts) ----
	// Kit-Assets-relative paths. Keyed by roster Name so CarDefinition stays untouched
	// (physics contract); an unmapped name or missing model falls back to the primitive
	// box/kart builders, so a consumer with a custom roster loses nothing.
	static readonly Dictionary<string, string> BlockoutBodyModels = new()
	{
		["Compact Hatch"] = "models/vehicle_blockouts/body_hatch.vmdl",
		["Sports Coupe"] = "models/vehicle_blockouts/body_coupe.vmdl",
		["Go-Kart"] = "models/vehicle_blockouts/body_kart.vmdl",
		["Utility Pickup"] = "models/vehicle_blockouts/body_pickup.vmdl",
	};

	const string TireModelPath = "models/vehicle_blockouts/tire.vmdl";

	// Canonical radius (m) the shared tire model is exported at; wheel visuals scale by
	// def.WheelRadius / this so the rendered tire always matches the physics contact
	// radius. See tools/vehicle_blockouts/README.md.
	const float TireModelRadiusM = 0.30f;

	// Imported blockout meshes follow the proven OBJ frame contract (authored +X forward
	// imports as model-local +Y forward), so every blockout BODY GameObject wears this yaw
	// to face the car root's +X. The tire needs no yaw: its axle is authored to import as
	// model-local +Y, the axis WheelVisual spins about.
	static readonly Rotation BlockoutBodyYaw = Rotation.FromYaw( -90f );

	public static GameObject Spawn( Scene scene, CarDefinition def, Vector3 position, Rotation rotation )
	{
		float m = Units.MetersToUnits;

		var root = scene.CreateObject();
		root.Name = def.Name;
		root.Tags.Add( "car" );
		root.WorldPosition = position;
		root.WorldRotation = rotation;

		var rigidbody = root.Components.Create<Rigidbody>();
		rigidbody.MassOverride = def.Mass;
		rigidbody.AngularDamping = 0.3f; // mild yaw settling on all cars
		rigidbody.OverrideMassCenter = true;
		rigidbody.MassCenterOverride = Vector3.Down * def.CenterOfMassDrop * m; // low CoM: better roll behavior

		// collider must stay ABOVE the wheels' contact zone or the car rests on its belly
		// and the suspension traces never reach the ground: bottom sits at ride clearance
		float colliderHeight = MathF.Max( def.BodySize.z * 0.55f, 0.2f );
		float colliderBottom = -(def.RideHeight - def.GroundClearance);
		var collider = root.Components.Create<BoxCollider>();
		collider.Scale = new Vector3( def.BodySize.x, def.BodySize.y, colliderHeight ) * m;
		collider.Center = Vector3.Up * (colliderBottom + colliderHeight * 0.5f) * m;

		// Custom body seam: a consumer can plug in a body builder. Null (default) or a false return
		// falls back to the primitive blockout path so a failed custom build never bricks a spawn.
		// Physics above is identical on every path (same rigidbody, root collider, mass/CoM overrides).
		bool customBody = CustomBodyBuilder is not null && CustomBodyBuilder( scene, root, def );

		if ( !customBody )
		{
			// Modeled blockout body first (visuals only; same physics on every path).
			// Fallback keeps the original primitive builders for unmapped/missing models.
			if ( !TryBuildBlockoutModelBody( scene, root, def ) )
			{
				if ( def.Style == BodyStyle.Kart )
					BuildKartBody( scene, root, def ); // adds its own driver when HasDriver
				else
					BuildBoxBody( scene, root, def );
			}
		}
		else if ( def.HasDriver )
		{
			// A custom body that wants the shared engine citizen driver gets the default seat pose;
			// a builder that seats its own driver can leave HasDriver false.
			AddDriver( scene, root, def, DefaultDriverLocalM );
		}

		var controller = root.Components.Create<VehicleController>();
		controller.Definition = def;

		// Shared placeholder engine audio on every car: one positional looping tone pitched by live RPM
		// and swelled by throttle. Reads the drivetrain off the controller; tuned live via the
		// vp_engine_sound / vp_engine_volume console dials.
		root.Components.Create<EngineAudio>();

		// Skid / traction-loss audio: a positional loop driven by the worst wheel slip (handbrake,
		// power-slide, or lockup). Reads the controller's wheels; muted below a slip threshold.
		root.Components.Create<SkidAudio>();

		// Static wheel load from the LIVE scene gravity: the demo scene runs ~1.1 g explicitly, so
		// mass·9.81/4 would under-reference the true static load and bias the load-sensitivity curve.
		// Read gravity the same way SeatHeightM does.
		float gravity = scene.PhysicsWorld is { } pw
			? pw.Gravity.Length * Units.UnitsToMeters
			: 9.81f;
		float staticLoad = def.Mass * gravity / 4f;

		// wheels: FL, FR, RL, RR at (+-wheelbase/2, +-track/2, -rideHeight)
		for ( int i = 0; i < 4; i++ )
		{
			bool front = i < 2;
			bool left = i % 2 == 0;

			var attach = new Vector3(
				(front ? 1f : -1f) * def.Wheelbase * 0.5f,
				(left ? 1f : -1f) * def.TrackWidth * 0.5f,
				-def.RideHeight ) * m;

			var wheelGo = scene.CreateObject();
			wheelGo.Name = front ? (left ? "Wheel FL" : "Wheel FR") : (left ? "Wheel RL" : "Wheel RR");
			wheelGo.SetParent( root, false );
			wheelGo.LocalPosition = attach;

			var wheel = wheelGo.Components.Create<VehicleWheel>();
			wheel.Radius = def.WheelRadius;
			wheel.Inertia = def.WheelInertia;
			wheel.SuspensionTravel = def.SuspensionTravel;
			wheel.SpringRate = def.SpringRate;
			wheel.DamperRate = def.DamperRate;
			wheel.LongitudinalCurve = def.LongitudinalCurve;
			wheel.LateralCurve = def.LateralCurve;
			wheel.LoadSensitivity = def.LoadSensitivity;
			wheel.StaticLoad = staticLoad;
			wheel.IsSteering = front;
			wheel.HasHandbrake = !front;
			wheel.IsDriven = def.Layout switch
			{
				DriveLayout.FWD => front,
				DriveLayout.RWD => !front,
				_ => true
			};

			controller.Wheels.Add( wheel );

			BuildBlockoutWheelVisual( scene, wheelGo, wheel, def );
		}

		return root;
	}

	/// <summary>Suspension-equilibrium spawn height above the ground (SI m) so the car settles level
	/// instead of dropping or flinging on spawn. Springs already carry the weight at rest, so this is
	/// NOT surface+radius. Reads live scene gravity. Callers add this to their ground spawn point.</summary>
	public static float SeatHeightM( CarDefinition def )
	{
		float gravity = Game.ActiveScene?.PhysicsWorld is { } pw
			? pw.Gravity.Length * Units.UnitsToMeters
			: 9.81f;
		float staticCompression = def.Mass * gravity / 4f / def.SpringRate;
		return def.SuspensionTravel + def.WheelRadius - staticCompression + def.RideHeight;
	}

	/// <summary>Build the modeled blockout body (single mesh, neutral-gray albedo tinted per car)
	/// under the car root. Returns false when this def has no mapped model or the model fails to
	/// load, so the caller can fall back to the primitive box/kart builders. Visual only: the
	/// root collider, wheels, and all physics are identical on every body path.</summary>
	static bool TryBuildBlockoutModelBody( Scene scene, GameObject root, CarDefinition def )
	{
		if ( !BlockoutBodyModels.TryGetValue( def.Name, out var path ) )
			return false;

		var model = Model.Load( path );
		if ( model is null || model.IsError )
			return false;

		var go = scene.CreateObject();
		go.Name = "Body (blockout model)";
		go.SetParent( root, false );
		go.LocalRotation = BlockoutBodyYaw; // model-local +Y forward -> root +X forward

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = model;
		renderer.Tint = def.Tint; // neutral-gray albedo * per-car tint = the roster colour

		// The kart body model has no driver baked in; seat the shared citizen exactly as
		// the primitive kart builder does.
		if ( def.HasDriver )
			AddDriver( scene, root, def );

		return true;
	}

	/// <summary>Build the VISIBLE blockout wheel under <paramref name="wheelGo"/>: the modeled
	/// tire (baked dark tire + light rim, deliberately NOT body-tinted) scaled from its canonical
	/// export radius to this car's physics radius. Falls back to the original squashed dev sphere
	/// when the tire model is missing. A custom body builder that wants its own modeled wheels can
	/// replace these under each wheel GameObject after spawn.</summary>
	public static void BuildBlockoutWheelVisual( Scene scene, GameObject wheelGo, VehicleWheel wheel, CarDefinition def )
	{
		float m = Units.MetersToUnits;

		var visualGo = scene.CreateObject();
		visualGo.Name = "Visual (blockout)";
		visualGo.SetParent( wheelGo, false );

		var tireModel = Model.Load( TireModelPath );
		if ( tireModel is not null && !tireModel.IsError )
		{
			// Uniform scale maps the canonical export radius onto the physics radius, so the
			// rendered tire matches where the suspension ray contacts. No tint, no material
			// override: the tire's baked vmats (dark tire + light rim disc) show as authored.
			visualGo.LocalScale = def.WheelRadius / TireModelRadiusM;
			var tireRenderer = visualGo.Components.Create<ModelRenderer>();
			tireRenderer.Model = tireModel;
		}
		else
		{
			// fallback: the original 30-minute blockout, a squashed dev sphere
			bool kart = def.Style == BodyStyle.Kart;
			float diameterScale = def.WheelRadius * m / 50f * (kart ? 1.3f : 1.1f);
			visualGo.LocalScale = new Vector3( diameterScale, diameterScale * (kart ? 0.85f : 0.6f), diameterScale );

			var sphereRenderer = visualGo.Components.Create<ModelRenderer>();
			sphereRenderer.MaterialOverride = Material.Load( "materials/default.vmat" );
			sphereRenderer.Model = Model.Load( "models/dev/sphere.vmdl" );
			sphereRenderer.Tint = new Color( 0.12f, 0.12f, 0.13f );
		}

		var visual = visualGo.Components.Create<WheelVisual>();
		visual.Wheel = wheel;
	}

	static void BuildBoxBody( Scene scene, GameObject root, CarDefinition def )
	{
		AddBox( scene, root, "Body", Vector3.Zero, def.BodySize, def.Tint );
		AddBox( scene, root, "Cabin",
			new Vector3( -def.BodySize.x * 0.08f, 0f, def.BodySize.z * 0.72f ),
			new Vector3( def.BodySize.x * 0.45f, def.BodySize.y * 0.85f, def.BodySize.z * 0.6f ),
			def.Tint.Darken( 0.35f ) );
	}

	static void BuildKartBody( Scene scene, GameObject root, CarDefinition def )
	{
		var frame = def.Tint.Darken( 0.35f );
		var dark = new Color( 0.15f, 0.15f, 0.16f );
		var chrome = new Color( 0.75f, 0.76f, 0.78f );

		// chassis
		AddBox( scene, root, "Deck", new Vector3( 0f, 0f, 0f ), new Vector3( 1.5f, 0.9f, 0.10f ), def.Tint );
		AddBox( scene, root, "Nose", new Vector3( 0.88f, 0f, 0.04f ), new Vector3( 0.5f, 0.5f, 0.14f ), def.Tint );
		AddBox( scene, root, "NoseWing", new Vector3( 1.05f, 0f, 0.02f ), new Vector3( 0.16f, 0.95f, 0.06f ), frame );
		AddBox( scene, root, "PodL", new Vector3( 0.05f, 0.52f, 0.06f ), new Vector3( 0.9f, 0.16f, 0.14f ), frame );
		AddBox( scene, root, "PodR", new Vector3( 0.05f, -0.52f, 0.06f ), new Vector3( 0.9f, 0.16f, 0.14f ), frame );
		AddBox( scene, root, "RearBumper", new Vector3( -0.78f, 0f, 0.10f ), new Vector3( 0.10f, 0.95f, 0.16f ), frame );

		// seat
		AddBox( scene, root, "SeatBase", new Vector3( -0.42f, 0f, 0.10f ), new Vector3( 0.42f, 0.44f, 0.10f ), dark );
		AddBox( scene, root, "SeatBack", new Vector3( -0.60f, 0f, 0.32f ), new Vector3( 0.10f, 0.44f, 0.46f ), dark );

		// roll bar behind the seat
		AddBox( scene, root, "RollPostL", new Vector3( -0.68f, 0.18f, 0.38f ), new Vector3( 0.06f, 0.06f, 0.62f ), chrome );
		AddBox( scene, root, "RollPostR", new Vector3( -0.68f, -0.18f, 0.38f ), new Vector3( 0.06f, 0.06f, 0.62f ), chrome );
		AddBox( scene, root, "RollTop", new Vector3( -0.68f, 0f, 0.70f ), new Vector3( 0.06f, 0.44f, 0.06f ), chrome );

		// controls + engine
		AddBox( scene, root, "Column", new Vector3( 0.42f, 0f, 0.20f ), new Vector3( 0.05f, 0.05f, 0.34f ), chrome );
		AddBox( scene, root, "SteeringWheel", new Vector3( 0.30f, 0f, 0.40f ), new Vector3( 0.05f, 0.30f, 0.20f ), dark );
		AddBox( scene, root, "EngineBlock", new Vector3( -0.52f, -0.34f, 0.14f ), new Vector3( 0.30f, 0.22f, 0.20f ), dark );
		AddBox( scene, root, "Exhaust", new Vector3( -0.80f, -0.34f, 0.12f ), new Vector3( 0.28f, 0.08f, 0.08f ), chrome );

		if ( def.HasDriver )
			AddDriver( scene, root, def );
	}

	static void AddDriver( Scene scene, GameObject root, CarDefinition def )
		=> AddDriver( scene, root, def, DefaultDriverLocalM );

	static void AddDriver( Scene scene, GameObject root, CarDefinition def, Vector3 localMeters )
	{
		float m = Units.MetersToUnits;

		var driverGo = scene.CreateObject();
		driverGo.Name = "Driver";
		driverGo.SetParent( root, false );
		// citizen root is at the feet; seated pose sits on an invisible chair behind the origin.
		driverGo.LocalPosition = localMeters * m;

		var renderer = driverGo.Components.Create<SkinnedModelRenderer>();
		renderer.Model = Model.Load( "models/citizen/citizen.vmdl" );

		// dress the citizen — a simple default outfit via the engine clothing system. Each item is
		// null-checked so a missing asset degrades to a bare driver rather than bricking the spawn.
		DressDriver( renderer );

		// citizen animgraph: sit is an enum (0 none, 1-3 chair poses, 4-5 ground). Per-car (def) so a
		// recumbent kart pose (legs forward to the pedals) doesn't disturb any upright-seated car.
		renderer.Set( "b_grounded", true );
		renderer.Set( "sit", def.DriverSit );
		renderer.Set( "sit_offset_height", def.DriverSitOffsetHeight );
	}

	/// <summary>A plain default outfit for the seated citizen driver: a single-piece jumpsuit + shoes.
	/// Loaded from the shipped citizen_clothes resources via the engine clothing system; any item that
	/// fails to resolve is skipped so a bare driver is the worst case, never a broken spawn.</summary>
	static readonly string[] DriverOutfit =
	{
		"models/citizen_clothes/shirt/Jumpsuit/blue_jumpsuit.clothing",
		"models/citizen_clothes/shoes/Trainers/trainers.clothing",
	};

	static void DressDriver( SkinnedModelRenderer renderer )
	{
		var outfit = new ClothingContainer();
		bool any = false;
		foreach ( var path in DriverOutfit )
		{
			var item = ResourceLibrary.Get<Clothing>( path );
			if ( item is null )
			{
				Log.Warning( $"[vp] driver clothing '{path}' did not resolve — skipping (bare on that slot)" );
				continue;
			}
			outfit.Add( item );
			any = true;
		}
		if ( any )
			outfit.Apply( renderer );
	}

	static void AddBox( Scene scene, GameObject parent, string name, Vector3 positionMeters, Vector3 sizeMeters, Color color )
	{
		float m = Units.MetersToUnits;

		var go = scene.CreateObject();
		go.Name = name;
		go.SetParent( parent, false );
		go.LocalPosition = positionMeters * m;
		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;
	}
}