champions-of-luneraria/Assets/Luneraria/Core/Simulation/LuneCombat.cs
Martin Stickelbroeck 4d6ed17dca U1: Regelkern in C#, verhaltensgleich zur Godot-Referenz
Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne
UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit
atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit
zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen,
Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit
Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und
Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle.

- Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein
  Zufall, keine Uhr, keine Threads
- Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py)
- Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien,
  120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert)
- Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402
- Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript
- Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert
- Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv
- Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5)

Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut
und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET
Standard 2.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:31:18 +02:00

973 lines
41 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";
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);
}
foreach (LuneUnit unit in living)
{
if (unit.IsMobile() || unit.Kind == "tower")
{
bool keep = unit.Target != null && unit.Target.Alive;
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);
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();
}
/// <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.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<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.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<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)
{
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.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 (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;
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;
}
}
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)
{
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;
}
}
if (success)
{
unit.AbilityReady = unit.CombatTime + (ability == "w03" || ability == "w04" ? 5.0 : (ability == "w09" || ability == "w10" ? 4.0 : 6.0));
unit.AbilitySerial += 1;
}
}
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;
}
}
}