Vehicles/Vehicle.Bullets.cs

Partial Vehicle class that handles bullet hits: raycasts the car's actual mesh triangles and wheel cylinders so shots stop on sheet metal, glass or tyres instead of the collision box, refines the shooter's weapon trace from a static helper, and syncs bullet holes, broken glass and punctured tyres. The host validates hits, applies body/engine damage, spawns hole decals and glass shards, and plays the puncture sound.

Networking
namespace HorizonRP;

/// <summary>
/// Les balles sur les véhicules.
///
/// Un véhicule n'a qu'une boîte de collision, qui ne descend pas sous les essieux : une balle de
/// SWB s'arrêtait à sa surface, à quelques pouces de la tôle, passait pour une touche même dans
/// un coin vide, et ne voyait jamais le bas des roues. <see cref="RefineBulletTrace"/> (branché
/// dans SWB, Weapon.TraceBullet) regarde les vrais triangles de chaque véhicule sur le trajet de
/// la balle — caisse, vitres, roues (des cylindres) — et rend la première touche : sur la tôle,
/// sur un pneu, sur une vitre, ou sur quelqu'un à bord derrière une vitre ; à côté de tout, la
/// balle continue sa route.
///
/// Chez le tireur, SWB appelle <see cref="OnDamage"/> ; la demande part à l'hôte
/// (WeaponSystem.RequestVehicleHit), qui vérifie le tir, retrouve la pièce touchée et applique :
/// la caisse et le moteur abîmés selon la munition (<see cref="TakeBullet"/>, Ballistics), un trou
/// dans la tôle ; une vitre qui vole en éclats (<see cref="BreakGlass"/>) ; un pneu crevé
/// (<see cref="Puncture"/>) — la roue s'affaisse et n'accroche presque plus : la voiture glisse.
/// Trous, vitres et pneus sont synchronisés et enregistrés comme les bosses ; la réparation efface
/// tout. Le choc et les étincelles viennent de la surface de la boîte,
/// « surfaces/horizon/carrosserie.surface » (bullet_hit_car.mp3, entendu de tous).
/// </summary>
public sealed partial class Vehicle : Component.IDamageable
{
	/// <summary>Au-delà, les plus vieux trous disparaissent.</summary>
	public const int MaxHoles = 60;

	public const string BodySurface = "surfaces/horizon/carrosserie.surface";

	/// <summary>Un pneu crevé : son rayon (la roue s'affaisse sur la jante) et son adhérence restante.</summary>
	const float FlatRadius = 0.74f, FlatGrip = 0.3f;

	/// <summary>Les impacts de balle, dans le repère de la caisse : « x y z nx ny nz taille; … ».</summary>
	[Sync( SyncFlags.FromHost )] public string BulletHoles { get; set; } = "";

	/// <summary>Les vitres brisées : « morceau.vitre; … » (morceau = groupe de la caisse, vitre = pièce d'un seul tenant).</summary>
	[Sync( SyncFlags.FromHost )] public string BrokenGlass { get; set; } = "";

	/// <summary>Les pneus crevés, un bit par roue (l'ordre de Rig.Wheels).</summary>
	[Sync( SyncFlags.FromHost )] public int FlatTyres { get; set; }

	public enum ShotPart { Body, Glass, Wheel }

	/// <summary>Ce qu'une balle touche d'un véhicule : la pièce, où, et à quelle distance du départ.</summary>
	public struct ShotHit
	{
		public ShotPart Part;
		public Vector3 Point;
		public Vector3 Normal;
		public float Distance;
		/// <summary>La roue (Wheel), ou le groupe de la caisse (Body, Glass).</summary>
		public int Index;
		/// <summary>La vitre, d'un seul tenant, dans son groupe (Glass).</summary>
		public int Piece;
	}

	string appliedHoles = "";
	string appliedGlass = "";
	readonly List<GameObject> holeObjects = new();
	BBox[] groupBounds;
	Vertex[][] boundsOf;
	readonly HashSet<(int Group, int Piece)> broken = new();
	RealTimeSince sinceBodyBuilt;

	public bool IsFlat( int wheel ) => (FlatTyres & (1 << wheel)) != 0;

	/// <summary>Le rayon d'une roue tel que la suspension le voit : affaissé si le pneu est crevé.</summary>
	float RadiusOf( int wheel ) => Rig.Wheels[wheel].Radius * (IsFlat( wheel ) ? FlatRadius : 1f);

	/// <summary>
	/// SWB, sur la machine du tireur : sa balle a touché ce véhicule. La pièce et le point se lisent
	/// ici, sur ce qu'il voit, et partent dans le repère de la caisse ; l'hôte décide.
	/// </summary>
	public void OnDamage( in DamageInfo info )
	{
		if ( info.Attacker is not { } attacker || attacker.IsProxy || attacker.GetComponentInParent<Vehicle>() == this )
			return;
		var eye = attacker.GetComponent<WeaponHolder>()?.EyePos ?? info.Position;
		var dir = (info.Position - eye).Normal;
		if ( !RaycastParts( eye, info.Position + dir * 16, out var hit ) || !BodyFrame( out var frame ) )
			return;
		WeaponSystem.Current?.RequestVehicleHit( GameObject.Id, frame.PointToLocal( hit.Point ), frame.Rotation.Inverse * hit.Normal, (int)hit.Part, hit.Index, hit.Piece );
	}

	/// <summary>Le repère de la caisse (celui des trous, des vitres et du gréement), s'il est construit.</summary>
	public bool BodyFrame( out Transform frame )
	{
		frame = bodyRenderer.IsValid() ? bodyRenderer.GameObject.WorldTransform : WorldTransform;
		return Rig is not null;
	}

	// ------------------------------------------------------------------ lancer de rayon

	/// <summary>La première pièce du véhicule sur le segment : caisse, vitre (intacte) ou roue.</summary>
	public bool RaycastParts( Vector3 from, Vector3 to, out ShotHit hit )
	{
		var body = RaycastBody( from, to, false, out var b );
		var wheel = RaycastWheels( from, to, out var w );
		hit = wheel && (!body || w.Distance < b.Distance) ? w : b;
		return body || wheel;
	}

	/// <summary>
	/// Le premier triangle de la caisse sur le segment (vitres sautées si <paramref name="skipGlass"/>,
	/// vitres brisées toujours), en coordonnées du monde, sa normale tournée vers le tireur.
	/// </summary>
	public bool RaycastBody( Vector3 from, Vector3 to, bool skipGlass, out ShotHit hit )
	{
		hit = default;
		if ( deformed is null || Rig is null || !bodyRenderer.IsValid() )
			return false;

		var tf = bodyRenderer.GameObject.WorldTransform;
		var o = tf.PointToLocal( from );
		var dir = tf.PointToLocal( to ) - o;
		var best = dir.Length;
		if ( best < 0.01f )
			return false;
		dir /= best;

		// Une boîte par morceau (avec de la marge pour les bosses) : on ne teste que ceux que le rayon traverse.
		if ( boundsOf != deformed )
		{
			groupBounds = deformed.Select( g => g.Length == 0 ? default : BBox.FromPoints( g.Select( v => v.Position ) ).Grow( 30 ) ).ToArray();
			boundsOf = deformed;
		}
		var pieces = GlassPieces();

		var found = false;
		var bestNormal = Vector3.Zero;
		var bestGroup = -1;
		var bestTri = -1;
		for ( var g = 0; g < deformed.Length; g++ )
		{
			var isGlass = pieces[g] is not null;
			if ( skipGlass && isGlass || !RayHitsBox( o, dir, best, groupBounds[g] ) )
				continue;
			var verts = deformed[g];
			var idx = Rig.BodyGroups[g].Indices;
			for ( var t = 0; t + 2 < idx.Length; t += 3 )
			{
				if ( isGlass && broken.Contains( (g, pieces[g][t / 3]) ) )
					continue;
				var a = verts[idx[t]].Position;
				var e1 = verts[idx[t + 1]].Position - a;
				var e2 = verts[idx[t + 2]].Position - a;
				// Möller-Trumbore, des deux faces.
				var p = Vector3.Cross( dir, e2 );
				var det = Vector3.Dot( e1, p );
				if ( MathF.Abs( det ) < 1e-6f )
					continue;
				var inv = 1 / det;
				var s = o - a;
				var u = Vector3.Dot( s, p ) * inv;
				if ( u < 0 || u > 1 )
					continue;
				var q = Vector3.Cross( s, e1 );
				var v = Vector3.Dot( dir, q ) * inv;
				if ( v < 0 || u + v > 1 )
					continue;
				var d = Vector3.Dot( e2, q ) * inv;
				if ( d <= 0.01f || d >= best )
					continue;
				best = d;
				bestNormal = Vector3.Cross( e1, e2 );
				bestGroup = g;
				bestTri = t / 3;
				found = true;
			}
		}
		if ( !found )
			return false;

		if ( Vector3.Dot( bestNormal, dir ) > 0 )
			bestNormal = -bestNormal;
		var glass = pieces[bestGroup] is not null;
		hit = new ShotHit
		{
			Part = glass ? ShotPart.Glass : ShotPart.Body,
			Point = tf.PointToWorld( o + dir * best ),
			Normal = (tf.Rotation * bestNormal.Normal).Normal,
			Distance = best,
			Index = bestGroup,
			Piece = glass ? pieces[bestGroup][bestTri] : -1,
		};
		return true;
	}

	/// <summary>Les roues, vues comme des cylindres (pneu compris), là où la suspension les a mises.</summary>
	public bool RaycastWheels( Vector3 from, Vector3 to, out ShotHit hit )
	{
		hit = default;
		if ( Rig is null || wheelObjects is null )
			return false;
		var o = WorldTransform.PointToLocal( from );
		var dir = WorldTransform.PointToLocal( to ) - o;
		var best = dir.Length;
		if ( best < 0.01f )
			return false;
		dir /= best;

		var found = false;
		for ( var i = 0; i < Rig.Wheels.Length; i++ )
		{
			var w = Rig.Wheels[i];
			var c = i < wheelObjects.Length && wheelObjects[i].IsValid() ? wheelObjects[i].LocalPosition : w.Center;
			var r = RadiusOf( i );
			var half = w.Width * 0.5f;
			// Le flanc : le cercle dans le plan XZ, l'axe selon Y.
			var ox = o.x - c.x; var oz = o.z - c.z;
			var A = dir.x * dir.x + dir.z * dir.z;
			var B = 2 * (ox * dir.x + oz * dir.z);
			var C = ox * ox + oz * oz - r * r;
			if ( A > 1e-6f )
			{
				var disc = B * B - 4 * A * C;
				if ( disc >= 0 )
				{
					var t = (-B - MathF.Sqrt( disc )) / (2 * A);
					if ( t > 0.01f && t < best && MathF.Abs( o.y + dir.y * t - c.y ) <= half )
					{
						best = t;
						var p = o + dir * t;
						hit = new ShotHit { Part = ShotPart.Wheel, Index = i, Distance = t, Point = p, Normal = new Vector3( p.x - c.x, 0, p.z - c.z ).Normal };
						found = true;
					}
				}
			}
			// Les faces : un disque de chaque côté.
			if ( MathF.Abs( dir.y ) > 1e-6f )
			{
				foreach ( var side in new[] { -half, half } )
				{
					var t = (c.y + side - o.y) / dir.y;
					if ( t <= 0.01f || t >= best )
						continue;
					var p = o + dir * t;
					if ( (p.x - c.x) * (p.x - c.x) + (p.z - c.z) * (p.z - c.z) > r * r )
						continue;
					best = t;
					hit = new ShotHit { Part = ShotPart.Wheel, Index = i, Distance = t, Point = p, Normal = new Vector3( 0, MathF.Sign( side ), 0 ) };
					found = true;
				}
			}
		}
		if ( !found )
			return false;
		hit.Point = WorldTransform.PointToWorld( hit.Point );
		hit.Normal = (WorldRotation * hit.Normal).Normal;
		return true;
	}

	static bool RayHitsBox( Vector3 o, Vector3 dir, float length, BBox box )
	{
		float near = 0, far = length;
		for ( var axis = 0; axis < 3; axis++ )
		{
			var oo = o[axis];
			var dd = dir[axis];
			var mn = box.Mins[axis];
			var mx = box.Maxs[axis];
			if ( MathF.Abs( dd ) < 1e-6f )
			{
				if ( oo < mn || oo > mx )
					return false;
				continue;
			}
			var t1 = (mn - oo) / dd;
			var t2 = (mx - oo) / dd;
			if ( t1 > t2 )
				(t1, t2) = (t2, t1);
			near = MathF.Max( near, t1 );
			far = MathF.Min( far, t2 );
			if ( near > far )
				return false;
		}
		return true;
	}

	/// <summary>
	/// Par groupe de la caisse : null si ce n'est pas du verre, sinon la vitre de chaque triangle —
	/// des pièces d'un seul tenant (sommets confondus à 0,05 pouce près), une par vitre.
	/// </summary>
	int[][] GlassPieces()
	{
		if ( glassPieces is not null && glassPiecesOf == Rig )
			return glassPieces;
		glassPiecesOf = Rig;
		glassPieces = new int[Rig.BodyGroups.Length][];
		for ( var g = 0; g < Rig.BodyGroups.Length; g++ )
		{
			var group = Rig.BodyGroups[g];
			if ( !VehicleRig.IsGlass( group.Material ) )
				continue;
			var parent = Enumerable.Range( 0, group.Vertices.Length ).ToArray();
			int Find( int x ) { while ( parent[x] != x ) x = parent[x] = parent[parent[x]]; return x; }
			void Union( int a, int b ) { a = Find( a ); b = Find( b ); if ( a != b ) parent[a] = b; }
			var byPosition = new Dictionary<(int, int, int), int>();
			for ( var v = 0; v < group.Vertices.Length; v++ )
			{
				var p = group.Vertices[v].Position;
				var key = ((int)MathF.Round( p.x * 20 ), (int)MathF.Round( p.y * 20 ), (int)MathF.Round( p.z * 20 ));
				if ( byPosition.TryGetValue( key, out var other ) )
					Union( v, other );
				else
					byPosition[key] = v;
			}
			for ( var t = 0; t + 2 < group.Indices.Length; t += 3 )
			{
				Union( group.Indices[t], group.Indices[t + 1] );
				Union( group.Indices[t + 1], group.Indices[t + 2] );
			}
			var ids = new Dictionary<int, int>();
			var pieces = new int[group.Indices.Length / 3];
			for ( var t = 0; t < pieces.Length; t++ )
			{
				var root = Find( group.Indices[t * 3] );
				if ( !ids.TryGetValue( root, out var id ) )
					ids[root] = id = ids.Count;
				pieces[t] = id;
			}
			glassPieces[g] = pieces;
		}
		return glassPieces;
	}

	int[][] glassPieces;
	VehicleRig glassPiecesOf;

	/// <summary>Le triangle d'un groupe vitré fait-il partie d'une vitre brisée ? (Pour refaire la caisse.)</summary>
	bool IsBrokenGlass( int group, int triangle ) =>
		broken.Count > 0 && GlassPieces()[group] is { } pieces && broken.Contains( (group, pieces[triangle]) );

	// ------------------------------------------------------------------ chez le tireur

	/// <summary>
	/// SWB, à chaque tracé de balle (chez le tireur) : la première pièce d'un véhicule sur le
	/// trajet, avant ce que la balle a touché d'autre. Les occupants restent visés derrière une vitre.
	/// </summary>
	public static SceneTraceResult RefineBulletTrace( SceneTraceResult tr, GameObject shooter, Vector3 start, Vector3 end, float radius, string[] ignoreTags )
	{
		var full = start.Distance( end );
		if ( full < 1 )
			return tr;
		var dir = (end - start) / full;
		var hitCar = tr.Hit ? tr.GameObject?.GetComponent<Vehicle>() : null;
		var limit = tr.Hit && hitCar is null ? start.Distance( tr.HitPosition ) : full;
		var aboard = shooter.IsValid() ? shooter.GetComponentInParent<Vehicle>() : null;

		Vehicle best = null;
		var bestHit = default( ShotHit );
		foreach ( var car in All )
		{
			if ( !car.IsValid() || !car.Ready || car.Rig is null || car == aboard )
				continue;
			// Une sphère autour du véhicule : la plupart sont loin du trajet.
			var center = car.WorldTransform.PointToWorld( car.Rig.Bounds.Center );
			var along = Vector3.Dot( center - start, dir );
			var reach = car.Rig.Bounds.Size.Length * 0.5f + 12;
			if ( along < -reach || along > limit + reach || (start + dir * Math.Clamp( along, 0, limit )).Distance( center ) > reach )
				continue;
			if ( car.RaycastParts( start, start + dir * limit, out var hit ) && hit.Distance < limit )
			{
				var d = start.Distance( hit.Point );
				if ( best is null || d < start.Distance( bestHit.Point ) )
				{
					best = car;
					bestHit = hit;
				}
			}
		}

		if ( best is null )
		{
			// La boîte d'un véhicule, mais rien de lui sur le trajet : la balle continue.
			return hitCar is null ? tr : Retrace( shooter, start, end, radius, ignoreTags );
		}

		// Une vitre : quelqu'un à bord, juste derrière ? (La boîte de collision ignorée, les
		// occupants — enfants du véhicule — restent visés.) La vitre se brise côté hôte.
		if ( bestHit.Part == ShotPart.Glass )
		{
			var beyond = best.RaycastBody( bestHit.Point + dir * 0.5f, end, true, out var behind ) ? behind.Point : end;
			var inside = Retrace( shooter, start, beyond, radius, ignoreTags );
			if ( inside.Hit && inside.GameObject.IsValid() && inside.GameObject.GetComponentInParent<Vehicle>() == best && inside.GameObject.GetComponent<Vehicle>() is null )
				return inside;
		}

		tr.Hit = true;
		tr.GameObject = best.GameObject;
		tr.HitPosition = bestHit.Point;
		tr.EndPosition = bestHit.Point;
		tr.Normal = bestHit.Normal;
		tr.Fraction = start.Distance( bestHit.Point ) / full;   // Distance en découle
		tr.Surface = best.Components.Get<BoxCollider>()?.Surface ?? tr.Surface;
		return tr;
	}

	/// <summary>Le même tracé, sans les boîtes des véhicules (les occupants restent visés).</summary>
	static SceneTraceResult Retrace( GameObject shooter, Vector3 start, Vector3 end, float radius, string[] ignoreTags )
	{
		var trace = Game.ActiveScene.Trace.Ray( start, end ).UseHitboxes().WithoutTags( ignoreTags ).Size( radius );
		if ( shooter.IsValid() )
			trace = trace.IgnoreGameObjectHierarchy( shooter );
		foreach ( var car in All )
			if ( car.IsValid() )
				trace = trace.IgnoreGameObject( car.GameObject );
		return trace.Run();
	}

	// ------------------------------------------------------------------ hôte

	/// <summary>
	/// Des points de caisse et de moteur perdus (balles, vitres), écrits chez le propriétaire
	/// réseau du véhicule : EngineHealth et BodyHealth sont ses [Sync], et une voiture conduite
	/// appartient à son conducteur. L'hôte les écrivait sur sa copie et le conducteur les écrasait
	/// aussitôt de ses propres valeurs — presque aucun dégât sur une voiture qui roule (Julien,
	/// 30/10/2026). Chez l'hôte propriétaire (voiture garée), l'appel reste local.
	/// </summary>
	[Rpc.Owner( NetFlags.HostOnly )]
	public void LoseHealth( float body, float engine )
	{
		BodyHealth = MathF.Max( 0, BodyHealth - body );
		EngineHealth = MathF.Max( 0, EngineHealth - engine );
		if ( EngineOn && EngineDead )
		{
			EngineOn = false;
			PlayEngineSwitch( false );
		}
	}

	/// <summary>Énergie de la balle (J) par point de caisse perdu, et par point de moteur (à l'avant, ailleurs).</summary>
	public const float BodyDamageDivisor = 30f;
	public const float EngineFrontDivisor = 24f;
	public const float EngineDivisor = 75f;

	/// <summary>Hôte : une balle vérifiée sur la tôle. Caisse, moteur (balle arrivée à l'avant), un trou de plus.</summary>
	public void TakeBullet( Vector3 worldPoint, Vector3 worldNormal, Ballistics.Round round, float energy )
	{
		if ( Rig is null || !bodyRenderer.IsValid() )
			return;
		var tf = bodyRenderer.GameObject.WorldTransform;
		var local = tf.PointToLocal( worldPoint );
		var n = (tf.Rotation.Inverse * worldNormal).Normal;

		// Une balle de fusil (≈ 2 000 J) ôte ≈ 67 à la caisse sur 1 000 : une quinzaine de balles d'AK,
		// à peu près un chargeur, et la voiture prend feu (Vehicle.Fire). Le moteur souffre de
		// partout, trois fois plus touché à l'avant. C'était trois fois plus le 30/10/2026 (cinq à
		// six balles) : trop, a jugé Julien le 31/10/2026 après l'avoir demandé plus fort.
		var bb = Rig.BodyBounds;
		var front = local.x > bb.Maxs.x - bb.Size.x * 0.35f;
		LoseHealth( energy / BodyDamageDivisor, energy / (front ? EngineFrontDivisor : EngineDivisor) * round.Penetration );

		var holes = (BulletHoles ?? "") + $"{F( local.x )} {F( local.y )} {F( local.z )} {F( n.x )} {F( n.y )} {F( n.z )} {F( round.HoleScale )};";
		var count = holes.Count( c => c == ';' );
		if ( count > MaxHoles )
			holes = string.Join( ";", holes.Split( ';', StringSplitOptions.RemoveEmptyEntries ).Skip( count - MaxHoles ) ) + ";";
		BulletHoles = holes;
		RequestSaveState();
	}

	/// <summary>Hôte : une vitre vole en éclats (une seule balle suffit, comme un verre trempé).</summary>
	public void BreakGlass( int group, int piece )
	{
		if ( Rig is null || group < 0 || group >= Rig.BodyGroups.Length || GlassPieces()[group] is null || broken.Contains( (group, piece) ) )
			return;
		BrokenGlass = (BrokenGlass ?? "") + $"{group}.{piece};";
		RequestSaveState();
	}

	/// <summary>Hôte : la vitre sur le trajet d'une balle (un occupant visé par sa vitre) se brise.</summary>
	public void BreakGlassOnLine( Vector3 from, Vector3 to )
	{
		if ( RaycastBody( from, to, false, out var hit ) && hit.Part == ShotPart.Glass )
			BreakGlass( hit.Index, hit.Piece );
	}

	/// <summary>Hôte : un pneu crevé. La roue s'affaisse et glisse (RadiusOf, Vehicle.Simulate).</summary>
	public void Puncture( int wheel )
	{
		if ( Rig is null || wheel < 0 || wheel >= Rig.Wheels.Length || IsFlat( wheel ) )
			return;
		FlatTyres |= 1 << wheel;
		Wake();
		PlayPuncture( Rig.Wheels[wheel].Center );
		RequestSaveState();
	}

	/// <summary>Partout : l'air qui s'échappe du pneu.</summary>
	[Rpc.Broadcast]
	void PlayPuncture( Vector3 localPoint )
	{
		if ( Application.IsDedicatedServer )
			return;
		Sounds.PlayAt( "pneu", WorldTransform.PointToWorld( localPoint ), 0.6f );
	}

	// ------------------------------------------------------------------ chaque machine

	/// <summary>Les vitres brisées : la caisse refaite sans elles, et des éclats pour celles qui viennent de casser.</summary>
	void UpdateGlass()
	{
		var text = BrokenGlass ?? "";
		if ( text == appliedGlass || deformed is null || Rig is null || !bodyRenderer.IsValid() )
			return;
		var before = new HashSet<(int, int)>( broken );
		broken.Clear();
		foreach ( var entry in text.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
		{
			var parts = entry.Split( '.' );
			if ( parts.Length == 2 && int.TryParse( parts[0], out var g ) && int.TryParse( parts[1], out var p ) )
				broken.Add( (g, p) );
		}
		appliedGlass = text;
		RebuildBody();

		// Les éclats et le bruit, pour une vitre qui casse maintenant (pas pour l'état chargé au départ).
		if ( Application.IsDedicatedServer || sinceBodyBuilt < 1f )
			return;
		var pieces = GlassPieces();
		foreach ( var (g, p) in broken.Where( x => !before.Contains( x ) ) )
		{
			var group = Rig.BodyGroups[g];
			var pts = new List<Vector3>();
			for ( var t = 0; t < pieces[g].Length; t++ )
				if ( pieces[g][t] == p )
					pts.Add( group.Vertices[group.Indices[t * 3]].Position );
			if ( pts.Count == 0 )
				continue;
			var center = bodyRenderer.GameObject.WorldTransform.PointToWorld( pts.Aggregate( Vector3.Zero, ( a, b ) => a + b ) / pts.Count );
			Sound.Play( "sounds/impacts/bullets/impact-bullet-glass.sound", center );
			var fx = GameObject.Clone( GlassShards, new Transform( center ) );
			if ( fx.IsValid() )
			{
				fx.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
				fx.NetworkMode = NetworkMode.Never;
			}
		}
	}

	/// <summary>Chaque machine : les trous posés sur la caisse, refaits quand la liste change.</summary>
	void UpdateHoles()
	{
		var holes = BulletHoles ?? "";
		if ( holes == appliedHoles || !bodyRenderer.IsValid() )
			return;
		appliedHoles = holes;
		foreach ( var go in holeObjects )
			if ( go.IsValid() )
				go.Destroy();
		holeObjects.Clear();
		if ( Application.IsDedicatedServer )
			return;

		var holder = bodyRenderer.GameObject;
		foreach ( var entry in holes.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
		{
			var v = entry.Split( ' ' ).Select( x => float.TryParse( x, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f ) ? f : 0f ).ToArray();
			if ( v.Length < 7 )
				continue;
			var world = holder.WorldTransform;
			var position = world.PointToWorld( new Vector3( v[0], v[1], v[2] ) );
			var normal = (world.Rotation * new Vector3( v[3], v[4], v[5] )).Normal;
			var go = GameObject.Clone( HoleDecal, new Transform( position + normal * 0.5f, Rotation.LookAt( -normal ) ) );
			if ( !go.IsValid() )
				continue;
			go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
			go.NetworkMode = NetworkMode.Never;
			// Le prefab est un effet passager : il se détruirait. Ici, le trou reste.
			foreach ( var temporary in go.Components.GetAll<Component>( FindMode.EverythingInSelfAndDescendants ).Where( c => c.GetType().Name == "TemporaryEffect" ).ToList() )
				temporary.Destroy();
			go.SetParent( holder, true );
			go.WorldScale = v[6];
			holeObjects.Add( go );
		}
	}

	/// <summary>Le trou de balle dans la tôle : la décalque « métal » de s&box.</summary>
	const string HoleDecal = "prefabs/surface/metal_bullet-decal.prefab";

	/// <summary>Les éclats d'une vitre : l'impact « verre » de s&box.</summary>
	const string GlassShards = "prefabs/surface/glass_bullet.prefab";
}