using System; using Unity.Netcode; using UnityEngine; namespace ProjectI.Enemies { [DisallowMultipleComponent] public sealed class EnemyMeleeExecution : NetworkBehaviour, IEnemyAttackExecution, IEnemyRecoveryContribution { public MeleeAttackDefinition definition; public EnemyPerception perception; public EnemyMovementExecutor movement; private EnemyActorContext actor; private MeleeAttackDefinition captured; private EnemyAttackRequest request; private EnemyActionToken travelToken; private readonly RaycastHit[] hits = new RaycastHit[32]; private readonly Collider[] overlaps = new Collider[32]; private readonly NetworkVariable cue = new NetworkVariable(); private ulong paidLifetime; private double due, lastTick, committedAt; private bool lunge, hitConsumed; private bool combatParticipation; private float damage; private EnemyMeleeCue lastPresented; private ulong presentedLifetime, presentedAttempt; public EnemyMeleePhase PresentedPhase { get; private set; } public Vector3 PresentedHeading { get; private set; } private EnemyActionStatus terminal = EnemyActionStatus.Completed; public EnemyMeleePhase Phase { get; private set; } public Vector3 CommittedHeading { get; private set; } public EnemyTargetKey CommittedTarget { get; private set; } public double ReadyAt { get; private set; } public EnemyMeleeCue PresentationState => cue.Value; #if UNITY_EDITOR || DEVELOPMENT_BUILD public ulong AcceptedActions { get; private set; } public double InspectionDeadline => Phase == EnemyMeleePhase.Recovery ? ReadyAt : due; public double InspectionDuration => captured == null ? 0 : Phase == EnemyMeleePhase.Recovery ? ReadyAt - inspectionPaidAt : Phase == EnemyMeleePhase.Preparation ? captured.preparationSeconds : Phase == EnemyMeleePhase.Committed ? captured.commitmentSeconds : captured.lungeSeconds; private double inspectionPaidAt; public ulong AppliedHits { get; private set; } public float AppliedDamage { get; private set; } public ulong PresentedCommitments { get; private set; } #endif private bool Active => Phase == EnemyMeleePhase.Preparation || Phase == EnemyMeleePhase.Committed || Phase == EnemyMeleePhase.Lunge; private bool Authorized => actor != null && actor.IsValid && IsSpawned && IsServer && isActiveAndEnabled; private bool Clock(double now) => actor != null && EnemyObservationRules.Finite(now) && now >= 0 && now == actor.Now; public void InitializeServer(EnemyActorContext context) { ShutdownServer(EnemyShutdownReason.InitializationFailure); if (context == null || !context.IsValid || context.Owner.gameObject != gameObject || !IsSpawned || !IsServer || definition == null || !definition.Valid || perception == null || perception.gameObject != gameObject || movement == null || movement.gameObject != gameObject || movement.Safety == null) throw new InvalidOperationException("Melee requires local authoritative movement/perception and valid authored tuning."); actor = context; captured = Instantiate(definition); captured.hideFlags = HideFlags.HideAndDontSave; if (paidLifetime != context.SpawnIdentity) { ReadyAt = 0; paidLifetime = context.SpawnIdentity; } damage = (float)Math.Min(float.MaxValue, (double)EnemyResistanceRules.ResolveOutgoingDamage(captured.damage, actor.Owner.Resistances) * actor.Owner.StageDamageMultiplier); Phase = EnemyMeleePhase.Idle; terminal = EnemyActionStatus.Completed; Publish(0); } private bool Target(out ProjectI.Combat.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); } 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 (actor.Status.Stunned || !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 (!Target(out var target) || !perception.TryVerifyPersonalSightServer(input.Target, now, out _) || Vector3.Distance(transform.position, target.Vitals.AcceptedPosition) > captured.lungeAdmissionRange) { request = default; return EnemyActionStatus.Rejected; } Vector3 heading = target.Vitals.AcceptedPosition - transform.position; heading.y = 0; if (actor.Status.Rooted && !ContactOpportunity(target, heading.sqrMagnitude > .000001f ? heading.normalized : transform.forward)) { request = default; return EnemyActionStatus.Rejected; } lunge = !actor.Status.Rooted && Vector3.Distance(transform.position, target.Vitals.AcceptedPosition) > captured.attackRange; ReadyAt = now + (lunge ? captured.lungeCooldown : captured.attackCooldown); due = now + captured.preparationSeconds; lastTick = now; hitConsumed = false; CommittedHeading = default; CommittedTarget = default; committedAt = 0; // Cancel only owned normal travel before starting windup. The graph's attack token stays independent. if (movement.AcceptedPlan != null) movement.Cancel(movement.AcceptedPlan.Token); if (movement.HasObservation) movement.Cancel(movement.ObservationToken); Phase = EnemyMeleePhase.Preparation; terminal = EnemyActionStatus.Running; combatParticipation = true; if (perception.TryGetSnapshot(input.Target, now, out var admittedSight)) actor.Encounter.PressureDirector?.ObserveMeleeAdmissionServer(actor, input, admittedSight.PersonalExpiresAt); #if UNITY_EDITOR || DEVELOPMENT_BUILD inspectionPaidAt = now; AcceptedActions++; #endif Publish(due); return EnemyActionStatus.Running; } public EnemyActionStatus TickServer(double now) { if (!Authorized) { if (Active) Finish(EnemyActionStatus.Cancelled); return EnemyActionStatus.Rejected; } if (!Clock(now) || now < lastTick) return EnemyActionStatus.Rejected; lastTick = now; if (!Active) return terminal; if (!actor.ReadyForActionsServer || !request.Token.IsValidFor(actor) || actor.Status.Stunned || !Target(out var target)) return Finish(EnemyActionStatus.Cancelled); if (Phase == EnemyMeleePhase.Preparation) { if (now - due > captured.lungeSeconds) return Finish(EnemyActionStatus.Cancelled); if (!perception.TryVerifyPersonalSightServer(request.Target, now, out var sight)) return Finish(EnemyActionStatus.Cancelled); movement.TryFaceServer(request.Token, sight.Position); if (now < due) return EnemyActionStatus.Running; Vector3 direction = sight.Position - transform.position; direction.y = 0; if (direction.sqrMagnitude < .000001f) direction = transform.forward; CommittedHeading = direction.normalized; CommittedTarget = request.Target; if (!movement.TryFaceServer(request.Token, transform.position + CommittedHeading, true)) return Finish(EnemyActionStatus.Cancelled); Phase = EnemyMeleePhase.Committed; committedAt = now; due = now + captured.commitmentSeconds; Publish(due); return EnemyActionStatus.Running; } if (Phase == EnemyMeleePhase.Committed) { if (now < due) return EnemyActionStatus.Running; // A missed execution window cannot become a delayed surprise strike. if (now - due > captured.lungeSeconds) return Finish(EnemyActionStatus.Completed); if (TryContact(target)) return Finish(EnemyActionStatus.Completed); if (!lunge || actor.Status.Rooted) return Finish(EnemyActionStatus.Completed); travelToken = movement.IssueTokenServer(); if (!movement.TryBeginStraightServer(travelToken, CommittedHeading, new EnemyMoveLimits(captured.lungeDistance / captured.lungeSeconds, captured.lungeDistance, captured.lungeSeconds))) return Finish(EnemyActionStatus.Completed); Phase = EnemyMeleePhase.Lunge; due = now + captured.lungeSeconds; Publish(due); return EnemyActionStatus.Running; } if (now >= due) return Finish(EnemyActionStatus.Completed); if (!movement.OwnsStraightContactServer(travelToken)) return Finish(EnemyActionStatus.Cancelled); if (TryContact(target)) return Finish(EnemyActionStatus.Completed); if (!movement.StraightMoveActive) return Finish(EnemyActionStatus.Completed); return EnemyActionStatus.Running; } private bool TryContact(ProjectI.Combat.PrototypePlayerCombat target) { if (hitConsumed || !Target(out var valid) || valid != target || !CommittedTarget.Equals(request.Target) || !ContactOpportunity(target, CommittedHeading)) return false; hitConsumed = true; float applied = target.Vitals.DamageServer(damage, "Melee strike"); #if UNITY_EDITOR || DEVELOPMENT_BUILD AppliedHits++; AppliedDamage += applied; #endif return true; } internal bool TryCaptureCombatServer(EnemyActorContext owner, out EnemyObservation evidence, out double validUntil) { evidence = default; validUntil = 0; if (!ReferenceEquals(owner, actor) || !Authorized || !combatParticipation || actor.Status.Stunned || !Target(out var target) || Active && !request.Token.IsValidFor(actor) || !Active && (terminal != EnemyActionStatus.Completed || actor.Now >= ReadyAt) || Phase == EnemyMeleePhase.Lunge && !movement.OwnsStraightContactServer(travelToken) || !perception.TryVerifyPersonalSightServer(request.Target, actor.Now, out _) || !perception.TryGetSnapshot(request.Target, actor.Now, out var snapshot) || !snapshot.Personal.HasValue) return false; Vector3 heading = target.Vitals.AcceptedPosition - transform.position; heading.y = 0; if (Phase == EnemyMeleePhase.Committed || Phase == EnemyMeleePhase.Lunge) heading = CommittedHeading; if (!ContactOpportunity(target, heading.sqrMagnitude > .000001f ? heading.normalized : transform.forward)) return false; evidence = snapshot.Personal.Value; validUntil = System.Math.Min(snapshot.PersonalExpiresAt, actor.Now + 1); if (Active) validUntil = System.Math.Min(validUntil, Phase == EnemyMeleePhase.Lunge ? due : due + captured.lungeSeconds); else validUntil = System.Math.Min(validUntil, ReadyAt); return actor.Now < validUntil; } private bool ContactOpportunity(ProjectI.Combat.PrototypePlayerCombat target, Vector3 heading) { if (movement.Safety == null || !movement.Safety.Supported(transform.position)) return false; Vector3 origin = transform.position + Vector3.up * Mathf.Min(.9f, actor.Owner.Navigation.height * .5f); Vector3 toTarget = target.Vitals.AcceptedPosition - transform.position; toTarget.y = 0; if (Vector3.Dot(toTarget, heading) < 0) return false; int count = Physics.OverlapSphereNonAlloc(origin, captured.clearanceRadius, overlaps, ~0, QueryTriggerInteraction.Ignore); if (count == overlaps.Length) return false; Collider nearest = null; for (int i = 0; i < count; i++) if (!overlaps[i].transform.IsChildOf(transform)) { if (!overlaps[i].transform.IsChildOf(target.transform)) return false; nearest = overlaps[i]; } if (nearest == null) { count = Physics.SphereCastNonAlloc(origin, captured.clearanceRadius, heading, hits, captured.attackRange, ~0, QueryTriggerInteraction.Ignore); if (count == hits.Length) return false; 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.transform); } public void CancelServer(EnemyActionToken token) { if (Active && request.Token.Equals(token)) Finish(EnemyActionStatus.Cancelled); } private EnemyActionStatus Finish(EnemyActionStatus status) { if (status != EnemyActionStatus.Completed) combatParticipation = false; movement?.Cancel(travelToken); travelToken = default; request.Token.Invalidate(); if (actor != null && captured != null) ReadyAt = Math.Max(ReadyAt, actor.Now + captured.recoverySeconds); Phase = EnemyMeleePhase.Recovery; terminal = status; Publish(ReadyAt); return status; } private void Publish(double until) { if (IsSpawned && IsServer) cue.Value = new EnemyMeleeCue { Lifetime = paidLifetime, Attempt = request.Token.Sequence, Phase = Phase, Until = until, CommittedAt = committedAt, Heading = CommittedHeading }; } private void Update() { if (!IsSpawned) return; ConsumePresentation(cue.Value, GetComponent().ServerNow); } private void ConsumePresentation(EnemyMeleeCue state, double now) { var owner = GetComponent(); if (owner == null || !owner.IsAlive || state.Lifetime != owner.SpawnIdentity || state.Attempt == 0 || !EnemyObservationRules.Finite(now) || !EnemyObservationRules.Finite(state.Until) || !EnemyObservationRules.Finite(state.CommittedAt) || state.CommittedAt > state.Until || state.Lifetime == lastPresented.Lifetime && (state.Attempt < lastPresented.Attempt || state.Attempt == lastPresented.Attempt && state.Phase < lastPresented.Phase)) return; float remaining = Mathf.Max(0, (float)(state.Until - now)); PresentedPhase = remaining > 0 ? state.Phase : EnemyMeleePhase.Recovery; if (state.Equals(lastPresented)) return; lastPresented = state; PresentedHeading = state.Heading; if (state.Phase == EnemyMeleePhase.Preparation && remaining > 0) owner.presentation?.Windup(remaining, transform.position + transform.forward); else if ((state.Phase == EnemyMeleePhase.Committed || state.Phase == EnemyMeleePhase.Lunge) && remaining > 0) { if (presentedLifetime == state.Lifetime && presentedAttempt == state.Attempt) return; presentedLifetime = state.Lifetime; presentedAttempt = state.Attempt; owner.presentation?.MeleeCommit(Mathf.Max(0, (float)(now - state.CommittedAt))); #if UNITY_EDITOR || DEVELOPMENT_BUILD PresentedCommitments++; #endif } else owner.presentation?.CancelPreparation(); } public EnemyRecoveryAssessment AssessRecoveryServer(EnemyActorContext context, EnemyTargetKey target) { if (!ReferenceEquals(actor, context) || !Authorized) return EnemyRecoveryAssessment.Unknown; if (Active || actor.Now < ReadyAt || actor.Status.Rooted || actor.Status.Stunned) return EnemyRecoveryAssessment.Deferred; if (!actor.Network.SpawnManager.SpawnedObjects.TryGetValue(target.NetworkObjectId, out var obj) || !obj.TryGetComponent(out var player) || !EnemyTargetRules.Eligible(player, target, actor.Network, actor.Stage, actor.RunId, actor.StageId)) return EnemyRecoveryAssessment.Unknown; if (Vector3.Distance(transform.position, player.Vitals.AcceptedPosition) > captured.lungeAdmissionRange) return EnemyRecoveryAssessment.Infeasible; return perception.TryVerifyPersonalSightServer(target, actor.Now, out _) ? EnemyRecoveryAssessment.Useful : EnemyRecoveryAssessment.Unknown; } public void ShutdownServer(EnemyShutdownReason reason) { if (Active) Finish(EnemyActionStatus.Cancelled); actor = null; combatParticipation = false; if (captured != null) Destroy(captured); captured = null; } private void OnDisable() => ShutdownServer(EnemyShutdownReason.Disabled); public override void OnDestroy() { ShutdownServer(EnemyShutdownReason.Despawn); base.OnDestroy(); } } }