Erweiterte Regeln: in jeder dritten Runde ein neutraler Wächter in jeder Lanemitte; wer beim Fall mehr Einheiten (dann mehr Leben) in 8 m hat, nimmt das Lager: Glutsegen (+10 % Schaden), Mondschild (Schild 10 %), Beute (50 Gold je Spieler). Fog of War (LuneFog, nur Darstellung): Gegner sichtbar in 9 m um eigene Einheiten, 10 m um Gebäude, 12 m um Späher; Späher (40 Gold, 3 Runden, Teamwirkung) zeigen in der Planung die gegnerische Armee ihrer Lane. HUD: Teampanel SPÄHER und LAGER, Lager-Sechseck, Nebel in Feld, Minimap, Namensschildern und Lane-Zählung. Kern 597/597 (s3 31), EditMode 33/33, PlayMode 18/18, Verify 718/0, Selbsttest 30/30, Golden 0, Fingerabdruck unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
10 KiB
C#
265 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Luneraria.Core
|
|
{
|
|
/// <summary>Timed effect of one family (e.g. "root_grasp"): kind, strength, time left.</summary>
|
|
public sealed class Effect
|
|
{
|
|
/// <summary>movement_slow, attack_slow, attack_speed, damage, reduction, healing_reduction.</summary>
|
|
public string Kind = "";
|
|
public double Amount;
|
|
public double Remaining;
|
|
}
|
|
|
|
/// <summary>Damage over time of one family (e.g. "burn"): ticks once per second.</summary>
|
|
public sealed class DamageOverTime
|
|
{
|
|
public double Damage;
|
|
public double Remaining;
|
|
public double NextTick = 1.0;
|
|
public int AttackType;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One combat instance (reference: lune_unit.gd). Persistent ownership stays in LuneMatch;
|
|
/// this object lives for one combat phase. Damage, heals and effects are queued and applied
|
|
/// together by LuneCombat, so processing order never decides who strikes first.
|
|
/// </summary>
|
|
public sealed class LuneUnit
|
|
{
|
|
/// <summary>Attack type (row) against armour type (column): Klinge, Durchschlag, Impuls.</summary>
|
|
public static readonly double[,] DamageMatrix = { { 1.30, 0.75, 1.00 }, { 1.00, 1.30, 0.75 }, { 0.75, 1.00, 1.30 } };
|
|
public static readonly string[] MobileKinds = { "troop", "hero", "monster", "champion", "boss", "camp" };
|
|
|
|
/// <summary>Sequential per combat, assigned in spawn order (deterministic).</summary>
|
|
public int Id;
|
|
/// <summary>Spawn index within the own team; mirrored units share it (fair staggering).</summary>
|
|
public int TeamIndex;
|
|
/// <summary>troop, hero, monster, champion, boss, tower or core.</summary>
|
|
public string Kind = "troop";
|
|
public int Team;
|
|
public int OwnerSlot = -1;
|
|
public string PersistentUid = "";
|
|
public string Lane = "mid";
|
|
/// <summary>lane or grotto.</summary>
|
|
public string Zone = "lane";
|
|
public UnitDef Def = new UnitDef();
|
|
public string BuildingId = "";
|
|
public Vec2 Position;
|
|
public Vec2 PreviousPosition;
|
|
public double Facing;
|
|
public double Hp = 1.0;
|
|
public double MaxHp = 1.0;
|
|
public bool Alive = true;
|
|
public LuneUnit Target;
|
|
public double Progress;
|
|
public double Lateral;
|
|
public double AttackProgress;
|
|
public int HitCount;
|
|
public double CombatTime;
|
|
public double Shield;
|
|
public double ShieldRemaining = double.PositiveInfinity;
|
|
public readonly OrderedMap<string, Effect> Effects = new OrderedMap<string, Effect>();
|
|
public readonly OrderedMap<string, DamageOverTime> Dots = new OrderedMap<string, DamageOverTime>();
|
|
public double AbilityReady = 6.0;
|
|
public bool ReachedBase;
|
|
/// <summary>The grotto boss waits while two expeditions still fight each other.</summary>
|
|
public bool Dormant;
|
|
public double AuraReady = 4.0;
|
|
// H1 hero kit: Sylvara's leap (once per combat) and the ultimate charge (once per combat).
|
|
public bool LeapUsed;
|
|
/// <summary>Set for the one step of a leap: the unit lands next to this target.</summary>
|
|
public LuneUnit LeapTarget;
|
|
/// <summary>The first hit after a leap deals extra damage.</summary>
|
|
public bool LeapStrike;
|
|
public bool UltimateUsed;
|
|
// Presentation-only counters; never read by the rules.
|
|
public int AttackSerial;
|
|
public int AbilitySerial;
|
|
public int LeapSerial;
|
|
public int UltimateSerial;
|
|
public double HitFlash;
|
|
public bool InRange;
|
|
public double DeathTime = -1.0;
|
|
|
|
public bool IsBuilding() { return Kind == "tower" || Kind == "core"; }
|
|
|
|
/// <summary>Buildings and S3 camp guardians stay where they are and only fight in reach.</summary>
|
|
public bool IsStationary() { return IsBuilding() || Kind == "camp"; }
|
|
|
|
public bool IsMobile() { return Array.IndexOf(MobileKinds, Kind) >= 0; }
|
|
|
|
public double AttackRange() { return Def.AttackRange; }
|
|
|
|
public double Speed() { return Def.Speed; }
|
|
|
|
public int ArmorType() { return GdMath.Clamp(Def.ArmorType, 0, 2); }
|
|
|
|
public string Ability() { return Def.Ability; }
|
|
|
|
public bool IsBoss() { return Kind == "boss" || Def.IsBoss; }
|
|
|
|
public double Mitigated(double raw, int attackType)
|
|
{
|
|
double value = GdMath.Max(0.0, raw) * DamageMatrix[GdMath.Clamp(attackType, 0, 2), ArmorType()];
|
|
return value * (1.0 - DamageReduction());
|
|
}
|
|
|
|
/// <summary>Applies an already mitigated amount through the shield.</summary>
|
|
public void Take(double amount)
|
|
{
|
|
if (!Alive || amount <= 0.0)
|
|
return;
|
|
double absorbed = GdMath.Min(Shield, amount);
|
|
Shield -= absorbed;
|
|
Hp = GdMath.Max(0.0, Hp - (amount - absorbed));
|
|
HitFlash = 0.18;
|
|
}
|
|
|
|
public void Heal(double amount)
|
|
{
|
|
if (Alive && !IsBuilding())
|
|
Hp = GdMath.Min(MaxHp, Hp + GdMath.Max(0.0, amount) * (1.0 - StrongestEffect("healing_reduction")));
|
|
}
|
|
|
|
public void GrantShield(double amount, double duration = double.PositiveInfinity)
|
|
{
|
|
if (Alive && amount > Shield)
|
|
{
|
|
Shield = amount;
|
|
ShieldRemaining = duration;
|
|
}
|
|
}
|
|
|
|
/// <summary>Buildings ignore crowd control and buffs; an existing family keeps the stronger values.</summary>
|
|
public void ApplyEffect(string family, string kind, double amount, double duration)
|
|
{
|
|
if (!Alive || IsBuilding())
|
|
return;
|
|
Effect effect;
|
|
if (Effects.TryGetValue(family, out effect))
|
|
{
|
|
effect.Amount = GdMath.Max(effect.Amount, amount);
|
|
effect.Remaining = GdMath.Max(effect.Remaining, duration);
|
|
}
|
|
else
|
|
{
|
|
Effects[family] = new Effect { Kind = kind, Amount = amount, Remaining = duration };
|
|
}
|
|
}
|
|
|
|
public void ApplyDot(string family, double damage, double duration, int attackType)
|
|
{
|
|
if (!Alive || IsBuilding())
|
|
return;
|
|
DamageOverTime dot;
|
|
if (Dots.TryGetValue(family, out dot))
|
|
{
|
|
if (damage > dot.Damage)
|
|
{
|
|
dot.Damage = damage;
|
|
dot.AttackType = attackType;
|
|
}
|
|
dot.Remaining = GdMath.Max(dot.Remaining, duration);
|
|
}
|
|
else
|
|
{
|
|
Dots[family] = new DamageOverTime { Damage = damage, Remaining = duration, NextTick = 1.0, AttackType = attackType };
|
|
}
|
|
}
|
|
|
|
public double StrongestEffect(string kind)
|
|
{
|
|
double amount = 0.0;
|
|
IReadOnlyList<Effect> values = Effects.Values;
|
|
for (int i = 0; i < values.Count; i++)
|
|
{
|
|
if (values[i].Kind == kind)
|
|
amount = GdMath.Max(amount, values[i].Amount);
|
|
}
|
|
return amount;
|
|
}
|
|
|
|
public double DamageBonus()
|
|
{
|
|
double result = 0.0;
|
|
IReadOnlyList<Effect> values = Effects.Values;
|
|
for (int i = 0; i < values.Count; i++)
|
|
{
|
|
if (values[i].Kind == "damage")
|
|
result += values[i].Amount;
|
|
}
|
|
return GdMath.Min(0.30, result) + Def.DamageBonus;
|
|
}
|
|
|
|
public double DamageReduction()
|
|
{
|
|
double result = StrongestEffect("reduction");
|
|
switch (Ability())
|
|
{
|
|
case "g03":
|
|
if (CombatTime < 8.0 - 0.000001)
|
|
result = GdMath.Max(result, 0.15);
|
|
break;
|
|
case "g04":
|
|
result = GdMath.Max(result, CombatTime < 10.0 - 0.000001 ? 0.25 : 0.10);
|
|
break;
|
|
}
|
|
return GdMath.Min(0.35, result + Def.ReductionBonus);
|
|
}
|
|
|
|
public double AttackSpeedFactor()
|
|
{
|
|
double slowing = StrongestEffect("attack_slow") * (IsBoss() ? 0.5 : 1.0);
|
|
double bonus = Def.AttackSpeedBonus;
|
|
return 1.0 + GdMath.Clamp(StrongestEffect("attack_speed") - slowing + bonus, -0.25, 0.5);
|
|
}
|
|
|
|
public bool HasTalent(string id) { return Def.Talents.Contains(id); }
|
|
|
|
public string Role() { return Def.Role; }
|
|
|
|
public double MovementFactor()
|
|
{
|
|
return 1.0 - GdMath.Min(0.35, StrongestEffect("movement_slow") * (IsBoss() ? 0.5 : 1.0));
|
|
}
|
|
|
|
/// <summary>Advances effect and shield timers; returns the DoT damage due this step.</summary>
|
|
public double AdvanceStatus(double delta)
|
|
{
|
|
CombatTime += delta;
|
|
HitFlash = GdMath.Max(0.0, HitFlash - delta);
|
|
var families = new List<string>(Effects.Keys);
|
|
foreach (string family in families)
|
|
{
|
|
Effect effect = Effects[family];
|
|
effect.Remaining -= delta;
|
|
if (effect.Remaining <= 0.000001)
|
|
Effects.Remove(family);
|
|
}
|
|
if (!double.IsPositiveInfinity(ShieldRemaining))
|
|
{
|
|
ShieldRemaining -= delta;
|
|
if (ShieldRemaining <= 0.000001)
|
|
Shield = 0.0;
|
|
}
|
|
double dotDamage = 0.0;
|
|
var dotFamilies = new List<string>(Dots.Keys);
|
|
foreach (string family in dotFamilies)
|
|
{
|
|
DamageOverTime dot = Dots[family];
|
|
dot.NextTick -= GdMath.Min(delta, GdMath.Max(0.0, dot.Remaining));
|
|
while (dot.NextTick <= 0.000001)
|
|
{
|
|
dotDamage += Mitigated(dot.Damage, dot.AttackType);
|
|
dot.NextTick += 1.0;
|
|
}
|
|
dot.Remaining -= delta;
|
|
if (dot.Remaining <= 0.000001)
|
|
Dots.Remove(family);
|
|
}
|
|
return dotDamage;
|
|
}
|
|
}
|
|
}
|