champions-of-luneraria/Assets/Luneraria/View/LuneUnitView.cs
Martin Stickelbroeck 805f5754c9 H1 Sylvara: Figur, Skin-System, Oberfläche, Nachweise
Meshy-Figur Sylvara (20.709 Dreiecke, Lane-Fassung 4.999) mit zwei Sicheln
als eigene Modelle, Clips leap/ultimate, 116 Credits. Skin-System: hero_skin
als kosmetische Planungswahl, Blutmond als Varianten-Skin (skin_variant.py,
AI_*_Blutmond, Dress mit Skin). Heldenfenster mit Reiter Ultimate & Skin,
Befehlsplatz der Heldenkarte, Mondring. Ultimate zündet nicht im Sprungschritt.
Kern 500/500, EditMode 30/30, PlayMode 15/15, Verify 718/0, Selbsttest 30/30,
Fingerabdrücke unverändert; Balance 50 % wie Ringborke/Funkenlanze; Leistung
gleich der Funkenlanze-Heldin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:06:58 +02:00

324 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
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);
}
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);
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);
}
}
}