TrainingRange.cs
using System;
using Sandbox;

namespace PaintballBaddies;

/// <summary>Single-player practice range. Targets are spawned only in the running scene.</summary>
public sealed class TrainingRange : Component
{
    [Property] public bool UseCitizenCharacters { get; set; }
    [Property] public bool RunIntegrationChecks { get; set; }
    [Property] public bool RunRosterChecks { get; set; }
    [Property] public bool RunNavigationChecks { get; set; }
    [Property] public bool RunMatchChecks { get; set; }
    [Property] public bool RunCoveredMatchChecks { get; set; }
    [Property] public int MatchCheckCharacter { get; set; } = -1;
    [Property] public bool RunCoverChecks { get; set; }
    [Property] public bool RunCombatMotionChecks { get; set; }
    [Property] public bool RunVaultChecks { get; set; }
    [Property] public bool RunVaultTraversalChecks { get; set; }
    [Property] public string VaultCheckModelPath { get; set; } = "";
    private GameObject targetsRoot;
    public void SetTargetsVisible( bool visible ) { if ( targetsRoot is not null ) targetsRoot.Enabled = visible; }
    public static GameObject Box( GameObject parent, string name, Vector3 position, Vector3 size, Color color, bool solid = false )
    {
        var go = new GameObject( parent, true, name );
        go.LocalPosition = position;
        var renderer = go.Components.Create<ModelRenderer>();
        renderer.Model = Model.Load( "models/dev/box.vmdl" );
        renderer.Tint = color;
        go.LocalScale = size / 50;
        if ( solid )
        {
            var collider = go.Components.Create<BoxCollider>();
            collider.Scale = new Vector3( 50 );
            collider.Static = true;
        }
        return go;
    }

    public static void CreateLocalHud(Scene scene)
    {
        if(scene.GetAllComponents<TrainingHud>().Any())return;
        var ui=new GameObject(true,"Training HUD"){NetworkMode=NetworkMode.Never};
        ui.Components.Create<ScreenPanel>();ui.Components.Create<TrainingHud>();
    }
    protected override void OnStart()
    {
        if(!MultiplayerSession.Find(Scene).IsValid())
            new GameObject(true,"Paintball multiplayer session").Components.Create<MultiplayerSession>();
        if(MultiplayerSession.Online){CreateLocalHud(Scene);return;}
        var player = Scene.GetAllComponents<PlayerController>().FirstOrDefault();
        if ( player is null ) return;
        if ( PaintImpactSystem.Find(Scene) is null ) Components.Create<PaintImpactSystem>();
        if ( player.Components.Get<PaintballMarker>() is null ) player.Components.Create<PaintballMarker>();
        if ( player.Components.Get<RosterSelection>() is null )
            player.Components.Create<RosterSelection>().UseCitizenCharacters=UseCitizenCharacters;
        if ( player.Components.Get<ArenaMatch>() is null ) player.Components.Create<ArenaMatch>();
        if ( player.Components.Get<CoverController>() is null ) player.Components.Create<CoverController>();
        if ( player.Components.Get<VaultController>() is null ) player.Components.Create<VaultController>();
        if ( player.Components.Get<ForwardAutoRun>() is null ) player.Components.Create<ForwardAutoRun>();
        foreach ( var collider in Scene.GetAllComponents<BoxCollider>().ToArray() )
        {
            var name = collider.GameObject.Name;
            if ( collider.Static && (name.StartsWith( "Cover " ) || name.StartsWith( "Bunker -" ) || name == "foundry_low_bunker" || name == "foundry_equipment_crate")
                && collider.Components.Get<CoverSurface>() is null ) collider.Components.Create<CoverSurface>();
        }
        var rangeRoot = new GameObject( true, "Training range targets" ){NetworkMode=NetworkMode.Never};
        targetsRoot = rangeRoot;
        for ( int i = 0; i < (ArenaMap.Find(Scene)?.ShowPracticeTargets==false ? 0 : 5); i++ )
        {
            // The first three are in the clear starting lane; two reward navigating cover.
            var position = i < 3 ? new Vector3( -420, (i - 1) * 125, 55 )
                : new Vector3( 100, i == 3 ? -390 : 390, 65 );
            var target = new GameObject( rangeRoot, true, $"Practice target {i+1}" );
            target.LocalPosition = position;
            var plate = Model.Load( "models/foundry/practice_target/practice_plate.vmdl" );
            target.Components.Create<ModelRenderer>().Model = plate;
            var collider = target.Components.Create<ModelCollider>();
            collider.Model = plate;
            collider.Static = true;
            target.Components.Create<PaintballTarget>();
            var stand = new GameObject( rangeRoot, true, $"Practice target {i+1} stand" );
            stand.LocalPosition = position.WithZ( 0 );
            stand.LocalScale = new Vector3( 1, 1, (position.z - 23) / 32 );
            stand.Components.Create<ModelRenderer>().Model = Model.Load( "models/foundry/practice_target/practice_stand.vmdl" );
        }
        CreateLocalHud(Scene);
        ArenaShield.ResetArena(Scene);
        if ( RunIntegrationChecks ) player.Components.Create<TrainingChecks>();
        if ( RunRosterChecks ) player.Components.Create<RosterChecks>();
        if ( RunNavigationChecks ) player.Components.Create<OpponentNavigationChecks>();
        if ( RunMatchChecks ) { var checks = player.Components.Create<ArenaMatchChecks>(); checks.CoveredModels = RunCoveredMatchChecks; checks.PlayerCharacterIndex = MatchCheckCharacter; }
        if ( RunCoverChecks ) player.Components.Create<CoverChecks>();
        if ( RunCombatMotionChecks ) player.Components.Create<CombatMotionChecks>();
        if ( RunVaultChecks ) player.Components.Create<VaultChecks>();
        if ( RunVaultTraversalChecks ) player.Components.Create<VaultTraversalChecks>().TestModelPath = VaultCheckModelPath;
    }
}

public sealed class PaintballTarget : Component
{
    // The rigid practice plate stays still; physical paint decals provide hit feedback.
    public int HitsReceived { get; private set; }
    public void Hit() => HitsReceived++;
}