champions-of-luneraria/Assets/Luneraria/View/LuneUnitView.cs
Martin Stickelbroeck 2c1686b837 U5 Vertical Slice: PBR-Sätze, Surface-Shader, Mid-Lane, Kampf-VFX
Die Mid-Lane steht auf Zielqualität (stilisierter Realismus). Die übrige
Karte bleibt auf dem U2-Stand, bis über den Weg entschieden ist.

- U5A: tools/blender/bake_pbr.py backt sechs nahtlose PBR-Sätze
  (limestone, soil, bark, leaf, rock, water) mit Albedo, Normal aus dem
  Höhenfeld (Sobel) und ORM (R AO, G Rauigkeit, B Metall), je 2k.
  Torus-Mapping für Kantenfluss wie phase10/bake_bark.py.
- U5B: Luneraria/Surface trägt _AlbedoMap, _NormalMap, _OrmMap triplanar
  über den Godot-Weltfarben; ORM steuert Rauigkeit, Metall und AO.
  LuneMaterialFactory bindet je Oberfläche den passenden Satz, LN_Rock
  den Felsensatz.
- U5C: Mid-Lane-Licht — Nebel (ExponentialSquared, Dichte 0,0085),
  scharfe Schatten (Bias 0,02), Weißabgleich und Vignette, ACES und
  Bloom leicht angehoben.
- U5D: Hit- und Angriffs-Flash über MaterialPropertyBlock (warm beim
  Angriff, weißglühend beim Treffer), Turmblitze als dreipunktiger Funke
  mit Breitenkurve und Farbverlauf. CaptureMidLane schreibt fünf
  Nachweisbilder nach docs/port/screenshots/u5/.
- Leistung (macOS-Player, 116 Einheiten): 132 fps Übersicht, 182 fps Mid
  nah — 20–25 % unter U3 durch PBR, Normals und Nebel. Mid-Lane bleibt
  über 120 fps.
- Tests: EditMode 29/29, PlayMode 13/13, Kerntests 457/457. Keine
  Regeländerung; Golden 51.221 Werte unverändert.
- Grenze eingehalten: keine prozeduralen Gesichter, Haare oder Anatomie.
  Sculpts bleiben vorbehalten (Freigabe und Lizenzprüfung).
- Dokumentation: U5-VISUAL (Umsetzung, Nachweis, Entscheidungsvorlage
  für die ganze Karte), PORT-QA (U5), PORT-STATUS, KNOWN-BEHAVIOR-
  DIFFERENCES.

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

231 lines
10 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;
float _attackFlash;
float _time;
Vector3 _lastVisual;
string _clip = "";
bool _attackB;
bool _deathShown;
Renderer[] _renderers;
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);
_renderers = _model.GetComponentsInChildren<Renderer>();
_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;
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;
_lastAttack = Unit.AttackSerial;
_lastAbility = Unit.AbilitySerial;
if (attacked || ability)
_attackFlash = .28f;
_attackFlash = Mathf.Max(0f, _attackFlash - dt);
HitFlashGlow();
if (_skeletal)
AnimateSkeletal(moved, attacked, ability);
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>
/// U5: white hit flash of the core's HitFlash (0.18 s on Take) plus a short warm attack
/// glow. A MaterialPropertyBlock keeps the shared team materials untouched.
/// </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 (_flashBlock == null || _flashBlock.isEmpty)
return;
_flashBlock.Clear();
foreach (Renderer renderer in _renderers)
{
if (renderer != null)
renderer.SetPropertyBlock(null);
}
return;
}
// Attack flash is warm (the figure lunges); a taken hit is white-hot.
float white = Mathf.Clamp01(hit / .18f);
Color color = Color.Lerp(new Color(1f, .72f, .35f), Color.white, white);
_flashBlock.SetColor("_EmissionColor", color * (glow * 2.2f));
_flashBlock.SetColor("_BaseColor", Color.Lerp(Color.white, color, glow * .55f));
foreach (Renderer renderer in _renderers)
{
if (renderer != null)
renderer.SetPropertyBlock(_flashBlock);
}
}
void AnimateSkeletal(float moved, bool attacked, bool ability)
{
if (!Unit.Alive)
{
if (!_deathShown)
Play("death");
_deathShown = true;
return;
}
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 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);
}
}
}