champions-of-luneraria/Assets/Luneraria/Core/Simulation/LuneCombat.cs
Martin Stickelbroeck 82066d17a6 S2 Glyphe, Item-Rezepte und Ancient-Stufen (Dota 2, LoL, Legion TD 2)
Hinter MatchOptions.Extended: Glyphe als Teamwahl einer Lane (Türme 5 s
unverwundbar, sobald einer unter 50 % fällt; 4 Runden Abklingzeit nur nach
Auslösen), vier Rezepte (Bollwerk, Glutsturm, Quellherz, Splitterkrone),
Ancient-Stufen 1-3 (150 x Stufe, Reparatur 1.000, Schuss 45/75/110),
Mid-Bots jagen bei sicherer Lane die Grotte. Teampanel mit Glyphe und
Ancient, Rezepte im Shop (TEILE FEHLEN), Glyphen-Ring und Ancient-Schüsse.
Kern 566/566 (s2 35), EditMode 32/32, PlayMode 17/17, Verify 718/0,
Selbsttest 30/30, Golden 0, Fingerabdruck unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:54:46 +02:00

1154 lines
50 KiB
C#

using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>A death recorded during combat; the settlement pays bounties and XP from it.</summary>
public sealed class CombatEvent
{
public int Serial;
public int Step;
public double Time;
public int Team;
public string Lane = "";
public string Zone = "lane";
/// <summary>Kind of the unit that died.</summary>
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<string> XpHeroes = new List<string>();
}
/// <summary>
/// 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.
/// </summary>
public sealed class LuneCombat
{
public const int Neutral = 2;
/// <summary>Units without a target look for one every RETARGET steps, staggered by TeamIndex.</summary>
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<LuneUnit> Units = new List<LuneUnit>();
public readonly OrderedMap<string, LuneUnit> Buildings = new OrderedMap<string, LuneUnit>();
public double Elapsed;
public int Steps;
public bool Finished;
/// <summary>cleared, timeout or core.</summary>
public string EndReason = "";
public readonly List<int> DestroyedCores = new List<int>();
public readonly List<CombatEvent> Events = new List<CombatEvent>();
public LuneUnit GrottoBoss;
public bool GrottoResolved = true;
public int GrottoClaimTeam = -1;
public int GrottoActiveTeam = -1;
/// <summary>none, success or failed.</summary>
public string GrottoOutcome = "none";
/// <summary>S2 glyph per team: the held lane ("" none), when its towers stop being invulnerable, whether it fired.</summary>
public readonly string[] GlyphLane = { "", "" };
public readonly double[] GlyphUntil = { -1.0, -1.0 };
public readonly bool[] GlyphUsed = new bool[2];
int _nextId = 1;
readonly int[] _teamCount = new int[3];
int _eventSerial;
readonly Dictionary<int, double> _pendingDamage = new Dictionary<int, double>();
readonly Dictionary<int, double> _pendingHeal = new Dictionary<int, double>();
readonly List<PendingEffect> _pendingEffects = new List<PendingEffect>();
readonly List<PendingDot> _pendingDots = new List<PendingDot>();
readonly List<PendingShield> _pendingShields = new List<PendingShield>();
// Per-step caches: building gates, march limits, spatial buckets of mobile units.
readonly Dictionary<string, bool> _attackable = new Dictionary<string, bool>();
readonly Dictionary<int, double> _limits = new Dictionary<int, double>();
readonly Dictionary<Vec2I, List<LuneUnit>>[] _grid = { new Dictionary<Vec2I, List<LuneUnit>>(), new Dictionary<Vec2I, List<LuneUnit>>(), new Dictionary<Vec2I, List<LuneUnit>>() };
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<string, bool> AliveMap()
{
var result = new Dictionary<string, bool>();
IReadOnlyList<LuneUnit> 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<string, bool> 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<LuneUnit> living, Dictionary<string, bool> gates)
{
_attackable.Clear();
IReadOnlyList<LuneUnit> 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<LuneUnit> bucket;
if (!_grid[unit.Team].TryGetValue(key, out bucket))
{
bucket = new List<LuneUnit>();
_grid[unit.Team][key] = bucket;
}
bucket.Add(unit);
}
}
public static int[] HostileTeams(int team)
{
return team == Neutral ? new[] { 0, 1 } : new[] { 1 - team, Neutral };
}
/// <summary>Mobile units of the given teams in the cells around a point; the caller checks distance.</summary>
List<LuneUnit> NearMobile(Vec2 center, double radius, int[] teams)
{
var result = new List<LuneUnit>();
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<Vec2I, List<LuneUnit>> cells = _grid[side];
if (cells.Count == 0)
continue;
for (int dx = -reach; dx <= reach; dx++)
{
for (int dy = -reach; dy <= reach; dy++)
{
List<LuneUnit> 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<string, bool> gates = AliveMap();
var living = new List<LuneUnit>();
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" || (unit.Kind == "core" && unit.Def.Damage > 0.0))
{
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<int, KeyValuePair<Vec2, double>>();
foreach (LuneUnit unit in living)
{
if (unit.IsMobile())
moves[unit.Id] = PlanMove(unit, living, dt);
}
foreach (LuneUnit unit in living)
{
KeyValuePair<Vec2, double> 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);
}
}
TriggerGlyphs();
UpdateGrotto();
CheckEnd();
}
/// <summary>S2: a tower of the glyph lane is invulnerable while its team's glyph runs.</summary>
public bool Glyphed(LuneUnit unit)
{
if (unit.Kind != "tower" || unit.Team > 1)
return false;
return GlyphLane[unit.Team].Length > 0 && unit.Lane == GlyphLane[unit.Team] && Elapsed < GlyphUntil[unit.Team] - 0.000001;
}
/// <summary>S2 (Dota 2 glyph): the first time a tower of the held lane is below the threshold, the glyph fires.</summary>
void TriggerGlyphs()
{
for (int team = 0; team < 2; team++)
{
if (GlyphLane[team].Length == 0 || GlyphUsed[team])
continue;
IReadOnlyList<LuneUnit> buildings = Buildings.Values;
for (int i = 0; i < buildings.Count; i++)
{
LuneUnit tower = buildings[i];
if (tower.Alive && tower.Kind == "tower" && tower.Team == team && tower.Lane == GlyphLane[team] && tower.Hp < tower.MaxHp * Balance.GlyphThreshold)
{
GlyphUsed[team] = true;
GlyphUntil[team] = Elapsed + Balance.GlyphSeconds;
break;
}
}
}
}
/// <summary>Expeditions fight each other first; the boss wakes when at most one team remains.</summary>
void UpdateGrotto()
{
if (GrottoResolved || GrottoBoss == null)
return;
var teamsAlive = new List<int>();
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<LuneUnit> living, Dictionary<string, bool> 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.IsBuilding() || unit.Position.DistanceTo(current.Position) <= unit.AttackRange())
return current;
}
}
double radius = unit.IsBuilding() ? unit.AttackRange() : Balance.AggroRadius;
LuneUnit best = null;
double bestDistance = double.PositiveInfinity;
string priority = unit.Def.Priority;
IReadOnlyList<LuneUnit> 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.IsBuilding())
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<LuneUnit> 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;
}
/// <summary>
/// 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.
/// </summary>
KeyValuePair<Vec2, double> PlanMove(LuneUnit unit, List<LuneUnit> living, double dt)
{
if (unit.LeapTarget != null)
return new KeyValuePair<Vec2, double>(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<Vec2, double>(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<LuneUnit> living, Dictionary<string, bool> gates, double dt)
{
unit.InRange = false;
LuneUnit target = unit.Target;
if (target == null || !target.Alive || (unit.Kind == "core" && unit.Def.Damage <= 0.0))
{
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.IsBuilding() ? 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<string, bool> 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;
if (Glyphed(target))
return;
double current;
_pendingDamage.TryGetValue(target.Id, out current);
_pendingDamage[target.Id] = current + amount;
}
/// <summary>Damage queued for a unit in the current step (tests).</summary>
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<LuneUnit> Nearby(LuneUnit unit, bool allied, Vec2 center, double radius, int maximum, LuneUnit excluded = null, bool healthPriority = false)
{
var result = new List<LuneUnit>();
// Buildings can be splash victims; they are few, so check them directly.
List<LuneUnit> pool = NearMobile(center, radius, allied ? new[] { unit.Team } : HostileTeams(unit.Team));
if (!allied)
{
IReadOnlyList<LuneUnit> 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<LuneUnit> living, Dictionary<string, bool> 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<LuneUnit> 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<LuneUnit> 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<LuneUnit> 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<LuneUnit> 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;
/// <summary>The leap only targets back-row enemies with at most this many allies within 3 m.</summary>
public const int SylvaraLeapGuards = 2;
/// <summary>Mondschleier: shield after the leap and after the ultimate, share of max HP, for 4 s.</summary>
public const double SylvaraVeil = 0.25;
public const double SylvaraDance = 2.2;
public const double SylvaraFullMoon = 2.8;
/// <summary>Back-row enemies: the leap and the "zweite_reihe" rule look for these.</summary>
public const double BackRowRange = 3.0;
static bool BackRow(LuneUnit other) { return other.IsMobile() && other.AttackRange() >= BackRowRange - 0.000001; }
List<LuneUnit> NearbyMobile(LuneUnit unit, double radius, int maximum)
{
List<LuneUnit> 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;
}
/// <summary>Mobile allies of the target within 3 m of it (its guards against a leap).</summary>
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;
}
/// <summary>Lands 0.9 m in front of the target, on the side the unit comes from.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
void SylvaraStep(LuneUnit unit, Dictionary<string, bool> gates)
{
bool leaping = false;
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;
leaping = true;
break;
}
}
}
// The dance waits for the landing: in the leap step she still stands at her old place.
if (leaping || 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;
}
/// <summary>Sichelwurf (every 6 s): 90 % to the target and up to 2 more enemies within 2.5 m of it.</summary>
bool SylvaraThrow(LuneUnit unit, Dictionary<string, bool> 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<LuneUnit> { 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<LuneUnit> living, Dictionary<string, bool> 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<LuneUnit> living, Dictionary<string, bool> 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;
}
}
}