Conveyance/DragonTurret.cs
using Sandbox;
/// <summary>
/// A dumb but lovable autonomous turret. Scans for tagged targets within range,
/// smoothly tracks the nearest one with its Gun child, and fires Bullet prefabs
/// from its Muzzle on a fixed cadence. No damage — just physics knockback.
///
/// Now with **gunnery sergeant ballistics**: when UseBallisticAim is on (the default),
/// the turret accounts for projectile drop under gravity and aims its barrel *upward*
/// from the target by the amount the bullet will fall during its flight. The result is
/// a parabolic shot that actually hits a stationary target instead of plonking into the
/// dirt halfway there.
///
/// How the ballistic solver works:
/// 1. Estimate time-of-flight T from launcher to target (rough horizontal distance / speed).
/// 2. Compute how far gravity will pull the bullet down during T: drop = 0.5 * g * T^2.
/// 3. Aim the barrel at (target_position + Up * drop) — shoot HIGHER than the target.
/// 4. Recompute T using the new aim distance, refine drop, repeat 2-3 iterations.
/// The fixed-point iteration converges fast because the elevation angle changes only
/// slightly each pass once we're close to the right answer.
///
/// This iteration-based approach has two big wins over the closed-form quadratic solution:
/// - No square root call (s&box sandboxes most of System.*, so float.Sqrt /
/// MathF.Sqrt may or may not be available depending on environment). We compute
/// everything from Vector3.Length and basic arithmetic, both of which are bulletproof.
/// - Trivial to extend to moving targets later: just predict where the target will
/// be at time T using their velocity, instead of using their static position.
/// </summary>
public sealed class DragonTurret : Component
{
// === Wiring ===
/// <summary>
/// The child GameObject that visually rotates to aim. Usually called "Gun".
/// </summary>
[Property] public GameObject Gun { get; set; }
/// <summary>
/// The child GameObject (usually under Gun) where projectiles spawn from.
/// </summary>
[Property] public GameObject Muzzle { get; set; }
/// <summary>
/// Prefab to spawn as a projectile. Should have a Rigidbody.
/// </summary>
[Property] public GameObject BulletPrefab { get; set; }
/// <summary>
/// Optional gun model whose tint flashes on each shot. Pure juice.
/// Set both this AND GunColorGradient for the flash to fire.
/// </summary>
[Property] public ModelRenderer GunModel { get; set; }
/// <summary>
/// Colour gradient sampled by the gun flash. X-axis is seconds-since-fired * 2.
/// Ignored unless GunModel is also set.
/// </summary>
[Property] public Gradient GunColorGradient { get; set; }
// === Targeting ===
/// <summary>
/// Tag a GameObject must carry to be considered a target. Defaults to "player".
/// </summary>
[Property] public string TargetTag { get; set; } = "player";
/// <summary>
/// How far away (in units) the turret can see targets.
/// </summary>
[Property, Range( 100, 5000 )] public float DetectionRange { get; set; } = 1500f;
/// <summary>
/// Degrees per second the gun can rotate while tracking. Lower = more dodgeable.
/// </summary>
[Property, Range( 30, 720 )] public float TurnRateDegPerSec { get; set; } = 120f;
// === Firing ===
/// <summary>
/// Seconds between shots.
/// </summary>
[Property, Range( 0.1f, 5f )] public float FireInterval { get; set; } = 1.5f;
/// <summary>
/// Muzzle velocity of the bullet, in units/sec.
/// </summary>
[Property, Range( 200, 5000 )] public float BulletSpeed { get; set; } = 1500f;
/// <summary>
/// Mass assigned to the bullet's Rigidbody at spawn time, overriding whatever's in the prefab.
/// Heavier bullets = more knockback. Player Rigidbody is mass 500 by default,
/// so 50–200 feels meaningful without being silly.
/// </summary>
[Property, Range( 1, 500 )] public float BulletMass { get; set; } = 100f;
/// <summary>
/// Optional sound to play on fire.
/// </summary>
[Property] public SoundEvent FireSound { get; set; }
// === Ballistics ===
/// <summary>
/// If true, the turret accounts for gravity drop and lobs its shots in a parabolic
/// arc to actually hit the target. If false, falls back to the old "aim straight at
/// the target" behaviour, which is funny but rarely lands a hit past close range.
/// </summary>
[Property] public bool UseBallisticAim { get; set; } = true;
/// <summary>
/// How many refinement passes the ballistic solver does before committing to an aim.
/// 2-3 is plenty for typical engagement ranges. More iterations don't hurt but stop
/// improving the result. Lower numbers (1) leave the aim slightly short for long shots.
/// </summary>
[Property, Range( 1, 6 )] public int BallisticIterations { get; set; } = 3;
// === Runtime ===
private TimeSince timeSinceFired = 0;
private GameObject currentTarget;
protected override void OnUpdate()
{
FlashGunModel();
currentTarget = FindNearestTarget();
if ( currentTarget.IsValid() )
{
TrackTarget( currentTarget );
if ( timeSinceFired >= FireInterval && IsAimedAtTarget( currentTarget ) )
{
Fire();
timeSinceFired = 0;
}
}
}
/// <summary>
/// Scan all GameObjects tagged with TargetTag and return the closest one in range.
/// </summary>
private GameObject FindNearestTarget()
{
GameObject best = null;
float bestDistSq = DetectionRange * DetectionRange;
foreach ( var candidate in Scene.GetAllObjects( true ) )
{
if ( !candidate.Tags.Has( TargetTag ) )
continue;
float distSq = ( candidate.WorldPosition - WorldPosition ).LengthSquared;
if ( distSq < bestDistSq )
{
bestDistSq = distSq;
best = candidate;
}
}
return best;
}
/// <summary>
/// Return the world-space point we should be aiming at on the target.
/// Uses the renderer bounds centre (chest-ish for a citizen, middle for any model)
/// rather than the GameObject origin (which is usually at ground level).
/// </summary>
private Vector3 GetAimPoint( GameObject target )
{
// ModelRenderer (and its subclass SkinnedModelRenderer) both expose Bounds.
var renderer = target.Components.Get<ModelRenderer>( FindMode.EnabledInSelfAndDescendants );
if ( renderer.IsValid() )
{
return renderer.Bounds.Center;
}
// Fallback: object origin.
return target.WorldPosition;
}
/// <summary>
/// Compute the direction the gun should point to hit `targetPos`, accounting for
/// gravity drop along the way. Returns a normalized direction vector.
///
/// This is the heart of the ballistic upgrade. We iterate:
/// - Estimate time-of-flight to where we're currently aiming
/// - Predict how much the bullet will drop during that time
/// - Aim higher by that much
/// - Repeat
/// Each pass refines both the predicted time and the predicted drop together,
/// converging on the actual ballistic solution.
///
/// If the target is "unreachable" (too far for our bullet speed to compensate for
/// gravity), the solver will produce an aim direction that approaches vertical —
/// effectively giving up and shooting straight up. We could detect this and fall
/// back to direct aim, but in practice "aim higher and hope" is the right behaviour
/// for a dumb-but-lovable turret.
/// </summary>
private Vector3 ComputeBallisticAimDirection( Vector3 targetPos )
{
// Gravity is a Vector3 on the physics world; we only care about its magnitude
// since the maths assumes a single down-axis. In standard s&box it's around 800
// units/s² downward. Reading it from the world keeps us robust to scene-level
// gravity overrides.
var gravityVec = Scene.PhysicsWorld.Gravity;
float gravity = -gravityVec.z; // gravityVec.z is negative for "down"; we want positive magnitude.
if ( gravity <= 0f )
{
// Zero or upward gravity? Just aim straight. No drop to compensate for.
return (targetPos - Muzzle.WorldPosition).Normal;
}
// Start with a direct-aim guess. Each iteration will refine it upward by the
// predicted drop at the current estimated flight time.
var aimPoint = targetPos;
for ( int i = 0; i < BallisticIterations; i++ )
{
// Distance from muzzle to the current aim point. This is what the bullet
// actually has to travel along its launch direction, so it's the correct
// number to divide by speed for time-of-flight.
float distance = (aimPoint - Muzzle.WorldPosition).Length;
float timeOfFlight = distance / BulletSpeed;
// Standard projectile drop: y = 0.5 * g * t^2 below the launch line.
// We compensate by aiming HIGHER than the actual target by this much.
float drop = 0.5f * gravity * timeOfFlight * timeOfFlight;
aimPoint = targetPos + Vector3.Up * drop;
}
return (aimPoint - Muzzle.WorldPosition).Normal;
}
/// <summary>
/// Decide where the gun should be pointing to hit this target. Either ballistic
/// (lobs to compensate for gravity) or direct (straight line), depending on toggle.
/// </summary>
private Vector3 GetDesiredAimDirection( GameObject target )
{
var targetPos = GetAimPoint( target );
if ( UseBallisticAim && Muzzle.IsValid() )
return ComputeBallisticAimDirection( targetPos );
// Direct aim — original behaviour.
var sourcePos = Muzzle.IsValid() ? Muzzle.WorldPosition : Gun.WorldPosition;
return (targetPos - sourcePos).Normal;
}
/// <summary>
/// Rotate the Gun toward the target at a capped angular speed.
/// Uses Rotation.Clamp which gives us "current rotation, but no more than N degrees from target".
/// </summary>
private void TrackTarget( GameObject target )
{
if ( !Gun.IsValid() ) return;
var aimDir = GetDesiredAimDirection( target );
var desiredRotation = Rotation.LookAt( aimDir );
var maxStepDeg = TurnRateDegPerSec * Time.Delta;
Gun.WorldRotation = Gun.WorldRotation.Clamp( desiredRotation, maxStepDeg );
}
/// <summary>
/// True if the gun is pointing close enough to the target's BALLISTIC aim direction
/// that firing makes sense. Prevents the turret from spraying wildly while still slewing,
/// AND from firing the moment it lines up with the target's center while its actual
/// trajectory is still way off (since the ballistic aim point is above the target).
/// </summary>
private bool IsAimedAtTarget( GameObject target )
{
if ( !Gun.IsValid() ) return false;
var aimDir = GetDesiredAimDirection( target );
var gunDir = Gun.WorldRotation.Forward;
// Dot > 0.95 ≈ within ~18 degrees of aim.
return Vector3.Dot( gunDir, aimDir ) > 0.95f;
}
/// <summary>
/// Spawn a bullet at the muzzle, propel it forward, play sound, mark fire time.
///
/// Note: this method doesn't need to know anything about ballistics. The muzzle's
/// forward direction is whatever the gun is pointing at right now, which is already
/// the elevated ballistic aim if UseBallisticAim is on. Bullet inherits that and gravity
/// does the rest — a beautiful side effect of the gun-rotation system being purely
/// transform-based.
/// </summary>
private void Fire()
{
if ( !BulletPrefab.IsValid() || !Muzzle.IsValid() ) return;
var bullet = BulletPrefab.Clone( Muzzle.WorldPosition, Muzzle.WorldRotation );
var rb = bullet.Components.Get<Rigidbody>( FindMode.EnabledInSelfAndDescendants );
if ( rb.IsValid() )
{
// MassOverride > 0 tells the engine "use this mass instead of computing from colliders".
// Setting at spawn time guarantees the value is applied, regardless of prefab state.
rb.MassOverride = BulletMass;
rb.Velocity = Muzzle.WorldRotation.Forward * BulletSpeed;
}
if ( FireSound is not null )
{
Sound.Play( FireSound, Muzzle.WorldPosition );
}
}
/// <summary>
/// Tint the gun model briefly after a shot, sampling the gradient.
/// Only runs if both GunModel and GunColorGradient are wired up.
/// </summary>
private void FlashGunModel()
{
if ( !GunModel.IsValid() ) return;
// timeSinceFired in [0, 0.5] maps to gradient X in [0, 1]
GunModel.Tint = GunColorGradient.Evaluate( timeSinceFired * 2.0f );
}
}