Alle 86 Modelle der Godot-Referenz laufen durch die Blender→FBX-Pipeline
(nach bestandenem Vertical Function Test); jede Einheit zeigt ihr eigenes
Modell.
- Exportmanifest aus den Godot-Manifesten (tools/blender/make_manifest.py):
Units, Enemies, Props, Characters, Peoples, Buildings, Environment; die
drei Vergleichs-GLBs als Aliase. Neu in der Pipeline: Tangenten berechnet
Unity mit MikkTSpace, Generatoren laufen in einer temporären Kopie, die
Grüne Kluft wird wie das GLB zusammengefasst, Texturen kommen mit.
1.616 Blender- und 1.247 Unity-Prüfungen grün, zwei Exportläufe
byteidentisch; Kontaktbögen je Gruppe.
- Einheitenbibliothek nach Godots AssetLibrary: 70 Einträge der
Einheitengruppen, Figuren mit Skelett ersetzen das starre Modell ihrer ID.
Starre Modelle werden zur Laufzeit je Material zusammengefasst; Controller
werden an Ort und Stelle aktualisiert.
- Tests mit allen Modellen: EditMode 11/11, PlayMode 2/2. Vertical Function
Test Seed 2 DCE95C96556F3A1A und Seed 9 ab Runde 21 7B8E43AEFC0D60C5,
beide gleich dem Kern (Nachweise mit LUNE_EVIDENCE=u2a-batch; die Belege
von U2C und U2D bleiben unverändert).
- macOS-Player mit allen Modellen: 158-160 fps in der Übersicht
(116 Einheiten), 205-207 fps auf der Mid-Lane; Haupt- und Render-Thread
etwa doppelt so lang wie mit dem Platzhalter, Present-Ausreißer kürzer.
- Befund iCloud Drive: Konfliktkopien ("Name 2.ext") im Build-Cache ließen
den Build die Einheitenbibliothek mit altem Feldlayout schreiben; der
Player meldete sharedassets0.assets als beschädigt. LunePlayerBuild
entfernt solche Kopien vor dem Build und bricht ab, wenn während des
Builds neue entstehen. Bereits committete Kopien: Controller-Duplikate und
World/Generated 2.meta entfernt, drei U2D-Benchmarkläufe als -run1 bis
-run3 benannt.
- Dokumentation: U2-WORLD (Batch-Import), PORT-STATUS (Entscheidungen:
Modelle direkt in Git, Projektort außerhalb von iCloud Drive), PORT-QA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.6 KiB
C#
179 lines
7.6 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.
|
|
/// </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 _bars;
|
|
Transform _fill;
|
|
int _lastAttack;
|
|
int _lastAbility;
|
|
float _attackFlash;
|
|
float _time;
|
|
Vector3 _lastVisual;
|
|
string _clip = "";
|
|
bool _attackB;
|
|
bool _deathShown;
|
|
|
|
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);
|
|
_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);
|
|
_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; } }
|
|
|
|
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);
|
|
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);
|
|
// Rigid figures have no death clip; skeletal ones stay down in their death pose.
|
|
if (!_skeletal)
|
|
_model.gameObject.SetActive(Unit.Alive);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|