using System; using ProjectI.Combat; using Unity.Netcode; using UnityEngine; namespace ProjectI.Enemies { [DisallowMultipleComponent] public sealed class EnemyBoltExecution : NetworkBehaviour, IEnemyAttackExecution { public BoltAttackDefinition definition; public EnemyPerception perception; public Transform muzzle; private EnemyActorContext actor; private EnemyProjectileService projectiles; private BoltAttackDefinition capturedDefinition; private EnemyPresentation presentation; private EnemyAttackRequest request; private readonly RaycastHit[] hits = new RaycastHit[32]; private readonly Collider[] overlaps = new Collider[32]; public enum AttackPhase { Idle, Preparation, Committed, BurstSpacing, Recovery } public AttackPhase Phase { get; private set; } public EnemyAttackCommitment? Commitment { get; private set; } public double ReadyAt { get; private set; } #if UNITY_EDITOR || DEVELOPMENT_BUILD public string LastCancellation { get; private set; } = "None"; public ulong AcceptedActions { get; private set; } public ulong ReleasedShots { get; private set; } public EnemyAttackCommitment? LastCommitment { get; private set; } public double LastCommittedAt { get; private set; } #endif private double prepareAt, nextShot, lastTick; private ulong paidLifetime; private int shotIndex; private EnemyActionStatus terminal = EnemyActionStatus.Completed; private bool Active => Phase == AttackPhase.Preparation || Phase == AttackPhase.Committed || Phase == AttackPhase.BurstSpacing; public void InitializeServer(EnemyActorContext context) { ShutdownServer(EnemyShutdownReason.InitializationFailure); if (context == null || !context.IsValid || context.Owner.gameObject != gameObject || !IsServer || !IsSpawned || !ValidDefinition(definition) || perception == null || perception.gameObject != gameObject || perception.Actor != context || muzzle == null || !muzzle.IsChildOf(transform) || context.Encounter.Projectiles == null || !context.Encounter.Projectiles.IsSpawned || context.Encounter.Projectiles.NetworkManager != context.Network) throw new InvalidOperationException("Bolt requires a local authoritative actor, initialized perception, authored muzzle and projectile service."); actor = context; projectiles = context.Encounter.Projectiles; presentation = context.Owner.presentation; capturedDefinition = Instantiate(definition); capturedDefinition.hideFlags = HideFlags.HideAndDontSave; if (paidLifetime != context.SpawnIdentity) { ReadyAt = 0; paidLifetime = context.SpawnIdentity; } Phase = AttackPhase.Idle; terminal = EnemyActionStatus.Completed; } public void ShutdownServer(EnemyShutdownReason reason) { if (Active) { #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCancellation = "Shutdown: " + reason; #endif CancelOwned(request.Token); } actor = null; projectiles = null; Commitment = null; if (capturedDefinition != null) Destroy(capturedDefinition); capturedDefinition = null; presentation = null; // Same-lifetime disable/reinitialization cannot refund a paid deadline. } private bool Authorized => actor != null && actor.IsValid && isActiveAndEnabled && IsSpawned && IsServer && projectiles != null && projectiles.IsSpawned && projectiles.IsServer && projectiles.NetworkManager == actor.Network && projectiles.gameObject.scene == gameObject.scene; private bool Clock(double now) => EnemyObservationRules.Finite(now) && now >= 0 && actor != null && now == actor.Now; private bool Target(out PrototypePlayerCombat target) { target = null; return Authorized && actor.Network.SpawnManager.SpawnedObjects.TryGetValue(request.Target.NetworkObjectId, out var obj) && obj.TryGetComponent(out target) && EnemyTargetRules.Eligible(target, request.Target, actor.Network, actor.Stage, actor.RunId, actor.StageId); } private bool VisibleAim(double now, out EnemyObservation sight, out PrototypePlayerCombat target) { sight = default; return Target(out target) && !actor.Status.Stunned && request.Token.IsValidFor(actor) && muzzle != null && muzzle.IsChildOf(transform) && perception.TryVerifyPersonalFiringSightServer(request.Target, now, muzzle.position, Mathf.Clamp(capturedDefinition.clearanceRadius, .01f, .5f), capturedDefinition.attackRange, out sight) && Lane(sight.Position, target.transform); } private bool Lane(Vector3 point, Transform target) { if (muzzle == null || !muzzle.IsChildOf(transform) || !EnemyObservationRules.Finite(muzzle.position) || !EnemyObservationRules.Finite(point)) return false; Vector3 delta = point - muzzle.position; float radius = Mathf.Clamp(capturedDefinition.clearanceRadius, .01f, .5f); if (delta.sqrMagnitude < .000001f || delta.magnitude > capturedDefinition.attackRange || !EnemyTargetingRules.OriginClear(muzzle.position, radius, transform, overlaps)) return false; int count = Physics.SphereCastNonAlloc(muzzle.position, radius, delta.normalized, hits, delta.magnitude + .1f, ~0, QueryTriggerInteraction.Ignore); if (count == hits.Length) return false; Collider nearest = null; float distance = float.PositiveInfinity; for (int i = 0; i < count; i++) if (!hits[i].collider.transform.IsChildOf(transform) && hits[i].distance < distance) { nearest = hits[i].collider; distance = hits[i].distance; } return nearest != null && nearest.transform.IsChildOf(target); } public EnemyActionStatus TryBeginServer(EnemyAttackRequest input, double now) { if (!Authorized || !Clock(now) || !input.Token.IsValidFor(actor)) return EnemyActionStatus.Rejected; if (!actor.ReadyForActionsServer) return EnemyActionStatus.Deferred; if (Active) return input.Token.Equals(request.Token) ? EnemyActionStatus.Running : EnemyActionStatus.Deferred; if (now < ReadyAt) return EnemyActionStatus.Deferred; if (!input.Target.Equals(input.Evidence.Target) || input.Evidence.Kind != EnemyObservationKind.PersonalVisual || input.AimPoint != input.Evidence.Position || !perception.TryGetPersonalObservation(input.Target, now, out var stored) || stored.ObservedAt != input.Evidence.ObservedAt || stored.Position != input.Evidence.Position || !stored.Source.Equals(input.Evidence.Source) || stored.SourceSpawnIdentity != input.Evidence.SourceSpawnIdentity) return EnemyActionStatus.Rejected; request = input; if (!VisibleAim(now, out _, out _)) { request = default; return EnemyActionStatus.Rejected; } // Payment is successful preparation admission, even if no projectile is subsequently admitted. double paidUntil = now + new EnemyAttackTuning(capturedDefinition, true, !actor.Owner.IsElite && !actor.Owner.IsBoss && actor.Owner.definition.archetype == EnemyArchetype.Standard && actor.Owner.definition.role == EnemyRole.Ranged).Cooldown; if (!EnemyObservationRules.Finite(paidUntil) || paidUntil <= now) { request = default; return EnemyActionStatus.Rejected; } ReadyAt = paidUntil; prepareAt = now + capturedDefinition.preparationSeconds; lastTick = now; shotIndex = 0; Commitment = null; Phase = AttackPhase.Preparation; terminal = EnemyActionStatus.Running; #if UNITY_EDITOR || DEVELOPMENT_BUILD AcceptedActions++; #endif WindupRpc(capturedDefinition.preparationSeconds, input.AimPoint); return EnemyActionStatus.Running; } public EnemyActionStatus TickServer(double now) { if (!Authorized) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (Active) LastCancellation = "Authority/lifetime invalid"; #endif if (Active) CancelOwned(request.Token); return EnemyActionStatus.Rejected; } if (!Clock(now) || now < lastTick) return EnemyActionStatus.Rejected; lastTick = now; if (!actor.ReadyForActionsServer) { if (Active) CancelOwned(request.Token); return EnemyActionStatus.Deferred; } if (!Active) return terminal; if (!VisibleAim(now, out var sight, out var target)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCancellation = actor.Status.Stunned ? "Stunned" : !request.Token.IsValidFor(actor) ? "Action invalidated" : !Target(out _) ? "Target lifetime invalid" : "Personal sight or muzzle blocked"; #endif CancelOwned(request.Token); return EnemyActionStatus.Cancelled; } // A later shot's original due time also bounds its committed, still-unreleased state. if (shotIndex > 0 && now - nextShot > capturedDefinition.burstSpacingSeconds + .000000001) { #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCancellation = "Burst deadline missed"; #endif CancelOwned(request.Token); return EnemyActionStatus.Cancelled; } if (Phase == AttackPhase.Preparation && now < prepareAt) return EnemyActionStatus.Running; if (Phase == AttackPhase.BurstSpacing) { if (now < nextShot) return EnemyActionStatus.Running; } if (Phase != AttackPhase.Committed) { Commitment = new EnemyAttackCommitment(request, sight, shotIndex); #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCommitment = Commitment; LastCommittedAt = now; #endif Phase = AttackPhase.Committed; return EnemyActionStatus.Running; // Commitment and release are distinct graph ticks, no new authored delay. } var commitment = Commitment.Value; // Verify captured aim too: fresh sight cannot drag this already committed shot elsewhere. if (!Lane(commitment.AimPoint, target.transform)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCancellation = "Committed lane blocked"; #endif CancelOwned(request.Token); return EnemyActionStatus.Cancelled; } Vector3 direction = ConeDirection((commitment.AimPoint - muzzle.position).normalized, new Vector2(UnityEngine.Random.value, UnityEngine.Random.value), capturedDefinition.burstConeHalfAngleDegrees); // Consume the shot identity BEFORE attempting service admission. Refusal cancels the tail and retains payment. shotIndex++; Commitment = null; if (!projectiles.LaunchServer(actor.Owner, target.Vitals, muzzle.position, direction, capturedDefinition, true, commitment.AimPoint.y - target.Vitals.AcceptedPosition.y)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD LastCancellation = "Projectile admission refused"; #endif CancelOwned(request.Token); return EnemyActionStatus.Cancelled; } #if UNITY_EDITOR || DEVELOPMENT_BUILD ReleasedShots++; #endif LaunchRpc(commitment.AimPoint); if (shotIndex >= capturedDefinition.burstShotCount) { request.Token.Invalidate(); Phase = AttackPhase.Recovery; terminal = EnemyActionStatus.Completed; WindupRpc(0, Vector3.zero); return terminal; } nextShot = now + capturedDefinition.burstSpacingSeconds; Phase = AttackPhase.BurstSpacing; return EnemyActionStatus.Running; } public void CancelServer(EnemyActionToken token) { #if UNITY_EDITOR || DEVELOPMENT_BUILD if (Active && request.Token.Equals(token)) LastCancellation = actor != null && actor.Status.Stunned ? "Stunned" : "Action cancelled"; #endif CancelOwned(token); } private void CancelOwned(EnemyActionToken token) { if (!Active || !request.Token.Equals(token)) return; request.Token.Invalidate(); Commitment = null; Phase = AttackPhase.Recovery; terminal = EnemyActionStatus.Cancelled; if (IsServer && IsSpawned) WindupRpc(0, Vector3.zero); } [Rpc(SendTo.ClientsAndHost)] private void WindupRpc(float seconds, Vector3 point) => (presentation != null ? presentation : GetComponent())?.Windup(seconds, point); [Rpc(SendTo.ClientsAndHost)] private void LaunchRpc(Vector3 point) => (presentation != null ? presentation : GetComponent())?.Launch(point); public static Vector3 ConeDirection(Vector3 forward, Vector2 sample, float halfAngleDegrees) { forward.Normalize(); var right = Vector3.Cross(Mathf.Abs(forward.y) > .99f ? Vector3.forward : Vector3.up, forward).normalized; var up = Vector3.Cross(forward, right).normalized; float cosine = Mathf.Lerp(1, Mathf.Cos(halfAngleDegrees * Mathf.Deg2Rad), Mathf.Clamp01(sample.x)); float sine = Mathf.Sqrt(Mathf.Max(0, 1 - cosine * cosine)), angle = Mathf.Clamp01(sample.y) * Mathf.PI * 2; return (forward * cosine + (right * Mathf.Cos(angle) + up * Mathf.Sin(angle)) * sine).normalized; } public static bool ValidDefinition(BoltAttackDefinition value) => value != null && value.Valid && value.burstShotCount >= 1 && value.burstShotCount <= EnemyProjectileService.Capacity && !double.IsNaN(value.burstSpacingSeconds) && !double.IsInfinity(value.burstSpacingSeconds) && value.burstSpacingSeconds >= .01 && value.burstSpacingSeconds <= 8 && Bounded(value.burstConeHalfAngleDegrees, 0, 45) && Bounded(value.spreadDegrees, 0, 45) && PositiveValue(value.projectileSpeed) && Bounded(value.projectileTurnDegrees, 0, 90) && Bounded(value.projectileTrackingDuration, 0, 2) && PositiveValue(value.projectileLifetime) && value.projectileLifetime <= 8 && PositiveValue(value.trackingStopDistance) && PositiveValue(value.normalBoltDamageScale) && PositiveValue(value.normalBoltCooldownScale) && PositiveValue(value.normalBoltSpeedScale) && Bounded(value.normalBoltTurnScale, 0, 90) && PositiveValue(value.damage * value.normalBoltDamageScale) && PositiveValue(value.attackCooldown * value.normalBoltCooldownScale) && PositiveValue(value.projectileSpeed * value.normalBoltSpeedScale) && Bounded(value.projectileTurnDegrees * value.normalBoltTurnScale, 0, float.MaxValue) && Bounded(value.projectileWaveAmplitudeDegrees, 0, 45) && Bounded(value.projectileWaveFrequencyHz, 0, 30) && Bounded(value.projectileJitterAmplitudeDegrees, 0, 10) && Bounded(value.projectileJitterFrequencyHz, 0, 30); private static bool Bounded(float value, float minimum, float maximum) => !float.IsNaN(value) && !float.IsInfinity(value) && value >= minimum && value <= maximum; private static bool PositiveValue(float value) => EnemyObservationRules.Finite(value) && value > 0; private void OnDisable() => ShutdownServer(EnemyShutdownReason.Disabled); public override void OnDestroy() { ShutdownServer(EnemyShutdownReason.Despawn); base.OnDestroy(); } } }