using System; using System.Collections.Generic; using ProjectI.Builds; using ProjectI.Enemies; using ProjectI.Runs; using ProjectI.Combat.Statuses; using UnityEngine; namespace ProjectI.Combat { // Owner-local authoritative runtime. Shared assets contain no mutable field/charge state. public sealed class AbilityExpansionRuntime { private sealed class Field { public AbilityCastRecord Cast;public AbilityEffect Effect;public Vector3 Position;public double End,Next,Last; public AbilityEffectParameters Parameters; public float Radius,Damage,TickShield;public readonly HashSet<(ulong,uint)> Entered=new HashSet<(ulong,uint)>(); public readonly Dictionary<(ulong,uint),float> Pulled=new Dictionary<(ulong,uint),float>(); } private sealed class Pending {public AbilityCastRecord Cast;public Vector3 Point,Velocity;public float Damage,Radius,EdgeFraction;public double At,Last,LastVisual;public bool Stopped;public int VisualId;} private readonly PrototypePlayerCombat actor; private readonly Dictionary fields=new Dictionary(4); private readonly List pending=new List(4); private readonly List remove=new List(4); private readonly List targets=new List(64); private int nextProjectileVisual=1000; private int AllocateProjectileVisual(){if(nextProjectileVisual==int.MaxValue)nextProjectileVisual=1000;return ++nextProjectileVisual;} public GunTriggerWindow GunWindow {get;}=new GunTriggerWindow(); public int FieldCount=>fields.Count; public int PendingCount=>pending.Count; public AbilityExpansionRuntime(PrototypePlayerCombat actor){this.actor=actor;} public static bool GroundTarget(Vector3 origin,Vector3 direction,float range,out Vector3 point) { point=default; if(!CombatAuthorityRules.IsValidAim(direction)||!Physics.Raycast(origin,direction,out var hit,range,~LayerMask.GetMask("Players","CombatTargets"),QueryTriggerInteraction.Ignore)||hit.normal.y<.5f)return false; point=hit.point+Vector3.up*.15f;return true; } public bool Preflight(AbilityTuning tuning,Vector3 origin,Vector3 direction,out Vector3 point) { point=origin; if(tuning==null||!CombatAuthorityRules.IsValidAim(direction))return false; float range=tuning.EffectParameters.PlacementMetres; if(range>0)return GroundTarget(origin,direction,range,out point); if(tuning.Effect==AbilityEffect.ShardGrenade) { if(pending.Count>=4)return false; point=Physics.Raycast(origin,direction,out var hit,20,~LayerMask.GetMask("Players"),QueryTriggerInteraction.Ignore)?hit.point-direction*.1f:origin+direction*20; } return true; } private float DurationBonus=>actor.StatusDurationBonus; private void Select(Vector3 point,float radius) { targets.Clear();foreach(var enemy in PrototypeEnemy.ActiveEnemies) if(enemy.IsAlive&&(enemy.AimPoint-point).sqrMagnitude<=radius*radius&&Visible(point,enemy.AimPoint))targets.Add(enemy); targets.Sort((a,b)=>{int distance=(a.AimPoint-point).sqrMagnitude.CompareTo((b.AimPoint-point).sqrMagnitude);return distance!=0?distance:a.TargetId.CompareTo(b.TargetId);}); } private static bool Visible(Vector3 origin,Vector3 point)=>!Physics.Linecast(origin,point,~LayerMask.GetMask("Players","CombatTargets"),QueryTriggerInteraction.Ignore); public void Activate(AbilityActivationContext context,AbilityCastRecord cast,Vector3 prepared) { var tuning=context.Tuning;var now=cast.StartedAt; switch(tuning.Effect) { case AbilityEffect.GravitySnare:AddField(cast,tuning,prepared,now);break; case AbilityEffect.ScanPulse: Select(actor.Vitals.AcceptedPosition+Vector3.up,tuning.Radius); foreach(var enemy in targets)enemy.ApplyScanServer(new StatusApplication(cast.Owner,EnemyStatusId.Mark,1,tuning.Duration*(1+DurationBonus),cast.AttackId,false,tuning.Duration*(1+actor.PassiveStatusDurationBonus),cast.AbilityId),now); actor.ExpansionVisualServer(cast.AbilityId,actor.Vitals.AcceptedPosition,tuning.Radius,.18f,true);break; case AbilityEffect.ShardGrenade: var grenade=new Pending{Cast=cast,Point=cast.Origin,Velocity=cast.Direction*tuning.EffectParameters.ProjectileForwardSpeed+Vector3.up*tuning.EffectParameters.ProjectileUpwardSpeed,Damage=tuning.Magnitude,Radius=tuning.Radius,EdgeFraction=tuning.EffectParameters.EdgeDamageFraction,At=now+tuning.Duration,Last=now,VisualId=AllocateProjectileVisual()};pending.Add(grenade); actor.ExpansionVisualServer(grenade.VisualId,cast.Origin,.12f,(float)tuning.Duration,false);break; case AbilityEffect.RepulsorBurst: Select(cast.Origin,tuning.Radius);Vector3 forward=new Vector3(cast.Direction.x,0,cast.Direction.z).normalized; foreach(var enemy in targets) { Vector3 delta=enemy.transform.position-cast.Origin;delta.y=0; if(Vector3.Dot(forward,delta.normalized)=Mathf.Cos(tuning.EffectParameters.ConeHalfAngleDegrees*Mathf.Deg2Rad))Apply(enemy,echo,tuning.Magnitude); } else if(tuning.Effect==AbilityEffect.ArcAnchor) { // One bounded secondary packet representing the authored 6s schedule; no replacement field or fresh cast credit. Select(point,tuning.Radius);for(int i=0;i=0;i--) { var p=pending[i];if(!p.Cast.Valid(actor)){pending.RemoveAt(i);actor.ClearExpansionVisualServer(p.VisualId);continue;} double at=Math.Min(now,p.At),remaining=Math.Max(0,at-p.Last); while(remaining>0&&!p.Stopped) { float step=(float)Math.Min(.05,remaining);Vector3 delta=p.Velocity*step+Vector3.down*4.905f*step*step; if(Physics.SphereCast(p.Point,.1f,delta.normalized,out var hit,delta.magnitude,~LayerMask.GetMask("Players"),QueryTriggerInteraction.Ignore)) {p.Point=hit.point+hit.normal*.12f;p.Stopped=true;} else p.Point+=delta; p.Velocity+=Vector3.down*9.81f*step;remaining-=step; } p.Last=at; if(now>=p.At){pending.RemoveAt(i);Burst(p.Cast,p.Point,p.Radius,p.Damage,true,p.EdgeFraction);actor.ExpansionVisualServer(p.VisualId,p.Point,p.Radius,.3f,true);} else if(now-p.LastVisual>=.05){p.LastVisual=now;actor.ExpansionVisualServer(p.VisualId,p.Point,.12f,(float)(p.At-now),false);} } remove.Clear(); foreach(var pair in fields) { var field=pair.Value; if(!field.Cast.Valid(actor)||(pair.Key==-1&&actor.UpgradeRank(UpgradeId.DashWake)==0)){remove.Add(pair.Key);continue;} if(now0?Mathf.Min(Mathf.Max(0,maximum-pulled)/factor,(float)seconds*field.Parameters.PullMetresPerSecond):0; Vector3 before=enemy.transform.position; if(step>0&&enemy.TryDisplaceServer((field.Position-before).normalized*step,step)) {Vector3 travel=enemy.transform.position-before;travel.y=0;field.Pulled[key]=Mathf.Min(maximum,pulled+travel.magnitude);} } } if(field.Effect==AbilityEffect.ArcAnchor)Complete(field.Cast); } if(now>=field.End) {if(field.Effect==AbilityEffect.GravityCollapse)Burst(field.Cast,field.Position,field.Radius,field.Damage,false);remove.Add(pair.Key);} } foreach(int id in remove){fields.Remove(id);actor.ClearExpansionVisualServer(id);} } private void TickWard(Field field,double at,double seconds) { // Recipient-side strongest family budget prevents four overlapping tick writers adding together. foreach(var ally in PrototypePlayerVitals.ActivePlayers) { if(!ally.IsSpawned||!ally.IsAlive||(ally.AcceptedPosition-field.Position).sqrMagnitude>field.Radius*field.Radius||!Visible(field.Position,ally.AcceptedPosition+Vector3.up))continue; ally.GrantWardFamilyServer(field.TickShield*(float)seconds,field.Parameters.TickSeconds); } } public bool TryGetWardBenefit(PrototypePlayerVitals recipient,double now,out PlayerBuffSnapshot benefit) { benefit=default;bool found=false; foreach(var field in fields.Values) { if(field.Effect!=AbilityEffect.WardBeacon||now>=field.End||!field.Cast.Valid(actor)||recipient==null||!recipient.IsAlive ||(recipient.AcceptedPosition-field.Position).sqrMagnitude>field.Radius*field.Radius ||!Visible(field.Position,recipient.AcceptedPosition+Vector3.up))continue; var candidate=new PlayerBuffSnapshot(EffectSourceKey.Ability(field.Cast.AbilityId,field.Cast.Owner),PlayerBuffEffect.WardShieldRate,field.TickShield,field.End); if(!found||candidate.Magnitude>benefit.Magnitude||candidate.Magnitude==benefit.Magnitude&&candidate.Source.CompareTo(benefit.Source)<0) {benefit=candidate;found=true;} } return found; } private void Burst(AbilityCastRecord cast,Vector3 point,float radius,float amount,bool falloff,float edgeFraction=.5f) { Select(point,radius);foreach(var target in targets) {float factor=falloff?Mathf.Lerp(1,edgeFraction,Mathf.Clamp01((target.AimPoint-point).magnitude/radius)):1;Apply(target,cast,amount*factor);}Complete(cast); } private void Apply(PrototypeEnemy enemy,AbilityCastRecord cast,float amount) { if(!cast.Valid(actor))return;var result=enemy.ApplyDamageServer(cast.Damage(amount,enemy.AimPoint)); if(cast.Secondary)actor.ProcessEchoDamageResultServer(result);else actor.ProcessDamageResultServer(result); } private static void Complete(AbilityCastRecord cast)=>PrototypeRunDirector.Instance?.CompleteAttack(cast.Owner,cast.AttackId); public void Clear() {foreach(int id in fields.Keys)actor.ClearExpansionVisualServer(id);foreach(var p in pending)actor.ClearExpansionVisualServer(p.VisualId);fields.Clear();pending.Clear();GunWindow.Clear();} public void RemoveField(int id){if(fields.Remove(id))actor.ClearExpansionVisualServer(id);} } }