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>
253 lines
9.4 KiB
C#
253 lines
9.4 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" };
|
|
|
|
/// <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;
|
|
// Presentation-only counters; never read by the rules.
|
|
public int AttackSerial;
|
|
public int AbilitySerial;
|
|
public double HitFlash;
|
|
public bool InRange;
|
|
public double DeathTime = -1.0;
|
|
|
|
public bool IsBuilding() { return Kind == "tower" || Kind == "core"; }
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|