Betäubungs-Ring über dem Kopf (Gäste über Bit 18 der Rahmen-Flags), Nebel-Gitter LuneFogOverlay mit Shader Luneraria/FogOverlay aus LuneFog.Vision (10 Hz, weiche Kante), laufende Glyphe je Team als vier Zusatzwerte nach den Gebäude-Lebenspunkten. Reine Darstellung. Kern 627/627 (s4 8), EditMode 35/35, PlayMode 20/20, Verify 718/0, Selbsttest 30/30, Golden 0, Fingerabdruck unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
16 KiB
C#
337 lines
16 KiB
C#
using System;
|
||
using Luneraria.Core;
|
||
using UnityEngine;
|
||
|
||
namespace Luneraria.View
|
||
{
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
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;
|
||
/// <summary>H1: expanding moon ring of an ultimate (seconds left, 0 = none).</summary>
|
||
Transform _burst;
|
||
float _burstTime;
|
||
/// <summary>S4: a small gold ring circling over the head of a stunned unit.</summary>
|
||
Transform _stun;
|
||
float _attackFlash;
|
||
float _time;
|
||
Vector3 _lastVisual;
|
||
string _clip = "";
|
||
bool _attackB;
|
||
bool _deathShown;
|
||
Renderer[] _renderers;
|
||
/// <summary>Base colour and emission of every material slot per renderer: a flash tints them instead of replacing them.</summary>
|
||
Color[][] _baseColors;
|
||
Color[][] _emissions;
|
||
bool _flashing;
|
||
/// <summary>Last applied flash state (strength and warmth in 16 steps each); unchanged steps skip the blocks.</summary>
|
||
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<Renderer>();
|
||
_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<Animator>();
|
||
_skeletal = controller != null && _animator != null;
|
||
if (_skeletal)
|
||
{
|
||
_animator.runtimeAnimatorController = controller;
|
||
_animator.applyRootMotion = false;
|
||
_animator.gameObject.AddComponent<LuneContactReceiver>();
|
||
}
|
||
var bounds = new Bounds(transform.position, Vector3.zero);
|
||
foreach (Renderer renderer in _model.GetComponentsInChildren<Renderer>())
|
||
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<MeshFilter>().sharedMesh = mesh;
|
||
var renderer = go.AddComponent<MeshRenderer>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>Where tower bolts hit this figure (upper body).</summary>
|
||
public Vector3 AimPoint { get { return transform.position + Vector3.up * _height * .55f; } }
|
||
|
||
/// <summary>Anchor of the name tag above the head (Godot tag.position.y = height + .55).</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Share of the flash colour in a slot's base colour at full flash.</summary>
|
||
public const float FlashTint = .3f;
|
||
/// <summary>Emission the flash adds at full strength (on materials that emit).</summary>
|
||
public const float FlashEmission = .15f;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>H1: the moon ring of an ultimate grows to the 5 m reach of the dance in this time.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Rigid figures: a stride bob while walking and a short lunge on attacks.</summary>
|
||
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);
|
||
}
|
||
}
|
||
}
|