using System; using System.Collections.Generic; namespace Luneraria.Core { /// A death recorded during combat; the settlement pays bounties and XP from it. public sealed class CombatEvent { public int Serial; public int Step; public double Time; public int Team; public string Lane = ""; public string Zone = "lane"; /// Kind of the unit that died. public string Kind = ""; public int UnitId; public string DefId = ""; public int Bounty; public int OwnerSlot = -1; public string PersistentUid = ""; public string BuildingId = ""; public int Xp; public List XpHeroes = new List(); } /// /// One automatic combat phase on all three lanes (reference: lune_combat.gd). Every step /// first gathers targets, movement, hits, heals and effects from the state at the start of /// the step, then applies them together, so processing order never decides who strikes /// first. Deaths of a step resolve together; two Ancients falling in one step is a draw. /// Iteration happens only over lists in spawn order and over insertion-ordered maps. /// public sealed class LuneCombat { public const int Neutral = 2; /// Units without a target look for one every RETARGET steps, staggered by TeamIndex. public const int Retarget = 4; const double Cell = 4.0; public readonly LuneMap Map; public readonly LuneBalance Balance; public readonly int RoundNumber; public readonly List Units = new List(); public readonly OrderedMap Buildings = new OrderedMap(); public double Elapsed; public int Steps; public bool Finished; /// cleared, timeout or core. public string EndReason = ""; public readonly List DestroyedCores = new List(); public readonly List Events = new List(); public LuneUnit GrottoBoss; public bool GrottoResolved = true; public int GrottoClaimTeam = -1; public int GrottoActiveTeam = -1; /// none, success or failed. public string GrottoOutcome = "none"; int _nextId = 1; readonly int[] _teamCount = new int[3]; int _eventSerial; readonly Dictionary _pendingDamage = new Dictionary(); readonly Dictionary _pendingHeal = new Dictionary(); readonly List _pendingEffects = new List(); readonly List _pendingDots = new List(); readonly List _pendingShields = new List(); // Per-step caches: building gates, march limits, spatial buckets of mobile units. readonly Dictionary _attackable = new Dictionary(); readonly Dictionary _limits = new Dictionary(); readonly Dictionary>[] _grid = { new Dictionary>(), new Dictionary>(), new Dictionary>() }; struct PendingEffect { public LuneUnit Unit; public string Family; public string Kind; public double Amount; public double Duration; } struct PendingDot { public LuneUnit Unit; public string Family; public double Damage; public double Duration; public int AttackType; } struct PendingShield { public LuneUnit Unit; public double Amount; public double Duration; } public LuneCombat(LuneMap map, LuneBalance balance, int roundNumber) { Map = map; Balance = balance; RoundNumber = roundNumber; } // ---------------------------------------------------------------- setup public LuneUnit AddBuilding(BuildingDef definition, double hp) { var unit = new LuneUnit(); unit.Id = _nextId++; unit.Kind = definition.Tier == "core" ? "core" : "tower"; unit.Team = definition.Team; unit.Lane = definition.Lane; unit.BuildingId = definition.Id; unit.TeamIndex = _teamCount[unit.Team]++; unit.Def = new UnitDef { Id = definition.Id, DisplayName = definition.Id, MaxHp = definition.MaxHp, Damage = definition.Damage, AttackRange = definition.Range, AttackType = 1, ArmorType = 1, Speed = 0.0, Ability = "", Bounty = definition.Bounty, Tier = definition.Tier }; unit.Position = definition.Position; unit.PreviousPosition = unit.Position; unit.MaxHp = definition.MaxHp; unit.Hp = GdMath.Clamp(hp, 0.0, unit.MaxHp); unit.Alive = unit.Hp > 0.0; Units.Add(unit); Buildings[unit.BuildingId] = unit; return unit; } public LuneUnit Spawn(string kind, int team, string lane, UnitDef definition, double progress, double lateral, int slot = -1, string uid = "") { var unit = new LuneUnit(); unit.Id = _nextId++; unit.Kind = kind; unit.Team = team; unit.Lane = lane; unit.Def = definition; unit.OwnerSlot = slot; unit.PersistentUid = uid ?? ""; unit.TeamIndex = _teamCount[team]++; unit.Progress = progress; unit.Lateral = lateral; unit.Position = Map.LanePoint(team, lane, progress, lateral); unit.PreviousPosition = unit.Position; Vec2 forward = Map.LaneForward(team, lane, progress); unit.Facing = GdMath.Atan2(forward.X, forward.Y); unit.MaxHp = definition.MaxHp; unit.Hp = unit.MaxHp; StartAbility(unit); Units.Add(unit); return unit; } public LuneUnit SpawnAt(string kind, int team, string zone, UnitDef definition, Vec2 at, int slot = -1, string uid = "") { LuneUnit unit = Spawn(kind, team, "mid", definition, 0.0, 0.0, slot, uid); unit.Zone = zone; unit.Lane = zone; unit.Position = at; unit.PreviousPosition = at; return unit; } public LuneUnit AddGrottoBoss(UnitDef definition) { GrottoBoss = SpawnAt("boss", Neutral, LuneMap.GrottoZone, definition, Map.GrottoBossPoint()); GrottoBoss.Dormant = true; GrottoResolved = false; return GrottoBoss; } public Dictionary AliveMap() { var result = new Dictionary(); IReadOnlyList values = Buildings.Values; for (int i = 0; i < values.Count; i++) result[values[i].BuildingId] = values[i].Alive; return result; } public bool BuildingAttackable(LuneUnit unit, Dictionary gates) { if (!unit.Alive) return false; bool cached; if (_attackable.TryGetValue(unit.BuildingId, out cached)) return cached; return LuneMap.IsAttackable(Map.BuildingDef(unit.BuildingId), gates); } void PrepareCaches(List living, Dictionary gates) { _attackable.Clear(); IReadOnlyList buildings = Buildings.Values; for (int i = 0; i < buildings.Count; i++) _attackable[buildings[i].BuildingId] = LuneMap.IsAttackable(Map.BuildingDef(buildings[i].BuildingId), gates); _limits.Clear(); for (int team = 0; team < 2; team++) { foreach (string lane in LuneMap.Lanes) { LanePath path = Map.LanePaths[lane]; double limit = path.Length; for (int i = 0; i < buildings.Count; i++) { LuneUnit building = buildings[i]; if (building.Alive && building.Kind == "tower" && building.Team != team && building.Lane == lane) limit = GdMath.Min(limit, path.Length - Map.BuildingDef(building.BuildingId).Progress - 1.5); } _limits[team * 3 + LuneMap.LaneIndex(lane)] = limit; } } for (int side = 0; side < 3; side++) _grid[side].Clear(); foreach (LuneUnit unit in living) { if (!unit.IsMobile()) continue; var key = new Vec2I(GdMath.FloorI(unit.Position.X / Cell), GdMath.FloorI(unit.Position.Y / Cell)); List bucket; if (!_grid[unit.Team].TryGetValue(key, out bucket)) { bucket = new List(); _grid[unit.Team][key] = bucket; } bucket.Add(unit); } } public static int[] HostileTeams(int team) { return team == Neutral ? new[] { 0, 1 } : new[] { 1 - team, Neutral }; } /// Mobile units of the given teams in the cells around a point; the caller checks distance. List NearMobile(Vec2 center, double radius, int[] teams) { var result = new List(); int reach = GdMath.CeilI(radius / Cell); int cx = GdMath.FloorI(center.X / Cell); int cy = GdMath.FloorI(center.Y / Cell); foreach (int side in teams) { Dictionary> cells = _grid[side]; if (cells.Count == 0) continue; for (int dx = -reach; dx <= reach; dx++) { for (int dy = -reach; dy <= reach; dy++) { List bucket; if (cells.TryGetValue(new Vec2I(cx + dx, cy + dy), out bucket)) result.AddRange(bucket); } } } return result; } // ---------------------------------------------------------------- step public void Step() { if (Finished) return; double dt = Balance.SimStep; Elapsed += dt; Steps += 1; _pendingDamage.Clear(); _pendingHeal.Clear(); _pendingEffects.Clear(); _pendingDots.Clear(); _pendingShields.Clear(); Dictionary gates = AliveMap(); var living = new List(); foreach (LuneUnit unit in Units) { if (unit.Alive) { living.Add(unit); unit.PreviousPosition = unit.Position; } } PrepareCaches(living, gates); foreach (LuneUnit unit in living) { double dot = unit.AdvanceStatus(dt); if (dot > 0.0) QueueDamage(unit, dot, gates); } foreach (LuneUnit unit in living) { if (unit.IsMobile()) AbilityStep(unit, living, gates); } foreach (LuneUnit unit in living) { if (unit.IsMobile() || unit.Kind == "tower") { bool keep = unit.Target != null && unit.Target.Alive; if (unit.LeapTarget != null) unit.Target = unit.LeapTarget; else if (keep || (Steps + unit.TeamIndex) % Retarget == 0) unit.Target = ChooseTarget(unit, living, gates); } } var moves = new Dictionary>(); foreach (LuneUnit unit in living) { if (unit.IsMobile()) moves[unit.Id] = PlanMove(unit, living, dt); } foreach (LuneUnit unit in living) { KeyValuePair plan; if (!moves.TryGetValue(unit.Id, out plan)) continue; Vec2 next = plan.Key; Vec2 heading = next - unit.Position; unit.Position = next; if (heading.LengthSquared() > 0.000001) unit.Facing = GdMath.Atan2(heading.X, heading.Y); unit.LeapTarget = null; if (unit.Zone == "lane") UpdateProgress(unit, plan.Value); } foreach (LuneUnit unit in living) Attack(unit, living, gates, dt); foreach (LuneUnit unit in living) { double heal; if (_pendingHeal.TryGetValue(unit.Id, out heal)) unit.Heal(heal); } foreach (PendingShield entry in _pendingShields) entry.Unit.GrantShield(entry.Amount, entry.Duration); foreach (PendingEffect entry in _pendingEffects) entry.Unit.ApplyEffect(entry.Family, entry.Kind, entry.Amount, entry.Duration); foreach (PendingDot entry in _pendingDots) entry.Unit.ApplyDot(entry.Family, entry.Damage, entry.Duration, entry.AttackType); foreach (LuneUnit unit in living) { double damage; if (_pendingDamage.TryGetValue(unit.Id, out damage)) unit.Take(damage); } foreach (LuneUnit unit in living) { if (unit.Hp <= 0.0 && unit.Alive) { unit.Alive = false; unit.DeathTime = Elapsed; unit.Target = null; RecordDeath(unit); } } UpdateGrotto(); CheckEnd(); } /// Expeditions fight each other first; the boss wakes when at most one team remains. void UpdateGrotto() { if (GrottoResolved || GrottoBoss == null) return; var teamsAlive = new List(); foreach (LuneUnit unit in Units) { if (unit.Alive && unit.Zone == LuneMap.GrottoZone && unit.Team != Neutral && !teamsAlive.Contains(unit.Team)) teamsAlive.Add(unit.Team); } if (GrottoBoss.Dormant && teamsAlive.Count <= 1) { GrottoBoss.Dormant = false; GrottoActiveTeam = teamsAlive.Count == 1 ? teamsAlive[0] : -1; } if (!GrottoBoss.Alive) { GrottoResolved = true; GrottoClaimTeam = GrottoActiveTeam; GrottoOutcome = GrottoClaimTeam >= 0 ? "success" : "failed"; } else if (teamsAlive.Count == 0) { GrottoResolved = true; GrottoOutcome = "failed"; } } void CheckEnd() { if (DestroyedCores.Count > 0) { Finished = true; EndReason = "core"; return; } bool mobileAlive = !GrottoResolved; foreach (LuneUnit unit in Units) { if (unit.Alive && unit.IsMobile() && unit.Zone == "lane") { mobileAlive = true; break; } } if (!mobileAlive) { Finished = true; EndReason = "cleared"; } else if (Elapsed >= Balance.CombatTimeLimit - 0.000001) { Finished = true; EndReason = "timeout"; } } void RecordDeath(LuneUnit unit) { _eventSerial += 1; var ev = new CombatEvent { Serial = _eventSerial, Step = Steps, Time = Elapsed, Team = unit.Team, Lane = unit.Lane, Zone = unit.Zone, Kind = unit.Kind, UnitId = unit.Id, DefId = unit.Def.Id, Bounty = 0, OwnerSlot = unit.OwnerSlot, PersistentUid = unit.PersistentUid }; if (unit.Kind == "monster" || unit.Kind == "champion" || unit.Kind == "boss") { ev.Bounty = unit.Def.Bounty; } else if (unit.IsBuilding()) { ev.Bounty = unit.Def.Bounty; ev.BuildingId = unit.BuildingId; if (unit.Kind == "core" && !DestroyedCores.Contains(unit.Team)) DestroyedCores.Add(unit.Team); } // Participation XP: every opposing hero near the death shares it, whoever landed the hit. int xp = 0; switch (unit.Kind) { case "monster": xp = 12 + RoundNumber; break; case "champion": xp = 30 + 2 * RoundNumber; break; case "boss": xp = LuneContent.BossXp; break; case "troop": case "hero": xp = 15; break; case "tower": case "core": xp = LuneContent.BuildingXp; break; } double radius = LuneContent.XpRadius + (unit.IsBuilding() ? 2.0 : 0.0); foreach (LuneUnit other in Units) { if (other.Kind == "hero" && other.Alive && other.Zone == unit.Zone && other.Team != unit.Team && other.Position.DistanceTo(unit.Position) <= radius) ev.XpHeroes.Add(other.PersistentUid); } ev.Xp = xp; Events.Add(ev); } // ---------------------------------------------------------------- targeting and movement static bool Hostile(LuneUnit unit, LuneUnit other) { return other.Alive && other.Team != unit.Team && other.Zone == unit.Zone && !other.Dormant && !unit.Dormant; } LuneUnit ChooseTarget(LuneUnit unit, List living, Dictionary gates) { LuneUnit current = unit.Target; if (current != null && Hostile(unit, current) && unit.Position.DistanceTo(current.Position) <= Balance.LeashRadius) { if (!current.IsBuilding() || BuildingAttackable(current, gates)) { if (unit.Kind != "tower" || unit.Position.DistanceTo(current.Position) <= unit.AttackRange()) return current; } } double radius = unit.Kind == "tower" ? unit.AttackRange() : Balance.AggroRadius; LuneUnit best = null; double bestDistance = double.PositiveInfinity; string priority = unit.Def.Priority; IReadOnlyList buildings = Buildings.Values; if (priority == "building") { // Siege champions go for the nearest attackable building before anything else. for (int i = 0; i < buildings.Count; i++) { LuneUnit building = buildings[i]; if (Hostile(unit, building) && BuildingAttackable(building, gates)) { double reachDistance = unit.Position.DistanceTo(building.Position) - Balance.BuildingRadius; if (reachDistance <= Balance.AggroRadius && reachDistance < bestDistance) { best = building; bestDistance = reachDistance; } } } if (best != null) return best; } foreach (LuneUnit other in NearMobile(unit.Position, radius, HostileTeams(unit.Team))) { if (!Hostile(unit, other)) continue; double d = unit.Position.DistanceTo(other.Position); // Pressure champions treat support and ranged enemies as 6 m closer. if (priority == "support" && (other.Role() == "support" || other.Role() == "ranged")) d -= 6.0; if (d <= radius && (d < bestDistance - 0.0001 || (Math.Abs(d - bestDistance) <= 0.0001 && other.Id < best.Id))) { best = other; bestDistance = d; } } if (best != null || unit.Kind == "tower") return best; // Buildings: nearby attackable ones; at the end of a lane the whole base is in reach. for (int i = 0; i < buildings.Count; i++) { LuneUnit other = buildings[i]; if (!Hostile(unit, other) || !BuildingAttackable(other, gates)) continue; double d = unit.Position.DistanceTo(other.Position) - Balance.BuildingRadius; if ((d <= radius || unit.ReachedBase) && d < bestDistance) { best = other; bestDistance = d; } } return best; } double MarchLimit(LuneUnit unit) { double cached; if (_limits.TryGetValue(unit.Team * 3 + LuneMap.LaneIndex(unit.Lane), out cached)) return cached; LanePath path = Map.LanePaths[unit.Lane]; double limit = path.Length; IReadOnlyList buildings = Buildings.Values; for (int i = 0; i < buildings.Count; i++) { LuneUnit building = buildings[i]; if (building.Alive && building.Kind == "tower" && building.Team != unit.Team && building.Lane == unit.Lane) limit = GdMath.Min(limit, path.Length - Map.BuildingDef(building.BuildingId).Progress - 1.5); } return limit; } /// /// Returns the next position and the marching progress. Marching advances the lane /// parameter directly, so formation offsets cannot stall a unit on the inside of a bend. /// KeyValuePair PlanMove(LuneUnit unit, List living, double dt) { if (unit.LeapTarget != null) return new KeyValuePair(LeapLanding(unit, unit.LeapTarget), unit.Progress); double stepLength = unit.Speed() * Balance.MarchSpeedFactor * unit.MovementFactor() * dt; Vec2 goal = unit.Position; double travel = 0.0; double marched = unit.Progress; if (unit.Target != null) { double reach = unit.AttackRange() + (unit.Target.IsBuilding() ? Balance.BuildingRadius : 0.0); double gap = unit.Position.DistanceTo(unit.Target.Position) - reach; if (gap > 0.0) { goal = unit.Target.Position; travel = GdMath.Min(stepLength, gap + 0.02); } } else if (unit.Zone == LuneMap.GrottoZone) { if (unit.Kind != "boss" && GrottoBoss != null && GrottoBoss.Alive) { double gapBoss = unit.Position.DistanceTo(GrottoBoss.Position) - unit.AttackRange(); if (gapBoss > 0.0) { goal = GrottoBoss.Position; travel = GdMath.Min(stepLength, gapBoss + 0.02); } } } else if (unit.Zone == "lane") { double limit = MarchLimit(unit); Vec2 anchor = Map.LanePoint(unit.Team, unit.Lane, unit.Progress, unit.Lateral); double off = unit.Position.DistanceTo(anchor); if (off > 1.2) { // Rejoin the formation line after a chase before advancing again. goal = anchor; travel = GdMath.Min(stepLength, unit.Position.DistanceTo(anchor)); } else if (unit.Progress < limit - 0.01) { marched = GdMath.Min(unit.Progress + stepLength, limit); goal = Map.LanePoint(unit.Team, unit.Lane, marched, unit.Lateral); travel = unit.Position.DistanceTo(goal); } else if (off > 0.05) { goal = anchor; travel = GdMath.Min(stepLength, unit.Position.DistanceTo(anchor)); } } Vec2 next = unit.Position; if (travel > 0.0 && goal.DistanceSquaredTo(unit.Position) > 0.000001) next += (goal - unit.Position).Normalized() * GdMath.Min(travel, unit.Position.DistanceTo(goal)); // Separation from allies so bodies do not stack; opponents may still close to melee range. Vec2 push = Vec2.Zero; foreach (LuneUnit other in NearMobile(unit.Position, 1.1, new[] { unit.Team })) { if (other == unit || other.Zone != unit.Zone) continue; Vec2 offset = unit.Position - other.Position; double distance = offset.Length(); if (distance < 1.1) { if (distance < 0.0001) { // Mirrored teams sidestep in mirrored directions. offset = unit.Id < other.Id ? Vec2.Right : Vec2.Left; if (unit.Team == 1) offset = LuneMap.Mirror(offset); distance = 0.001; } push += offset.Normalized() * (1.1 - distance) * 2.0; } } next += push.LimitLength(0.9) * dt; next.X = (float)GdMath.Clamp(next.X, -LuneMap.HalfExtent, LuneMap.HalfExtent); next.Y = (float)GdMath.Clamp(next.Y, -LuneMap.HalfExtent, LuneMap.HalfExtent); return new KeyValuePair(next, marched); } void UpdateProgress(LuneUnit unit, double marched) { LanePath path = Map.LanePaths[unit.Lane]; double limit = MarchLimit(unit); if (unit.Target != null) { // After a chase the unit continues from where it now stands, never further back. Vec2 blue = unit.Team == 0 ? unit.Position : LuneMap.Mirror(unit.Position); marched = GdMath.Max(marched, GdMath.Min(path.Project(blue), limit)); } unit.Progress = GdMath.Max(unit.Progress, GdMath.Min(marched, GdMath.Max(limit, unit.Progress))); if (unit.Progress >= path.Length - 0.6) unit.ReachedBase = true; } // ---------------------------------------------------------------- attacks void Attack(LuneUnit unit, List living, Dictionary gates, double dt) { unit.InRange = false; LuneUnit target = unit.Target; if (target == null || !target.Alive || unit.Kind == "core") { unit.AttackProgress = 0.0; return; } double reach = unit.AttackRange() + (target.IsBuilding() ? Balance.BuildingRadius : 0.0) + 0.035; if (unit.Position.DistanceTo(target.Position) > reach) { unit.AttackProgress = 0.0; return; } if (target.IsBuilding() && !BuildingAttackable(target, gates)) { unit.AttackProgress = 0.0; return; } unit.InRange = true; double interval = unit.Kind == "tower" ? Balance.TowerAttackInterval : 1.0; unit.AttackProgress += dt * unit.AttackSpeedFactor() / interval; while (unit.AttackProgress >= 1.0 - 0.000001) { unit.AttackProgress = GdMath.Max(0.0, unit.AttackProgress - 1.0); unit.HitCount += 1; unit.AttackSerial += 1; double damage = unit.Def.Damage * (1.0 + unit.DamageBonus()); if (unit.HitCount % 4 == 0) { if (unit.Ability() == "g05") damage *= unit.HasTalent("durchschlag") ? 2.1 : 1.5; else if (unit.Ability() == "g06") damage *= 1.75; } if (unit.HitCount % 3 == 0 && unit.HasTalent("stammschlag")) damage *= 1.6; if (unit.LeapStrike) { damage *= SylvaraLeapStrike; unit.LeapStrike = false; } if (target.IsBuilding()) damage *= unit.Def.SiegeFactor; QueueDamage(target, target.Mitigated(damage, unit.Def.AttackType), gates); OnHit(unit, target, living, gates); // Item effects come last and never trigger further on-hit effects. double itemSplash = unit.Def.ItemSplash; if (itemSplash > 0.0) Splash(unit, target, living, gates, itemSplash, 1.5, 2); } } public void QueueDamage(LuneUnit target, double amount, Dictionary gates) { if (amount <= 0.0 || !target.Alive) return; // The building gate applies to every source: ranged, splash, DoT and abilities. if (target.IsBuilding() && !BuildingAttackable(target, gates)) return; double current; _pendingDamage.TryGetValue(target.Id, out current); _pendingDamage[target.Id] = current + amount; } /// Damage queued for a unit in the current step (tests). public bool HasPendingDamage(LuneUnit unit) { return _pendingDamage.ContainsKey(unit.Id); } void QueueHeal(LuneUnit target, double amount) { double current; _pendingHeal.TryGetValue(target.Id, out current); _pendingHeal[target.Id] = current + amount; } // ---------------------------------------------------------------- abilities void StartAbility(LuneUnit unit) { unit.AbilityReady = 6.0; switch (unit.Ability()) { case "w03": case "w04": unit.AbilityReady = 5.0; break; case "w09": case "w10": unit.AbilityReady = 4.0; break; case "s02": case "ch_schutz": unit.GrantShield(unit.MaxHp * 0.15); break; case "e08": unit.GrantShield(unit.MaxHp * 0.20); break; case "sylvara": unit.AbilityReady = SylvaraFirstThrow; break; } } List Nearby(LuneUnit unit, bool allied, Vec2 center, double radius, int maximum, LuneUnit excluded = null, bool healthPriority = false) { var result = new List(); // Buildings can be splash victims; they are few, so check them directly. List pool = NearMobile(center, radius, allied ? new[] { unit.Team } : HostileTeams(unit.Team)); if (!allied) { IReadOnlyList buildings = Buildings.Values; for (int i = 0; i < buildings.Count; i++) pool.Add(buildings[i]); } foreach (LuneUnit other in pool) { if (!other.Alive || other == excluded || other.Zone != unit.Zone) continue; if ((other.Team == unit.Team) != allied || other.Dormant) continue; if (allied && other.IsBuilding()) continue; if (other.Position.DistanceTo(center) <= radius + 0.000001) result.Add(other); } GdMath.SortStable(result, (a, b) => { if (healthPriority && !GdMath.IsEqualApprox(a.Hp / a.MaxHp, b.Hp / b.MaxHp)) return a.Hp / a.MaxHp < b.Hp / b.MaxHp; double ad = a.Position.DistanceTo(center); double bd = b.Position.DistanceTo(center); return GdMath.IsEqualApprox(ad, bd) ? a.Id < b.Id : ad < bd; }); if (result.Count > maximum) result.RemoveRange(maximum, result.Count - maximum); return result; } void AddEffect(LuneUnit target, string family, string kind, double amount, double duration) { _pendingEffects.Add(new PendingEffect { Unit = target, Family = family, Kind = kind, Amount = amount, Duration = duration }); } void AddDot(LuneUnit target, string family, double damage, double duration, int attackType) { _pendingDots.Add(new PendingDot { Unit = target, Family = family, Damage = damage, Duration = duration, AttackType = attackType }); } void AbilityStep(LuneUnit unit, List living, Dictionary gates) { if (unit.Ability() == "sylvara") SylvaraStep(unit, gates); double regen = unit.Def.Regen; if (regen > 0.0 && unit.Hp < unit.MaxHp) QueueHeal(unit, unit.MaxHp * regen * Balance.SimStep); if (unit.HasTalent("schutzwurzeln") && unit.CombatTime + 0.000001 >= unit.AuraReady) { foreach (LuneUnit ally in Nearby(unit, true, unit.Position, 4.0, 4)) AddEffect(ally, "bark_guard", "reduction", 0.10, 4.5); unit.AuraReady = unit.CombatTime + 4.0; } if (unit.CombatTime + 0.000001 < unit.AbilityReady) return; string ability = unit.Ability(); bool success = false; switch (ability) { case "w03": case "w04": if (unit.Hp < unit.MaxHp) { QueueHeal(unit, ability == "w04" ? 100.0 : 35.0); success = true; } break; case "w09": case "w10": { bool upgraded = ability == "w10"; double radius = upgraded ? 4.5 : 4.0; int count = upgraded ? 3 : 1; double amount = upgraded ? 80.0 : 65.0; if (unit.HasTalent("tiefe_quelle")) { count = 1; amount = 190.0; } else if (unit.HasTalent("weite_quelle")) { radius = 5.5; count = 5; amount = 65.0; } foreach (LuneUnit ally in Nearby(unit, true, unit.Position, radius, count, null, true)) { if (ally.Hp < ally.MaxHp) { QueueHeal(ally, amount); if (unit.HasTalent("quellschild")) _pendingShields.Add(new PendingShield { Unit = ally, Amount = 70.0, Duration = 4.0 }); success = true; } } break; } case "ch_schutz": { List allies = Nearby(unit, true, unit.Position, 4.0, 4); foreach (LuneUnit ally in allies) _pendingShields.Add(new PendingShield { Unit = ally, Amount = 120.0, Duration = 6.0 }); success = allies.Count > 0; break; } case "g09": case "g10": { bool upgraded = ability == "g10"; List allies = Nearby(unit, true, unit.Position, upgraded ? 4.0 : 3.0, upgraded ? 4 : 3, null, true); foreach (LuneUnit ally in allies) { AddEffect(ally, "hearth_warmth", "damage", upgraded ? 0.15 : 0.10, 4.0); if (upgraded) QueueHeal(ally, 45.0); } success = allies.Count > 0; break; } case "e06": foreach (LuneUnit ally in Nearby(unit, true, unit.Position, 4.0, 100, null, true)) { if (ally.Hp < ally.MaxHp) { QueueHeal(ally, 30.0 * unit.Def.HpFactor); success = true; break; } } break; case "s03": case "e09": { List victims = Nearby(unit, false, unit.Position, ability == "e09" ? 6.0 : 3.0, ability == "e09" ? 3 : 4); foreach (LuneUnit victim in victims) AddEffect(victim, "dissonance", "attack_slow", 0.15, 3.0); success = victims.Count > 0; break; } case "s05": { List victims = Nearby(unit, false, unit.Position, 5.0, 1); foreach (LuneUnit victim in victims) AddEffect(victim, "rift_wound", "healing_reduction", 0.30, 4.0); success = victims.Count > 0; break; } case "sylvara": success = SylvaraThrow(unit, gates); break; } if (success) { unit.AbilityReady = unit.CombatTime + (ability == "w03" || ability == "w04" ? 5.0 : (ability == "w09" || ability == "w10" ? 4.0 : 6.0)); unit.AbilitySerial += 1; } } // ---------------------------------------------------------------- H1 hero kits public const double SylvaraLeapRange = 8.0; public const double SylvaraLeapStrike = 1.5; public const double SylvaraFirstThrow = 4.0; public const double SylvaraThrowRange = 6.0; /// The leap only targets back-row enemies with at most this many allies within 3 m. public const int SylvaraLeapGuards = 2; /// Mondschleier: shield after the leap and after the ultimate, share of max HP, for 4 s. public const double SylvaraVeil = 0.25; public const double SylvaraDance = 2.2; public const double SylvaraFullMoon = 2.8; /// Back-row enemies: the leap and the "zweite_reihe" rule look for these. public const double BackRowRange = 3.0; static bool BackRow(LuneUnit other) { return other.IsMobile() && other.AttackRange() >= BackRowRange - 0.000001; } List NearbyMobile(LuneUnit unit, double radius, int maximum) { List result = Nearby(unit, false, unit.Position, radius, 1000); result.RemoveAll(other => !other.IsMobile()); if (result.Count > maximum) result.RemoveRange(maximum, result.Count - maximum); return result; } /// Mobile allies of the target within 3 m of it (its guards against a leap). int Guards(LuneUnit unit, LuneUnit target) { int count = 0; foreach (LuneUnit other in NearMobile(target.Position, 3.0, new[] { target.Team })) { if (other != target && other.Alive && other.Zone == target.Zone && other.Position.DistanceTo(target.Position) <= 3.0 + 0.000001) count++; } return count; } /// Lands 0.9 m in front of the target, on the side the unit comes from. static Vec2 LeapLanding(LuneUnit unit, LuneUnit target) { Vec2 away = unit.Position - target.Position; if (away.LengthSquared() < 0.000001) away = unit.Team == 0 ? Vec2.Right : Vec2.Left; Vec2 landing = target.Position + away.Normalized() * 0.9; landing.X = (float)GdMath.Clamp(landing.X, -LuneMap.HalfExtent, LuneMap.HalfExtent); landing.Y = (float)GdMath.Clamp(landing.Y, -LuneMap.HalfExtent, LuneMap.HalfExtent); return landing; } /// /// Mondsprung (passive, once per combat): the first back-row enemy within 8 m draws the /// leap; she lands next to it, takes it as target and strikes the first hit at 150 %. /// Mondsicheltanz (ultimate, once per combat after 2 s): released by the planned rule. /// Both decide on the state at the start of the step; the landing is applied with the moves. /// void SylvaraStep(LuneUnit unit, Dictionary gates) { if (!unit.LeapUsed) { foreach (LuneUnit other in NearbyMobile(unit, SylvaraLeapRange, 1000)) { if (BackRow(other) && !other.IsBoss() && Guards(unit, other) <= SylvaraLeapGuards) { _pendingShields.Add(new PendingShield { Unit = unit, Amount = unit.MaxHp * SylvaraVeil, Duration = 4.0 }); unit.LeapUsed = true; unit.LeapTarget = other; unit.LeapStrike = true; unit.LeapSerial += 1; break; } } } if (unit.UltimateUsed || string.IsNullOrEmpty(unit.Def.UltimateRule) || unit.CombatTime + 0.000001 < LuneContent.UltimateDelay) return; bool release = false; switch (unit.Def.UltimateRule) { case "gruppe": release = NearbyMobile(unit, 4.0, 3).Count >= 3; break; case "zweite_reihe": release = NearbyMobile(unit, 5.0, 1000).Exists(BackRow); break; case "notfall": release = unit.Hp < unit.MaxHp * 0.4 && NearbyMobile(unit, 4.0, 1).Count > 0; break; case "sofort": release = NearbyMobile(unit, 4.0, 1).Count > 0; break; } if (!release) return; bool full = unit.HasTalent("vollmond"); double damage = unit.Def.Damage * (1.0 + unit.DamageBonus()) * (full ? SylvaraFullMoon : SylvaraDance); _pendingShields.Add(new PendingShield { Unit = unit, Amount = unit.MaxHp * SylvaraVeil, Duration = 4.0 }); foreach (LuneUnit victim in NearbyMobile(unit, 5.0, full ? 7 : 5)) { QueueDamage(victim, victim.Mitigated(damage, unit.Def.AttackType), gates); AddEffect(victim, "moon_mark", "movement_slow", 0.30, 2.0); } unit.UltimateUsed = true; unit.UltimateSerial += 1; } /// Sichelwurf (every 6 s): 90 % to the target and up to 2 more enemies within 2.5 m of it. bool SylvaraThrow(LuneUnit unit, Dictionary gates) { LuneUnit primary = unit.Target; if (primary == null || !Hostile(unit, primary) || !primary.IsMobile() || unit.Position.DistanceTo(primary.Position) > SylvaraThrowRange) return false; double damage = unit.Def.Damage * (1.0 + unit.DamageBonus()); if (unit.HasTalent("mondsilber")) { QueueDamage(primary, primary.Mitigated(damage * 2.0, unit.Def.AttackType), gates); return true; } bool storm = unit.HasTalent("sichelsturm"); var victims = new List { primary }; victims.AddRange(Nearby(unit, false, primary.Position, storm ? 3.5 : 2.5, storm ? 4 : 2, primary)); foreach (LuneUnit victim in victims) QueueDamage(victim, victim.Mitigated(damage * 0.9, unit.Def.AttackType), gates); return true; } void OnHit(LuneUnit unit, LuneUnit primary, List living, Dictionary gates) { string ability = unit.Ability(); int hits = unit.HitCount; if (unit.HasTalent("wurzelbeben") && hits % 3 == 0) Splash(unit, primary, living, gates, 0.50, 2.0, 3); if (unit.HasTalent("splitterschuss") && hits % 4 == 0) Splash(unit, primary, living, gates, 0.45, 2.0, 3); if (unit.HasTalent("keimsaat") && hits % 2 == 0) AddDot(primary, "seed_burn", 9.0, 4.0, unit.Def.AttackType); switch (ability) { case "w01": case "w02": if (hits % 3 == 0) { AddEffect(primary, "root_grasp", "movement_slow", ability == "w02" ? 0.30 : 0.20, ability == "w02" ? 3.0 : 2.0); if (ability == "w02") AddEffect(primary, "root_grasp_attack", "attack_slow", 0.10, 3.0); } break; case "w05": case "w06": if (hits % 4 == 0) AddDot(primary, "seed_burn", ability == "w06" ? 10.0 : 3.0, 4.0, unit.Def.AttackType); break; case "w07": case "w08": if (hits % 3 == 0) Splash(unit, primary, living, gates, ability == "w08" ? 0.80 : 0.60, ability == "w08" ? 2.5 : 2.0, ability == "w08" ? 4 : 3); break; case "g01": case "g02": if (hits % 3 == 0) AddDot(primary, "burn", ability == "g02" ? 12.0 : 4.0, 3.0, unit.Def.AttackType); break; case "g07": Splash(unit, primary, living, gates, 0.30, 1.5, 3); break; case "g08": Splash(unit, primary, living, gates, 0.40, 2.0, 4); break; case "e05": if (hits % 4 == 0) Splash(unit, primary, living, gates, 0.40, 1.5, 2); break; case "e07": if (hits % 4 == 0) Splash(unit, primary, living, gates, 0.60, 2.0, 3); break; case "e10": if (hits % 4 == 0) Splash(unit, primary, living, gates, 0.60, 2.5, 4); break; case "s04": if (hits % 4 == 0) Splash(unit, primary, living, gates, 0.50, 2.0, 3); break; } } void Splash(LuneUnit unit, LuneUnit primary, List living, Dictionary gates, double factor, double radius, int maximum) { foreach (LuneUnit victim in Nearby(unit, false, primary.Position, radius, maximum, primary)) QueueDamage(victim, victim.Mitigated(unit.Def.Damage * factor, unit.Def.AttackType), gates); } // ---------------------------------------------------------------- queries for views and tests public int MobileCount(int team = -1) { int count = 0; foreach (LuneUnit unit in Units) { if (unit.Alive && unit.IsMobile() && (team < 0 || unit.Team == team)) count++; } return count; } } }