using System; using Luneraria.Core; using UnityEngine; namespace Luneraria.View { /// /// Visual twin of one LuneUnit (port of scripts/luneraria/view/lune_unit_view.gd). Reads the /// authoritative state and never writes back: position between the last two fixed steps, /// facing, clips from real combat events (attack and ability serials), team ring, health bar, /// and for a deployed boss the wide owner ring of its team (G3: round for Blau, diamond for Rot). /// public sealed class LuneUnitView : MonoBehaviour { public LuneUnit Unit { get; private set; } LuneGround _ground; Transform _model; Animator _animator; bool _skeletal; float _height = 1.6f; Transform _ring; Transform _ownerRing; Transform _bars; Transform _fill; int _lastAttack; int _lastAbility; int _lastLeap; int _lastUltimate; /// H1: expanding moon ring of an ultimate (seconds left, 0 = none). Transform _burst; float _burstTime; /// S4: a small gold ring circling over the head of a stunned unit. Transform _stun; float _attackFlash; float _time; Vector3 _lastVisual; string _clip = ""; bool _attackB; bool _deathShown; Renderer[] _renderers; /// Base colour and emission of every material slot per renderer: a flash tints them instead of replacing them. Color[][] _baseColors; Color[][] _emissions; bool _flashing; /// Last applied flash state (strength and warmth in 16 steps each); unchanged steps skip the blocks. int _flashKey = -1; MaterialPropertyBlock _flashBlock; public void Bind(LuneUnit unit, LuneUnitLibrary library) { Unit = unit; _ground = library.Ground; name = "Unit_" + unit.Id + "_" + unit.Kind; GameObject prefab = library.PrefabFor(unit, out RuntimeAnimatorController controller); _model = Instantiate(prefab, transform, false).transform; _model.name = "Model"; _model.gameObject.SetActive(true); float scale = (float)Math.Max(.1, unit.Def.ViewScale); _model.localScale = Vector3.one * scale; library.Materials.Dress(_model.gameObject, unit.Team, unit.Def.Skin); _renderers = _model.GetComponentsInChildren(); _baseColors = new Color[_renderers.Length][]; _emissions = new Color[_renderers.Length][]; for (int r = 0; r < _renderers.Length; r++) { Material[] slots = _renderers[r].sharedMaterials; _baseColors[r] = new Color[slots.Length]; _emissions[r] = new Color[slots.Length]; for (int i = 0; i < slots.Length; i++) { _baseColors[r][i] = slots[i] != null && slots[i].HasProperty("_BaseColor") ? slots[i].GetColor("_BaseColor") : Color.white; _emissions[r][i] = slots[i] != null && slots[i].HasProperty("_EmissionColor") ? slots[i].GetColor("_EmissionColor") : Color.black; } } _flashBlock = new MaterialPropertyBlock(); _animator = _model.GetComponentInChildren(); _skeletal = controller != null && _animator != null; if (_skeletal) { _animator.runtimeAnimatorController = controller; _animator.applyRootMotion = false; _animator.gameObject.AddComponent(); } var bounds = new Bounds(transform.position, Vector3.zero); foreach (Renderer renderer in _model.GetComponentsInChildren()) bounds.Encapsulate(renderer.bounds); _height = Mathf.Max(1f, bounds.max.y - transform.position.y); int team = Mathf.Clamp(unit.Team, 0, 2); _ring = Marker("Ring", team == 0 ? library.RingRound : team == 1 ? library.RingDiamond : library.RingHexagon, unit.Kind == "hero" ? library.HeroRing : library.TeamRing[team]); _ring.localScale = new Vector3(scale, 1f, scale); _ring.localPosition = new Vector3(0f, .05f, 0f); if (unit.Kind == "boss" && unit.Team <= 1 && library.OwnerRingRound != null) { _ownerRing = Marker("OwnerRing", unit.Team == 0 ? library.OwnerRingRound : library.OwnerRingDiamond, library.OwnerRing[unit.Team]); _ownerRing.localScale = new Vector3(scale, 1f, scale); _ownerRing.localPosition = new Vector3(0f, .07f, 0f); } _bars = new GameObject("Bars").transform; _bars.SetParent(transform, false); _bars.localPosition = new Vector3(0f, _height + .25f, 0f); Transform back = Marker("Back", library.Quad, library.BarBack); back.SetParent(_bars, false); back.localScale = new Vector3(.9f, .1f, 1f); _fill = Marker("Fill", library.Quad, library.BarFill[team]); _fill.SetParent(_bars, false); _fill.localScale = new Vector3(.86f, .07f, 1f); _fill.localPosition = new Vector3(0f, 0f, -.001f); _lastAttack = unit.AttackSerial; _lastAbility = unit.AbilitySerial; _lastLeap = unit.LeapSerial; _lastUltimate = unit.UltimateSerial; if (unit.Kind == "hero" && library.MoonBurst != null) { _burst = Marker("UltimateBurst", library.RingRound, library.MoonBurst); _burst.localPosition = new Vector3(0f, .08f, 0f); _burst.gameObject.SetActive(false); } _stun = Marker("StunRing", library.RingRound, library.HeroRing); _stun.localScale = new Vector3(.55f, 1f, .55f); _stun.gameObject.SetActive(false); transform.position = Ground(unit.Position); transform.rotation = LuneSpace.Facing(unit.Facing); _lastVisual = transform.position; Play("idle"); } Transform Marker(string label, Mesh mesh, Material material) { var go = new GameObject(label); go.transform.SetParent(transform, false); go.AddComponent().sharedMesh = mesh; var renderer = go.AddComponent(); renderer.sharedMaterial = material; renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; renderer.receiveShadows = false; return go.transform; } Vector3 Ground(Vec2 at) { return LuneSpace.World(at, (_ground != null ? _ground.StandHeight(at) : 0f) + .03f); } /// Where tower bolts hit this figure (upper body). public Vector3 AimPoint { get { return transform.position + Vector3.up * _height * .55f; } } /// Anchor of the name tag above the head (Godot tag.position.y = height + .55). public Vector3 TagPoint { get { return transform.position + Vector3.up * (_height + .55f); } } public void Sync(float alpha, float dt, Transform camera) { _time += dt; Vec2 at = Unit.PreviousPosition.Lerp(Unit.Position, Mathf.Clamp01(alpha)); transform.position = Ground(at); transform.rotation = Quaternion.Slerp(transform.rotation, LuneSpace.Facing(Unit.Facing), Mathf.Min(1f, dt * 12f)); float moved = Vector2.Distance(new Vector2(transform.position.x, transform.position.z), new Vector2(_lastVisual.x, _lastVisual.z)); _lastVisual = transform.position; bool attacked = Unit.AttackSerial != _lastAttack; bool ability = Unit.AbilitySerial != _lastAbility; bool leap = Unit.LeapSerial != _lastLeap; bool ultimate = Unit.UltimateSerial != _lastUltimate; _lastAttack = Unit.AttackSerial; _lastAbility = Unit.AbilitySerial; _lastLeap = Unit.LeapSerial; _lastUltimate = Unit.UltimateSerial; if (ultimate && _burst != null) _burstTime = BurstSeconds; Burst(dt); bool stunned = Unit.Alive && Unit.ShowsStun(); if (_stun.gameObject.activeSelf != stunned) _stun.gameObject.SetActive(stunned); if (stunned) { _stun.localPosition = new Vector3(0f, _height + .15f, 0f); _stun.Rotate(0f, 360f * dt, 0f, Space.Self); } if (attacked || ability) _attackFlash = .28f; _attackFlash = Mathf.Max(0f, _attackFlash - dt); HitFlashGlow(); if (_skeletal) AnimateSkeletal(moved, attacked, ability, leap, ultimate); else AnimateRigid(moved); float fraction = Mathf.Clamp01((float)(Unit.Hp / Math.Max(1.0, Unit.MaxHp))); _fill.localScale = new Vector3(.86f * Mathf.Max(.001f, fraction), .07f, 1f); _fill.localPosition = new Vector3(-.43f * (1f - fraction), 0f, -.001f); _bars.gameObject.SetActive(Unit.Alive && fraction < .999f); if (camera != null) _bars.rotation = camera.rotation; _ring.gameObject.SetActive(Unit.Alive); if (_ownerRing != null) _ownerRing.gameObject.SetActive(Unit.Alive); // Rigid figures have no death clip; skeletal ones stay down in their death pose. if (!_skeletal) _model.gameObject.SetActive(Unit.Alive); } /// Share of the flash colour in a slot's base colour at full flash. public const float FlashTint = .3f; /// Emission the flash adds at full strength (on materials that emit). public const float FlashEmission = .15f; /// /// U5: white hit flash of the core's HitFlash (0.18 s on Take) plus a short warm attack /// glow. A MaterialPropertyBlock per material slot keeps the shared team materials /// untouched. U5.3: the flash tints each slot's own base colour and adds a little to its /// own emission. U5 set one near-white base colour and a strong emission for all slots, /// so in a melee, where nearly every figure attacks or is hit, the armies turned white, /// the boss glowed yellow and its crystals lost their team colour. /// void HitFlashGlow() { if (_renderers == null || _renderers.Length == 0) return; float hit = (float)Unit.HitFlash; float glow = Mathf.Clamp01(Mathf.Max(hit / .18f, _attackFlash / .28f)); if (glow <= .001f) { if (!_flashing) return; _flashing = false; _flashKey = -1; for (int r = 0; r < _renderers.Length; r++) { if (_renderers[r] == null) continue; for (int i = 0; i < _baseColors[r].Length; i++) _renderers[r].SetPropertyBlock(null, i); } return; } // Attack flash is warm (the figure lunges); a taken hit is white-hot. The flash // fades over 0.18–0.28 s; 16 steps of strength and warmth are enough for the eye and // save most per-slot block updates of a melee. int level = Mathf.CeilToInt(glow * 16f); int warmth = Mathf.RoundToInt(Mathf.Clamp01(hit / .18f) * 16f); int key = level * 17 + warmth; if (key == _flashKey) return; _flashKey = key; glow = level / 16f; float white = warmth / 16f; Color color = Color.Lerp(new Color(1f, .72f, .35f), Color.white, white); _flashing = true; for (int r = 0; r < _renderers.Length; r++) { if (_renderers[r] == null) continue; for (int i = 0; i < _baseColors[r].Length; i++) { _flashBlock.Clear(); _flashBlock.SetColor("_BaseColor", Color.Lerp(_baseColors[r][i], color, glow * FlashTint)); _flashBlock.SetColor("_EmissionColor", _emissions[r][i] + color * (glow * FlashEmission)); _renderers[r].SetPropertyBlock(_flashBlock, i); } } } /// H1: the moon ring of an ultimate grows to the 5 m reach of the dance in this time. public const float BurstSeconds = .45f; void Burst(float dt) { if (_burst == null) return; _burstTime = Mathf.Max(0f, _burstTime - dt); bool on = _burstTime > 0f && Unit.Alive; _burst.gameObject.SetActive(on); if (!on) return; // Reach 5 m (ring outer radius .52 at scale 1); the view scale of the model is not applied. float grown = 1f - _burstTime / BurstSeconds; float radius = Mathf.Lerp(.6f, 5f / .52f, Mathf.Sqrt(grown)); _burst.localScale = new Vector3(radius, 1f, radius); } void AnimateSkeletal(float moved, bool attacked, bool ability, bool leap, bool ultimate) { if (!Unit.Alive) { if (!_deathShown) Play("death"); _deathShown = true; return; } // Hero clips (H1): figures without them keep their normal clips. if (ultimate && HasClip("ultimate")) Play("ultimate", true); else if (leap && HasClip("leap")) Play("leap", true); else if (IsPlaying("ultimate") || IsPlaying("leap")) return; else if (ability) Play("ability", true); else if (attacked) { Play(_attackB ? "attack_b" : "attack_a", true); _attackB = !_attackB; } else if (IsPlaying("attack_a") || IsPlaying("attack_b") || IsPlaying("ability")) return; else Play(moved > .002f ? "walk" : "idle"); } bool HasClip(string state) { return _animator.HasState(0, Animator.StringToHash(state)); } bool IsPlaying(string state) { AnimatorStateInfo info = _animator.GetCurrentAnimatorStateInfo(0); return info.IsName(state) && info.normalizedTime < .95f; } void Play(string state, bool restart = false) { if (!_skeletal || (_clip == state && !restart)) return; // Figures have the clips they were built with (the worker has no combat clips). if (!_animator.HasState(0, Animator.StringToHash(state))) return; _clip = state; _animator.CrossFadeInFixedTime(state, .12f, 0, 0f); } /// Rigid figures: a stride bob while walking and a short lunge on attacks. void AnimateRigid(float moved) { float bob = moved > .002f ? Mathf.Abs(Mathf.Sin((_time + Unit.Id * .23f) * 9f)) * .08f : 0f; float lunge = Mathf.Sin(Mathf.Clamp01(_attackFlash / .28f) * Mathf.PI) * .25f; _model.localPosition = new Vector3(0f, bob, lunge); } } }