Aetheria/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs
Meta Vens 7f317bd332 Freeze a guided round that waits on its verdict and place a positionless hit at its target
After its fade a round kept integrating Velocity through the BindingLifetime wait, then a Hit (which carries
no burst point) played HitEffect at the drifted position. The wait now holds still; a hit without a point
plays the effect at the round's target position, and is skipped when the round never knew one. OnEnable resets
BindingLifetime so a pooled split child, which is never bound, does not inherit the last round's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:15:53 +02:00

251 lines
No EOL
10 KiB
C#

using System;
using System.Collections;
using UnityEngine;
using CultMath;
using CultMath.UnityBridge;
using static CultMath.math;
using Random = UnityEngine.Random;
using static Noise1D;
using float3 = CultMath.float3;
// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already
// decided this shot's fate before this object was ever spawned (R8). The homing/guidance flight (the whole
// point of this presentation) is untouched; children spawned on split carry no damage of their own to apply
// either, since they never did anything but inherit their parent's now-deleted fields.
public class GuidedProjectile : MonoBehaviour
{
public Prototype HitEffect;
public Prototype ChildProjectile;
public int Children;
public float SplitTime;
public float SplitSeparationForwardness;
public float SplitSeparationVelocity;
public ParticleSystem Particles;
public float FadeOutTime;
public AnimationCurve ThrustCurve;
public AnimationCurve GuidanceCurve;
public AnimationCurve LiftCurve;
public float Frequency;
public float Thrust;
public float TopSpeed;
public Transform Source;
public Func<Vector3> TargetPosition;
private float _phase;
private float _prevDist;
private bool _active;
private bool _alive;
private float _spawnTime;
private Vector3 _targetVelocity;
private Vector3 _previousTargetPosition;
// Cut 3: FireControl.Fire's ShotId -- this missile's handle onto Zone.ShotCommitted/ShotResolved.
public int ShotId { get; set; }
public Transform Target { get; set; }
public float3 StartPosition { get; set; }
public float Range { get; set; }
public Vector3 Velocity { get; set; }
public Entity SourceEntity { get; set; }
// How long after spawn the round keeps its binding to the simulation's verdict: the shot's own flight time (the
// manager sets it from the committed shot's ArrivalTime), so a round that faded out early still lives to show
// the detonation the simulation resolves at arrival. 0 (the default) kills the round when its fade ends.
public float BindingLifetime { get; set; }
public event Action OnKill;
// The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and
// ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when.
// The one owner of the subscription's lifetime: a new binding disposes the one it replaces, Kill disposes it,
// and so does destruction, so a projectile torn down with its scene never leaves an observer on the zone's
// subjects to touch a destroyed transform.
public IDisposable Binding
{
get => _binding;
set
{
_binding?.Dispose();
_binding = value;
}
}
private IDisposable _binding;
void OnDestroy() => Binding = null;
// The commit named a burst point: fly to it, whatever this round was homing on (a target it would have
// passed, or a Range clamp it would have overshot). The point is a static target, so the old target's motion
// is dropped with it: the jump to the new point must not feed the guidance a target velocity.
public void BurstAt(Vector3 point)
{
Target = null;
TargetPosition = () => point;
_previousTargetPosition = point;
_targetVelocity = Vector3.zero;
}
// The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when
// it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed,
// one whose target is gone) just falls away, with no explosion to show.
public void Resolve(bool detonated, Vector3? point)
{
// A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a
// round that has been killed is done. The fade is stopped so it cannot Kill the round a second time.
if (!_alive) return;
if (!detonated)
{
if (_active) StartCoroutine(FadeOut());
return;
}
StopAllCoroutines();
// A hit carries no burst point: the effect plays where the round was homing (a round that faded out has stopped
// short of it), and is skipped when the round never knew where its target was.
Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null);
if (point.HasValue) transform.position = point.Value;
if (HitEffect != null && effectAt.HasValue)
{
var ht = HitEffect.Instantiate<Transform>();
ht.position = effectAt.Value;
}
StartCoroutine(Kill());
}
void OnEnable()
{
_active = _alive = true;
_spawnTime = Time.time;
BindingLifetime = 0f; // a pooled round starts unbound; the manager binds it after enabling
_phase = Random.value * 100;
_prevDist = Single.MaxValue;
Particles.startColor = Color.white;
//var main = Particles.main;
//main.startColor = new ParticleSystem.MinMaxGradient { mode = ParticleSystemGradientMode.Color, color = Color.white };
Particles.Clear(true);
Particles.Play(true);
}
void Update ()
{
if (SourceEntity == null) return;
var t = transform;
if (_active)
{
if (TargetPosition == null && !Target)
{
StartCoroutine(FadeOut());
return;
}
var position = t.position.ToCultMath();
var targetPosition = TargetPosition?.Invoke() ?? Target.position;
_targetVelocity = lerp(_targetVelocity.ToCultMath(), (targetPosition - _previousTargetPosition).ToCultMath(), saturate(Time.deltaTime * 5)).ToUnity();
_previousTargetPosition = targetPosition;
targetPosition = first_order_intercept(position,float3.zero, TopSpeed, targetPosition.ToCultMath(), _targetVelocity.ToCultMath()).ToUnity();
var diff = targetPosition - transform.position;
var targetDist = diff.magnitude;
var sourceDist = length(StartPosition.xz - position.xz);
// Out of range, or flown past its target: the round falls away. Only the simulation's detonation
// (Resolve) shows an explosion; a round that merely ran out of flight has none.
if (sourceDist > Range || dot(diff.ToCultMath(), Velocity.ToCultMath()) < 0)
{
StartCoroutine(FadeOut());
return;
}
_prevDist = targetDist;
var targetDistFlat = diff.Flatland().magnitude;
var curveLerp = 1 - targetDistFlat / (sourceDist + targetDistFlat);
var dir = diff.normalized;
var right = cross(dir.ToCultMath(), float3(0, 1, 0));
var up = cross(dir.ToCultMath(), right);
if (Children > 0 && SplitTime < curveLerp)
{
for (int i = 0; i < Children; i++)
{
var child = ChildProjectile.Instantiate<GuidedProjectile>();
child.transform.position = t.position;
child.StartPosition = StartPosition;
var randomDirection = normalize(Random.insideUnitCircle.ToCultMath());
var perpendicularRandom = randomDirection.x * right + randomDirection.y * up;
child.Velocity = (normalize(lerp(perpendicularRandom, dir.ToCultMath(), SplitSeparationForwardness)) * length(Velocity.ToCultMath()) * SplitSeparationVelocity).ToUnity();
child.Range = Range;
child.Source = Source;
child.Target = Target;
child.SourceEntity = SourceEntity;
child.GuidanceCurve = GuidanceCurve;
child.LiftCurve = LiftCurve;
child.ThrustCurve = ThrustCurve;
child.Thrust = Thrust;
child.TopSpeed = TopSpeed;
child.Frequency = Frequency;
child.Thrust = Thrust;
}
StartCoroutine(Kill());
if (HitEffect != null)
{
var ht = HitEffect.Instantiate<Transform>();
ht.position = t.position;
}
}
var dodge = normalize(lerp(
normalize(right * noise(Time.time * Frequency + _phase) + up * noise(Time.time * Frequency + (100 + _phase))),
float3(0, 1, 0), LiftCurve.Evaluate(curveLerp))).ToUnity();
var desired = Vector3.Slerp(dodge, dir, GuidanceCurve.Evaluate(curveLerp)).normalized * TopSpeed;
var thrustCurve = ThrustCurve.Evaluate(curveLerp);
var thrust = Thrust * thrustCurve;
var c = Color.white * thrustCurve;
c.a = 1;
Particles.startColor = c;
Velocity += (desired-Velocity).normalized * (thrust * Time.deltaTime);
}
if(_alive)
{
t.position += Velocity * Time.deltaTime;
}
}
IEnumerator FadeOut()
{
_active = false;
var startTime = Time.time;
while (Time.time - startTime < FadeOutTime)
{
var lerp = 1 - (Time.time - startTime) / FadeOutTime;
var c = Color.white * lerp;
c.a = 1;
Particles.startColor = c;
//var main = Particles.main;
//main.startColor = new ParticleSystem.MinMaxGradient { mode = ParticleSystemGradientMode.Color, color = Color.white * lerp };
yield return null;
}
// The wait holds still: a round waiting on the verdict must not drift off its last position.
Velocity = Vector3.zero;
// Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over.
while (Time.time - _spawnTime < BindingLifetime) yield return null;
StartCoroutine(Kill());
}
IEnumerator Kill()
{
Binding = null;
_active = false;
_alive = false;
Particles.Stop(true, ParticleSystemStopBehavior.StopEmitting);
var startTime = Time.time;
var lifetime = Particles.main.startLifetime.constant;
while (Time.time - startTime < lifetime)
{
yield return null;
}
OnKill?.Invoke();
GetComponent<Prototype>().ReturnToPool();
}
}