using System.Collections.Generic; using Luneraria.Core; using UnityEngine; namespace Luneraria.View { /// /// 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. /// [DefaultExecutionOrder(100)] public sealed class LuneMatchView : MonoBehaviour { public LuneMatchDriver Driver; public LuneUnitLibrary Library; public Camera ViewCamera; /// /// 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. /// public LuneCombat Preview; readonly Dictionary _units = new Dictionary(); readonly Dictionary _buildings = new Dictionary(); readonly Dictionary _towerSerials = new Dictionary(); readonly List _bolts = new List(); LuneCombat _combat; Transform _unitRoot; bool _synced; GameObject _restingBoss; readonly Dictionary _ancientBars = new Dictionary(); /// Health bar over an Ancient (lune_ancient_view.gd _build_bar: 3.4 m wide, 11.2 m up). sealed class AncientBar { public Transform Root; public Transform Fill; } public const float AncientBarWidth = 3.4f; public const float AncientBarHeight = 11.2f; /// Godot BOSS_LOCAL and view scale of the resting boss on its dais. public const float RestingBossScale = 1.7f; /// The resting Schlundkrone (tests); null until the grotto was first open. public GameObject RestingBoss { get { return _restingBoss; } } sealed class Bolt { public LineRenderer Line; public float Age; public LuneUnitView Target; public Vector3 From; public int Team; } /// U5 spark: fat at the muzzle, a bright core, a fine tip. 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 Units { get { return _units.Values; } } /// S3 fog of war: the seat whose team's vision applies (-1 = everything visible). public int FogSlot = -1; /// Ids of the units hidden in the fog this frame. public HashSet Hidden { get; private set; } = new HashSet(); static readonly HashSet Empty = new HashSet(); /// S4: the darkening grid of the fog (null until a fogged match runs). public LuneFogOverlay Fog { get; private set; } void FogTick(LuneMatch match, float dt) { bool on = FogSlot >= 0 && FogSlot < match.Players.Count && match.Extended && Library != null && Library.FogOverlay != null; if (!on) { if (Fog != null && Fog.gameObject.activeSelf) Fog.gameObject.SetActive(false); return; } if (Fog == null) { var go = new GameObject("FogOverlay"); go.transform.SetParent(transform, false); Fog = go.AddComponent(); Fog.Build(Library.Ground, Library.FogOverlay); } if (!Fog.gameObject.activeSelf) Fog.gameObject.SetActive(true); Fog.Tick(dt, LuneFog.Vision(match, _combat, match.Players[FogSlot].Team)); } /// Main-thread cost of the last frame's view update (milliseconds). 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(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; FogTick(match, dt); 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(); 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)); } } /// S2 (Dota 2 glyph): a bright hexagon ring around an invulnerable tower. readonly Dictionary _glyphRings = new Dictionary(); 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().sharedMesh = Library.RingHexagon; var renderer = ring.AddComponent(); 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(); 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 }); } /// The bar shows while the Ancient is damaged and standing, always facing the camera. 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().sharedMesh = Library.Quad; var renderer = go.AddComponent(); renderer.sharedMaterial = material; renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; renderer.receiveShadows = false; return go.transform; } /// /// 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. /// 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(); 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); } } } }