PaintProjectile.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

public sealed class PaintProjectile : Component
{
    [Property, Category( "Paint" )] public GameObject DecalPrefab { get; set; }
    [Property, Category( "Paint" )] public float LifeTime { get; set; } = 4f;
    
    [Property, Category( "Spawn Variety" )] public float MinSpawnScale { get; set; } = 0.8f;
    [Property, Category( "Spawn Variety" )] public float MaxSpawnScale { get; set; } = 1.5f;
    
    [Property, Category( "Merge Settings" )] public float BaseMergeRadius { get; set; } = 40f;
    [Property, Category( "Merge Settings" )] public float ScaleMergeMultiplier { get; set; } = 35f;
    [Property, Category( "Merge Settings" )] public float MaxMergeRadius { get; set; } = 200f;

    [Property, Category( "Grid Optimization" )] public float CellSize { get; set; } = 50f;

    public Vector3 Velocity { get; set; }
    public Color PaintColor { get; set; } = Color.White;
    public int TeamId { get; set; } = 0;
    public GameObject Shooter { get; set; }
    public SoundEvent HitSound { get; set; }

    private TimeSince timeSinceCreated;

    public static readonly Dictionary<(int X, int Y, int Z), GameObject> PaintGrid = new();

    protected override void OnStart()
    {
        timeSinceCreated = 0;
    }

    protected override void OnUpdate()
    {
        if ( timeSinceCreated >= LifeTime )
        {
            GameObject.Destroy();
            return;
        }

        Velocity += Vector3.Down * 800f * Time.Delta;

        // ОНОВЛЕНО: Використовуємо WorldPosition замість Transform.Position
        var startPos = WorldPosition;
        var endPos = startPos + Velocity * Time.Delta;

        var tr = Scene.Trace.Ray( startPos, endPos )
            .Radius( 8f )
            .IgnoreGameObjectHierarchy( GameObject ) 
            .IgnoreGameObjectHierarchy( Shooter )
            .WithoutTags( "paint" ) 
            .Run();

        if ( tr.Hit )
        {
            if ( HitSound != null )
            {
                Sound.Play( HitSound, tr.HitPosition );
            }

            // Перевіряємо, чи влучили в гравця (шукаємо PlayerTeam в ієрархії об'єкта)
            var hitPlayerTeam = tr.GameObject.Components.Get<PlayerTeam>() ?? tr.GameObject.Components.GetInAncestors<PlayerTeam>();
            if ( !hitPlayerTeam.IsValid() )
            {
                // Якщо не знайшли безпосередньо, шукаємо через PlayerController
                var playerController = tr.GameObject.Components.Get<PlayerController>() ?? tr.GameObject.Components.GetInAncestors<PlayerController>();
                if ( playerController.IsValid() )
                {
                    hitPlayerTeam = playerController.GameObject.Components.Get<PlayerTeam>() ?? playerController.GameObject.Components.GetInDescendants<PlayerTeam>();
                }
            }

            if ( hitPlayerTeam.IsValid() )
            {
                if ( hitPlayerTeam.TeamId != TeamId )
                {
                    // Шукаємо компонент PaintSwimming, щоб перевірити, чи гравець у формі слизу
                    var pc = hitPlayerTeam.GameObject.Components.Get<PlayerController>() ?? hitPlayerTeam.GameObject.Components.GetInAncestors<PlayerController>();
                    var searchObj = pc.IsValid() ? pc.GameObject : hitPlayerTeam.GameObject;
                    var swimming = searchObj.Components.Get<PaintSwimming>() ?? searchObj.Components.GetInDescendants<PaintSwimming>();

                    if ( swimming.IsValid() && swimming.IsDiving )
                    {
                        // Якщо гравець у формі слизу — моментальна смерть
                        hitPlayerTeam.TakeDamage( 100f );
                    }
                    else
                    {
                        // Звичайне попадання (5 пострілів = смерть)
                        hitPlayerTeam.TakeDamage( 20f );
                    }
                    
                    // Знищуємо снаряд
                    GameObject.Destroy();
                    return;
                }
                else
                {
                    // Якщо влучили у тіммейта — пролітаємо крізь нього далі!
                    WorldPosition = endPos;
                    return;
                }
            }

            int gX = (int)MathF.Round( tr.HitPosition.x / CellSize );
            int gY = (int)MathF.Round( tr.HitPosition.y / CellSize );
            int gZ = (int)MathF.Round( tr.HitPosition.z / CellSize );
            var gridKey = (gX, gY, gZ);

            // Шукаємо найближчий блоб з урахуванням його розміру
            // Більший блоб = більший радіус злиття
            GameObject nearestPaint = null;
            float nearestDist = float.MaxValue;
            int searchRange = Convert.ToInt32(MathF.Max( 3, (int)MathF.Ceiling( MaxMergeRadius / CellSize )));

            for ( int dx = -searchRange; dx <= searchRange; dx++ )
            for ( int dy = -searchRange; dy <= searchRange; dy++ )
            for ( int dz = -searchRange; dz <= searchRange; dz++ )
            {
                var neighborKey = (gX + dx, gY + dy, gZ + dz);
                if ( PaintGrid.TryGetValue( neighborKey, out var paint ) && paint.IsValid() )
                {
                    float dist = paint.WorldPosition.Distance( tr.HitPosition );
                    
                    // Радіус злиття залежить від поточного масштабу блобу
                    float blobScale = paint.LocalScale.x;
                    float mergeRadius = BaseMergeRadius + ( blobScale * ScaleMergeMultiplier );

                    if ( dist < mergeRadius && dist < nearestDist )
                    {
                        nearestDist = dist;
                        nearestPaint = paint;
                    }
                }
            }

            if ( nearestPaint != null )
            {
                var blob = nearestPaint.Components.Get<PaintBlob>();
                if ( blob.IsValid() )
                {
                    if ( blob.TeamId == TeamId )
                    {
                        // СВОЯ КОМАНДА: злиття та ріст плям
                        var currentScale = nearestPaint.LocalScale;
                        float growthStep = 0.15f; 
                        float maxScale = 3.5f;   

                        if ( currentScale.x < maxScale )
                        {
                            // Зсуваємо позицію тільки поки блоб ще росте
                            float lerpFactor = 0.15f * ( 1f - ( currentScale.x / maxScale ) );
                            nearestPaint.WorldPosition = Vector3.Lerp( nearestPaint.WorldPosition, tr.HitPosition, lerpFactor );

                            nearestPaint.LocalScale = new Vector3( 
                                currentScale.x + growthStep, 
                                currentScale.y + growthStep, 
                                currentScale.z 
                            );
                        }
                    }
                    else
                    {
                        // ВОРОЖА КОМАНДА: ворожий слайм зникає, а на його місці з'являється наш слайм!
                        // 1. Видаляємо ворожу пляму з сітки та знищуємо її GameObject
                        foreach ( var kvp in PaintGrid.Where( x => x.Value == nearestPaint ).ToList() )
                        {
                            PaintGrid.Remove( kvp.Key );
                        }
                        nearestPaint.Destroy();

                        // 2. Спавнимо нову пляму нашої команди на її місці
                        if ( DecalPrefab.IsValid() )
                        {
                            var decal = DecalPrefab.Clone();
                            decal.WorldPosition = tr.HitPosition;
                            decal.WorldRotation = Rotation.LookAt( tr.Normal ) * Rotation.FromPitch( 90 ) * Rotation.FromYaw( Random.Shared.Float( 0f, 360f ) );

                            float randomScale = Random.Shared.Float( MinSpawnScale, MaxSpawnScale );
                            decal.LocalScale = new Vector3( randomScale, randomScale, randomScale );

                            var renderer = decal.Components.Get<ModelRenderer>();
                            if ( renderer.IsValid() )
                            {
                                renderer.Tint = PaintColor;
                            }

                            var blobInfo = decal.Components.Create<PaintBlob>();
                            blobInfo.TeamId = TeamId;

                            PaintGrid[gridKey] = decal;
                        }
                    }
                }
            }
            else
            {
                if ( DecalPrefab.IsValid() )
                {
                    var decal = DecalPrefab.Clone();
                    
                    decal.WorldPosition = tr.HitPosition;
                    decal.WorldRotation = Rotation.LookAt( tr.Normal ) * Rotation.FromPitch( 90 ) * Rotation.FromYaw( Random.Shared.Float( 0f, 360f ) );

                    float randomScale = Random.Shared.Float( MinSpawnScale, MaxSpawnScale );
                    decal.LocalScale = new Vector3( randomScale, randomScale, randomScale );

                    // Фарбуємо слиз-декаль у колір команди
                    var renderer = decal.Components.Get<ModelRenderer>();
                    if ( renderer.IsValid() )
                    {
                        renderer.Tint = PaintColor;
                    }

                    // Додаємо інформацію про команду
                    var blob = decal.Components.Create<PaintBlob>();
                    blob.TeamId = TeamId;

                    PaintGrid[gridKey] = decal;
                }
            }

            GameObject.Destroy();
        }
        else
        {
            // ОНОВЛЕНО: Снаряд летить далі через WorldPosition
            WorldPosition = endPos;
        }
    }
}