champions-of-luneraria/Assets/Luneraria/View/LuneUnitView.cs
Martin Stickelbroeck 07fa4572e0 U5.3: Gebäude, Boss und Ringborke auf PBR; Bildfehler aus U5 behoben
Fortsetzung des angefangenen U5.3-Stands vom 2. Oktober.

- Fünf neue Sätze aus bake_pbr.py (metal, cloth, hide, bone, wood) für
  Türme, Ancients, Grotte und die Schlundkrone. Ringborkes Rinde (RB_Bark)
  auf ihren eigenen UVs mit dem 4k-Satz (Rauigkeit zu ORM gepackt),
  Normalen im Tangentenraum des Meshes. Aufnahmen aller drei Lanes.
- Befund: Seit U5 war die Welt halb so hell und ohne Grün (Mid-Lane
  Helligkeit 0,27, Grün 0 %). Ursachen und Korrekturen:
  - Die PBR-Albedos wurden über die Godot-Farben multipliziert (Rasen
    etwa x0,05). Jetzt teilt der Shader jede Karte durch ihren Mittel-
    wert; die Godot-Farbe bleibt der Durchschnitt, die Karte liefert
    Struktur. Ebenso die Rauigkeit, wo das Material eine eigene hat.
  - Normal- und ORM-Karten waren als sRGB-Farbe importiert. Jetzt
    Normalkarte bzw. linear.
  - bake_pbr.py verzerrte alle Muster 10:1 (Regenstreifen). Stein, Erde,
    Laub, Wasser und die neuen Sätze ohne Maserung neu gebacken.
  - Der Treffer-Flash ersetzte die Grundfarbe aller Materialplätze; im
    Nahkampf waren die Armeen weiß und der Boss gelb. Jetzt Tönung der
    eigenen Farbe je Platz, wenig Emission, 16 Stufen.
  - Nebel 0,0085 verschleierte die Kartenansicht zu 35-50 %; jetzt 0,0045.
  Ergebnis Mid-Lane: Helligkeit 0,41-0,44, Grün 31-39 % (U3: 0,45 / 27 %).
- Tests: EditMode 29/29, PlayMode 13/13, Verify 718/0, Kerntests .NET
  457/457, Fingerabdrücke unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Leistung: A/B mit einem U5.2-Player unter gleicher Last, kein messbarer
  Unterschied (91,4 gegen 93,8 fps Karte, 124,9 gegen 125,3 fps Mid).
- Messwerkzeug tools/port/png_stats.py; Doku U5-VISUAL (U5.3),
  PORT-STATUS, PORT-QA; Bilder screenshots/u5.3/ als JPEG.

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

277 lines
13 KiB
C#
Raw 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;
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);
_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;
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>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);
}
}
}
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);
}
}
}