champions-of-luneraria/Assets/Luneraria/Editor/World/LuneUnitLibraryBuilder.cs
Martin Stickelbroeck 3871b3e2e6 U2C Simulation/View Binding
Fester Simulationstakt und Ansichten, die nur lesen; Nachweis, dass die Partie
bei jeder Bildrate dieselbe ist.

- LuneClock: Akkumulator, ganze Schritte zu 0,05 s, Alpha zum Interpolieren.
- LuneMatchRunner: Host-Ablauf einer Bot-Partie (Planung über die
  Befehlsschnittstelle, Kampf im festen Takt, Abrechnung, nächste Runde);
  LuneMatchDriver reicht in Update nur Bildzeit weiter.
- LuneTrace: Fingerabdruck der Partie (jeder Schritt, jede Abrechnung) und der
  reine Kernlauf zum Vergleich.
- Ansichten: LuneMatchView, LuneUnitView (interpolierte Position, Blickrichtung,
  Clips aus Kampfereignissen, Teamring, Lebensbalken), LuneBuildingView mit
  Einsturz und Ruine aus den autoritativen HP, Turmblitze, LuneCameraRig wie
  lune_camera.gd, vorläufige Debug-Anzeige (kein HUD; U3 portiert das G3-HUD).
- Einheitenbibliothek: Kohlenbeißer aus 131 Teilen zu einem Mesh, Animator-
  Controller mit den Blender-Clips, Ring- und Balkenmeshes, Markermaterialien.
- Tests: EditMode (30/60/120 fps, unbegrenzt, Ruckler, 8x Tempo; Seed 2 und
  Seed 9 ab Runde 21) und PlayMode in der Szene bei echten Bildraten 30, 60,
  120 und unbegrenzt: überall derselbe Fingerabdruck wie der Kern.

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

185 lines
9 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Linq;
using Luneraria.View;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using UnityEngine.Rendering;
namespace Luneraria.EditorTools
{
/// <summary>
/// Builds what the unit views need (U2C): a one-mesh version of the rigid Kohlenbeißer (131
/// named parts combined per material, like Godot's rig groups), animator controllers with the
/// Blender clips of the rigged figures, team ring and bar meshes and their materials.
/// </summary>
public static class LuneUnitLibraryBuilder
{
public const string LibraryPath = "Assets/Luneraria/Settings/LuneUnits.asset";
const string Generated = "Assets/Luneraria/Art/Generated";
const string Art = "Assets/Luneraria/Art/";
public static LuneUnitLibrary Build(LuneMaterialSet materials, LuneGround ground)
{
Directory.CreateDirectory(Generated);
AssetDatabase.Refresh();
var library = AssetDatabase.LoadAssetAtPath<LuneUnitLibrary>(LibraryPath);
if (library == null)
{
library = ScriptableObject.CreateInstance<LuneUnitLibrary>();
AssetDatabase.CreateAsset(library, LibraryPath);
}
library.Materials = materials;
library.Ground = ground;
library.Rigid = CombinedPrefab("g01", Art + "Units/g01.fbx");
library.Hero = AssetDatabase.LoadAssetAtPath<GameObject>(Art + "Characters/funkenlanze.fbx");
library.HeroController = Controller("funkenlanze", Art + "Characters/funkenlanze.fbx");
library.Boss = AssetDatabase.LoadAssetAtPath<GameObject>(Art + "Characters/schlundkrone.fbx");
library.BossController = Controller("schlundkrone", Art + "Characters/schlundkrone.fbx");
library.RingRound = Keep(Ring("RingRound", 40, 0f));
library.RingDiamond = Keep(Ring("RingDiamond", 4, 45f));
library.RingHexagon = Keep(Ring("RingHexagon", 6, 0f));
library.Quad = Keep(Quad());
for (int team = 0; team < 3; team++)
{
library.TeamRing[team] = Marker("Ring_" + team, LuneMaterialFactory.TeamColor[team], team == 2 ? .6f : 1.4f, false);
library.BarFill[team] = Marker("BarFill_" + team, Color.Lerp(LuneMaterialFactory.TeamColor[team], Color.white, .15f), 1f, true);
library.Bolt[team] = Marker("Bolt_" + team, LuneMaterialFactory.TeamLight[team], 3f, false);
}
library.HeroRing = Marker("Ring_Hero", LuneMaterialFactory.Hex("f2c96b"), 2.2f, false);
library.BarBack = Marker("BarBack", new Color(0f, 0f, 0f, .7f), 1f, true);
EditorUtility.SetDirty(library);
AssetDatabase.SaveAssets();
return library;
}
/// <summary>All parts of a rigid figure in one mesh, one submesh per material.</summary>
static GameObject CombinedPrefab(string id, string fbx)
{
var source = AssetDatabase.LoadAssetAtPath<GameObject>(fbx);
var groups = new Dictionary<Material, List<CombineInstance>>();
foreach (MeshFilter filter in source.GetComponentsInChildren<MeshFilter>(true))
{
Material[] slots = filter.GetComponent<MeshRenderer>().sharedMaterials;
Matrix4x4 toRoot = source.transform.worldToLocalMatrix * filter.transform.localToWorldMatrix;
for (int sub = 0; sub < filter.sharedMesh.subMeshCount; sub++)
{
if (!groups.TryGetValue(slots[sub], out List<CombineInstance> list))
groups[slots[sub]] = list = new List<CombineInstance>();
list.Add(new CombineInstance { mesh = filter.sharedMesh, subMeshIndex = sub, transform = toRoot });
}
}
var parts = new List<CombineInstance>();
var materials = new List<Material>();
foreach (KeyValuePair<Material, List<CombineInstance>> group in groups.OrderBy(g => g.Key.name))
{
var merged = new Mesh { indexFormat = IndexFormat.UInt32 };
merged.CombineMeshes(group.Value.ToArray(), true, true);
parts.Add(new CombineInstance { mesh = merged, transform = Matrix4x4.identity });
materials.Add(group.Key);
}
var mesh = new Mesh { name = id + "_combined", indexFormat = IndexFormat.UInt32 };
mesh.CombineMeshes(parts.ToArray(), false, false);
mesh.RecalculateBounds();
mesh = Keep(mesh);
string prefabPath = Generated + "/" + id + "_combined.prefab";
var go = new GameObject(id + "_combined");
go.AddComponent<MeshFilter>().sharedMesh = mesh;
go.AddComponent<MeshRenderer>().sharedMaterials = materials.ToArray();
GameObject prefab = PrefabUtility.SaveAsPrefabAsset(go, prefabPath);
Object.DestroyImmediate(go);
return prefab;
}
/// <summary>One state per clip of the model (named like the Blender action), idle first.</summary>
static RuntimeAnimatorController Controller(string id, string fbx)
{
string path = Generated + "/" + id + ".controller";
AssetDatabase.DeleteAsset(path);
AnimatorController controller = AnimatorController.CreateAnimatorControllerAtPath(path);
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(fbx).OfType<AnimationClip>().Where(c => !c.name.StartsWith("__preview__"))
.OrderBy(c => c.name == "idle" ? 0 : 1).ThenBy(c => c.name).ToArray();
AnimatorStateMachine machine = controller.layers[0].stateMachine;
foreach (AnimationClip clip in clips)
{
AnimatorState state = machine.AddState(clip.name);
state.motion = clip;
if (clip.name == "idle")
machine.defaultState = state;
}
EditorUtility.SetDirty(controller);
return controller;
}
/// <summary>Flat ring on the ground (Godot: TorusMesh inner .42, outer .52, flattened).</summary>
static Mesh Ring(string name, int sides, float offsetDegrees)
{
var vertices = new List<Vector3>();
var triangles = new List<int>();
for (int i = 0; i <= sides; i++)
{
float angle = (offsetDegrees + 360f * i / sides) * Mathf.Deg2Rad;
var direction = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle));
vertices.Add(direction * .42f);
vertices.Add(direction * .52f);
if (i < sides)
{
int a = i * 2;
triangles.AddRange(new[] { a, a + 1, a + 3, a, a + 3, a + 2 });
}
}
var mesh = new Mesh { name = name };
mesh.SetVertices(vertices);
mesh.SetTriangles(triangles, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
static Mesh Quad()
{
var mesh = new Mesh { name = "MarkerQuad" };
mesh.SetVertices(new List<Vector3> { new Vector3(-.5f, -.5f, 0f), new Vector3(.5f, -.5f, 0f), new Vector3(-.5f, .5f, 0f), new Vector3(.5f, .5f, 0f) });
mesh.SetTriangles(new[] { 0, 2, 1, 2, 3, 1 }, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
static Mesh Keep(Mesh mesh)
{
string path = Generated + "/" + mesh.name + ".asset";
var existing = AssetDatabase.LoadAssetAtPath<Mesh>(path);
if (existing == null)
{
AssetDatabase.CreateAsset(mesh, path);
return mesh;
}
EditorUtility.CopySerialized(mesh, existing);
EditorUtility.SetDirty(existing);
return existing;
}
/// <summary>Unshaded marker colour (Godot _flat with emission energy); bars draw on top.</summary>
static Material Marker(string name, Color srgb, float energy, bool onTop)
{
string path = LuneMaterialFactory.Folder + "/Markers/" + name + ".mat";
Directory.CreateDirectory(Path.GetDirectoryName(path));
Shader shader = Shader.Find("Luneraria/Unlit");
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
{
material = new Material(shader);
AssetDatabase.CreateAsset(material, path);
}
material.shader = shader;
Color linear = srgb.linear * energy;
material.SetColor("_Color", new Color(linear.r, linear.g, linear.b, srgb.a));
material.SetFloat("_ZTest", onTop ? (float)CompareFunction.Always : (float)CompareFunction.LessEqual);
material.enableInstancing = true;
EditorUtility.SetDirty(material);
return material;
}
}
}