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.
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";
}