champions-of-luneraria/Assets/Luneraria/View/LuneMatchView.cs
Martin Stickelbroeck fb5c92dd37 S3 Neutrallager und Fog of War mit Spähern (LoL, Dota 2)
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>
2026-10-11 16:30:57 +02:00

330 lines
16 KiB
C#

using System.Collections.Generic;
using Luneraria.Core;
using UnityEngine;
namespace Luneraria.View
{
/// <summary>
/// Shows the driver's match (U2C): unit views per combat, building looks from the authoritative
/// HP with the Ancients' health bars, tower bolts from the towers' attack events, and the
/// Schlundkrone resting in its grotto while the grotto is open (U3, from lune_grotto_view.gd).
/// Runs after the driver in the same frame and only reads the core.
/// </summary>
[DefaultExecutionOrder(100)]
public sealed class LuneMatchView : MonoBehaviour
{
public LuneMatchDriver Driver;
public LuneUnitLibrary Library;
public Camera ViewCamera;
/// <summary>
/// Planning only: the human's draft army in its formation (set by the game controller, U3;
/// Godot _rebuild_preview). Bot matches leave it empty and show no units while planning.
/// </summary>
public LuneCombat Preview;
readonly Dictionary<int, LuneUnitView> _units = new Dictionary<int, LuneUnitView>();
readonly Dictionary<string, LuneBuildingView> _buildings = new Dictionary<string, LuneBuildingView>();
readonly Dictionary<string, int> _towerSerials = new Dictionary<string, int>();
readonly List<Bolt> _bolts = new List<Bolt>();
LuneCombat _combat;
Transform _unitRoot;
bool _synced;
GameObject _restingBoss;
readonly Dictionary<string, AncientBar> _ancientBars = new Dictionary<string, AncientBar>();
/// <summary>Health bar over an Ancient (lune_ancient_view.gd _build_bar: 3.4 m wide, 11.2 m up).</summary>
sealed class AncientBar
{
public Transform Root;
public Transform Fill;
}
public const float AncientBarWidth = 3.4f;
public const float AncientBarHeight = 11.2f;
/// <summary>Godot BOSS_LOCAL and view scale of the resting boss on its dais.</summary>
public const float RestingBossScale = 1.7f;
/// <summary>The resting Schlundkrone (tests); null until the grotto was first open.</summary>
public GameObject RestingBoss { get { return _restingBoss; } }
sealed class Bolt
{
public LineRenderer Line;
public float Age;
public LuneUnitView Target;
public Vector3 From;
public int Team;
}
/// <summary>U5 spark: fat at the muzzle, a bright core, a fine tip.</summary>
static readonly AnimationCurve BoltWidth = new AnimationCurve(
new Keyframe(0f, .35f), new Keyframe(.55f, 1f), new Keyframe(1f, 0f));
static Gradient BoltGradient(Color light, float alpha)
{
var gradient = new Gradient();
gradient.SetKeys(
new[] { new GradientColorKey(light, 0f), new GradientColorKey(Color.Lerp(light, Color.white, .55f), .55f), new GradientColorKey(light, 1f) },
new[] { new GradientAlphaKey(alpha, 0f), new GradientAlphaKey(alpha, 1f) });
return gradient;
}
public IEnumerable<LuneUnitView> Units { get { return _units.Values; } }
/// <summary>S3 fog of war: the seat whose team's vision applies (-1 = everything visible).</summary>
public int FogSlot = -1;
/// <summary>Ids of the units hidden in the fog this frame.</summary>
public HashSet<int> Hidden { get; private set; } = new HashSet<int>();
static readonly HashSet<int> Empty = new HashSet<int>();
/// <summary>Main-thread cost of the last frame's view update (milliseconds).</summary>
public double LastSyncMs { get; private set; }
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
public LuneBuildingView Building(string id)
{
_buildings.TryGetValue(id, out LuneBuildingView view);
return view;
}
void Start()
{
foreach (LuneBuildingView building in FindObjectsByType<LuneBuildingView>(FindObjectsSortMode.None))
_buildings[building.BuildingId] = building;
_unitRoot = new GameObject("Units").transform;
if (ViewCamera == null)
ViewCamera = Camera.main;
}
void LateUpdate()
{
if (Driver == null || Driver.Runner == null)
return;
_clock.Restart();
LuneMatch match = Driver.Runner.Match;
float dt = Time.deltaTime;
// Planning shows the preview when there is one (human seat); a bot match keeps the last
// combat's figures standing, unsynced, as in Godot.
bool planning = Driver.Runner.Current == LuneMatchRunner.Stage.Planning;
LuneCombat shown = planning && Preview != null ? Preview : match.Combat;
if (shown != _combat)
{
foreach (LuneUnitView view in _units.Values)
Destroy(view.gameObject);
_units.Clear();
_towerSerials.Clear();
_combat = shown;
}
bool showUnits = _combat != null && (!planning || _combat == Preview);
// S3 fog of war (extended rules): enemies the viewer's team cannot see are hidden.
Hidden = FogSlot >= 0 && FogSlot < match.Players.Count && showUnits
? LuneFog.Hidden(match, _combat, match.Players[FogSlot].Team, planning, planning ? match.Players[FogSlot].Draft : null)
: Empty;
float alpha = Driver.Runner.Current == LuneMatchRunner.Stage.Combat ? (float)Driver.Runner.Clock.Alpha : 1f;
if (showUnits)
{
foreach (LuneUnit unit in _combat.Units)
{
if (!unit.IsMobile())
continue;
if (!_units.TryGetValue(unit.Id, out LuneUnitView view))
{
view = new GameObject().AddComponent<LuneUnitView>();
view.transform.SetParent(_unitRoot, false);
view.Bind(unit, Library);
_units[unit.Id] = view;
}
bool visible = !Hidden.Contains(unit.Id);
if (view.gameObject.activeSelf != visible)
view.gameObject.SetActive(visible);
view.Sync(alpha, dt, ViewCamera != null ? ViewCamera.transform : null);
}
}
SyncBuildings(match);
SyncBolts(dt);
SyncRestingBoss(match);
_synced = true;
LastSyncMs = _clock.Elapsed.TotalMilliseconds;
}
void SyncBuildings(LuneMatch match)
{
bool fighting = _combat != null && match.Phase == Phase.Combat;
foreach (BuildingDef definition in match.Map.Buildings)
{
if (!_buildings.TryGetValue(definition.Id, out LuneBuildingView view))
continue;
double hp = match.BuildingHp[definition.Id];
LuneUnit unit = null;
if (_combat != null && _combat.Buildings.TryGetValue(definition.Id, out unit) && fighting)
hp = unit.Hp;
view.SetHealth((float)hp, (float)definition.MaxHp, _synced);
if (view.Tier == "core")
SyncAncientBar(view, (float)hp, (float)definition.MaxHp);
// S2: a levelled Ancient shoots like a tower.
if (unit != null && (unit.Kind == "tower" || (unit.Kind == "core" && unit.Def.Damage > 0.0)) && _combat != null)
TowerAttack(view, unit);
GlyphRing(definition, view, unit != null && fighting && _combat.Glyphed(unit));
}
}
/// <summary>S2 (Dota 2 glyph): a bright hexagon ring around an invulnerable tower.</summary>
readonly Dictionary<string, GameObject> _glyphRings = new Dictionary<string, GameObject>();
void GlyphRing(BuildingDef definition, LuneBuildingView view, bool on)
{
_glyphRings.TryGetValue(definition.Id, out GameObject ring);
if (!on)
{
if (ring != null)
ring.SetActive(false);
return;
}
if (ring == null)
{
if (Library == null || Library.RingHexagon == null || Library.MoonBurst == null)
return;
ring = new GameObject("GlyphRing_" + definition.Id);
ring.transform.SetParent(view.transform, false);
ring.transform.localPosition = new Vector3(0f, .12f, 0f);
ring.transform.localScale = new Vector3(4.2f, 1f, 4.2f);
ring.AddComponent<MeshFilter>().sharedMesh = Library.RingHexagon;
var renderer = ring.AddComponent<MeshRenderer>();
renderer.sharedMaterial = Library.MoonBurst;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_glyphRings[definition.Id] = ring;
}
ring.SetActive(true);
ring.transform.Rotate(0f, 40f * Time.deltaTime, 0f);
}
void TowerAttack(LuneBuildingView view, LuneUnit tower)
{
_towerSerials.TryGetValue(tower.BuildingId, out int last);
_towerSerials[tower.BuildingId] = tower.AttackSerial;
if (tower.AttackSerial == last || last == 0 && tower.AttackSerial == 0)
return;
if (tower.Target == null || !_units.TryGetValue(tower.Target.Id, out LuneUnitView target))
return;
var line = new GameObject("Bolt").AddComponent<LineRenderer>();
line.transform.SetParent(_unitRoot, false);
line.sharedMaterial = Library.Bolt[Mathf.Clamp(view.Team, 0, 2)];
// U5: a drawn-out spark rather than a bare tube (G3 tower shot).
line.widthMultiplier = view.Tier == "base" ? .28f : .18f;
line.widthCurve = BoltWidth;
int team = Mathf.Clamp(view.Team, 0, 2);
line.colorGradient = BoltGradient(Library.BoltLight[team], 1f);
line.numCapVertices = 4;
line.numCornerVertices = 2;
line.textureMode = LineTextureMode.Tile;
line.positionCount = 3;
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
line.receiveShadows = false;
_bolts.Add(new Bolt { Line = line, Target = target, From = view.Muzzle, Team = team });
}
/// <summary>The bar shows while the Ancient is damaged and standing, always facing the camera.</summary>
void SyncAncientBar(LuneBuildingView view, float hp, float maxHp)
{
float fraction = Mathf.Clamp01(hp / Mathf.Max(1f, maxHp));
bool visible = hp > 0f && fraction < .999f;
_ancientBars.TryGetValue(view.BuildingId, out AncientBar bar);
if (bar == null)
{
if (!visible || Library == null || Library.Quad == null)
return;
bar = new AncientBar { Root = new GameObject("AncientBar").transform };
bar.Root.SetParent(view.transform, false);
bar.Root.localPosition = new Vector3(0f, AncientBarHeight, 0f);
Transform back = Quad(bar.Root, "Back", Library.BarBack);
back.localScale = new Vector3(AncientBarWidth + .1f, .26f, 1f);
bar.Fill = Quad(bar.Root, "Fill", Library.AncientBarFill[Mathf.Clamp(view.Team, 0, 1)]);
_ancientBars[view.BuildingId] = bar;
}
if (bar.Root.gameObject.activeSelf != visible)
bar.Root.gameObject.SetActive(visible);
if (!visible)
return;
bar.Fill.localScale = new Vector3(AncientBarWidth * Mathf.Max(.001f, fraction), .17f, 1f);
bar.Fill.localPosition = new Vector3(-AncientBarWidth * .5f * (1f - fraction), 0f, -.001f);
if (ViewCamera != null)
bar.Root.rotation = ViewCamera.transform.rotation;
}
Transform Quad(Transform parent, string label, Material material)
{
var go = new GameObject(label);
go.transform.SetParent(parent, false);
go.AddComponent<MeshFilter>().sharedMesh = Library.Quad;
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = material;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
renderer.receiveShadows = false;
return go.transform;
}
/// <summary>
/// The Schlundkrone rests on its dais while the grotto is open and no boss unit stands there
/// (lune_grotto_view.gd: state AVAILABLE without a fighter): the figure the fight uses,
/// neutral, facing the arena and the two stairs, in its idle clip. The grotto's light
/// states (runes, crystals, motes) are not ported yet.
/// </summary>
void SyncRestingBoss(LuneMatch match)
{
LuneUnit fighter = match.Phase == Phase.Combat && match.Combat != null ? match.Combat.GrottoBoss : null;
bool visible = match.Boss.Claim == null && match.GrottoAvailable() && fighter == null;
if (visible && _restingBoss == null)
_restingBoss = MakeRestingBoss(match.Map);
if (_restingBoss != null && _restingBoss.activeSelf != visible)
_restingBoss.SetActive(visible);
}
GameObject MakeRestingBoss(LuneMap map)
{
LuneUnitLibrary.UnitModel model = Library != null ? Library.Find(Library.BossModel) : null;
if (model == null || model.Prefab == null)
return null;
var root = new GameObject("RestingBoss");
root.transform.SetParent(transform, false);
Vec2 at = map.GrottoBossPoint();
root.transform.position = LuneSpace.World(at, (Library.Ground != null ? Library.Ground.StandHeight(at) : 0f) + .03f);
root.transform.rotation = LuneSpace.Facing(System.Math.Atan2(LuneMap.Axis.X, LuneMap.Axis.Y));
GameObject figure = Instantiate(model.Prefab, root.transform, false);
figure.name = "Model";
figure.SetActive(true);
figure.transform.localScale = Vector3.one * RestingBossScale;
Library.Materials.Dress(figure, 2);
var animator = figure.GetComponentInChildren<Animator>();
if (animator != null && model.Controller != null)
{
animator.runtimeAnimatorController = model.Controller;
animator.applyRootMotion = false;
if (animator.HasState(0, Animator.StringToHash("idle")))
animator.Play("idle", 0, 0f);
}
return root;
}
void SyncBolts(float dt)
{
for (int i = _bolts.Count - 1; i >= 0; i--)
{
Bolt bolt = _bolts[i];
bolt.Age += dt;
if (bolt.Age > .25f || bolt.Target == null)
{
Destroy(bolt.Line.gameObject);
_bolts.RemoveAt(i);
continue;
}
Vector3 to = bolt.Target.AimPoint;
// U5: three points bow the spark slightly toward the camera; fade over its life.
Vector3 mid = Vector3.Lerp(bolt.From, to, .55f);
if (ViewCamera != null)
mid += ViewCamera.transform.up * .12f;
bolt.Line.SetPosition(0, bolt.From);
bolt.Line.SetPosition(1, mid);
bolt.Line.SetPosition(2, to);
float fade = 1f - Mathf.Clamp01((bolt.Age - .12f) / .13f);
bolt.Line.colorGradient = BoltGradient(Library.BoltLight[bolt.Team], fade);
}
}
}
}