Das G3-HUD steht im Spiel; die Abnahme (U3D) folgt. - U3B: alle Bereiche und Komponenten aus Godot übertragen (LuneHud, Lanes, Bossgrotte, nächste Welle, Kaufkarten, Armee, Held mit Items und Talenten, Angriffschampions, Team, Ereignisse, Minimap, Tooltip, Optionen, Partieende). Porträts aus den echten Modellen, einmal gerendert. - Offene G3-Punkte umgesetzt: „1 WELLE“ als Band am Porträtrand, Schadens- und Rüstungssymbole auf den Karten, Armeewert, Heldenporträts auf der Minimap. Befehlsplatz für die Ultimates der geplanten Helden reserviert (verborgen). - U3C: LuneGame verdrahtet Befehle (gleiche Schnittstelle wie die Bots), Bereit mit 1-s-Sperre nach dem Kampf, Tempo und Pause, Tasten wie in G3, Kamera-Ziele, Auswahl im Feld, Planungsvorschau und Aufstellungsflächen. Ersetzt die U2-Debug-Anzeige. Bot-Partien und Benchmark unverändert. - Szenen- und Bibliotheksbauer idempotent: Animator-Controller und Post-Processing-Profil werden an Ort und Stelle aktualisiert. Die Szene bekommt bei jedem Bau neue Objekt-IDs, der Inhalt ist gleich. - Prüfung (Port von verify_g3, Teile A–H): 699 Prüfungen ohne Fehler. EditMode 23/23, PlayMode 12/12, Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
17 KiB
C#
431 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Luneraria.View;
|
|
using UnityEngine;
|
|
using UnityEngine.Rendering;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace Luneraria.UI
|
|
{
|
|
/// <summary>
|
|
/// Portraits from the real game models (port of components/portrait_cache.gd). An offscreen
|
|
/// camera renders one model at a time with the same orthographic three-quarter view, the same
|
|
/// key, fill and rim light and a transparent background; every model is framed from its own
|
|
/// bounds, so small creatures and the Schlundkrone fill the card alike. Each portrait is
|
|
/// rendered once and kept, so cards never hold live views. Requests are queued and rendered a
|
|
/// few per frame (Godot: one per frame).
|
|
/// </summary>
|
|
public sealed class LunePortraits : MonoBehaviour
|
|
{
|
|
public const int Small = 160;
|
|
public const int Large = 400;
|
|
const float Yaw = .42f;
|
|
const float Pitch = .2f;
|
|
/// <summary>Layer of the portrait stage; the portrait camera renders nothing else.</summary>
|
|
public const int Layer = 31;
|
|
const int PerFrame = 3;
|
|
|
|
public static LunePortraits Shared { get; private set; }
|
|
public event Action<string> PortraitReady;
|
|
public LuneUnitLibrary Library;
|
|
/// <summary>Portraits rendered so far (tests and the performance log).</summary>
|
|
public int Rendered { get; private set; }
|
|
|
|
readonly Dictionary<string, Texture> _textures = new Dictionary<string, Texture>();
|
|
readonly List<string> _queue = new List<string>();
|
|
Camera _camera;
|
|
readonly Light[] _lights = new Light[3];
|
|
|
|
public static string KeyFor(string modelId, bool large = false)
|
|
{
|
|
return modelId + (large ? "@L" : "");
|
|
}
|
|
|
|
public static LunePortraits Create(LuneUnitLibrary library)
|
|
{
|
|
if (Shared != null)
|
|
{
|
|
Shared.Library = library;
|
|
return Shared;
|
|
}
|
|
var host = new GameObject("LunePortraits");
|
|
host.transform.position = new Vector3(0f, -2000f, 0f);
|
|
var portraits = host.AddComponent<LunePortraits>();
|
|
portraits.Library = library;
|
|
return portraits;
|
|
}
|
|
|
|
void Awake()
|
|
{
|
|
Shared = this;
|
|
var cameraObject = new GameObject("PortraitCamera");
|
|
cameraObject.transform.SetParent(transform, false);
|
|
_camera = cameraObject.AddComponent<Camera>();
|
|
_camera.enabled = false;
|
|
_camera.orthographic = true;
|
|
_camera.clearFlags = CameraClearFlags.SolidColor;
|
|
_camera.backgroundColor = new Color(0f, 0f, 0f, 0f);
|
|
_camera.cullingMask = 1 << Layer;
|
|
_camera.allowMSAA = true;
|
|
_camera.allowHDR = false;
|
|
var data = cameraObject.AddComponent<UnityEngine.Rendering.Universal.UniversalAdditionalCameraData>();
|
|
data.renderPostProcessing = false;
|
|
data.renderShadows = false;
|
|
// Key, fill and rim light of the reference. Godot rotation_degrees (x, y) with the light along
|
|
// -Z (YXZ order) become Unity Euler (-x, 180 - y) with the light along +Z and x mirrored.
|
|
Vector3[] angles = { new Vector3(38f, 212f, 0f), new Vector3(18f, 30f, 0f), new Vector3(8f, 140f, 0f) };
|
|
Color[] colors = { LuneUiTheme.Hex("ffe2bc"), LuneUiTheme.Hex("9fc4ff"), LuneUiTheme.Hex("c8d4e4") };
|
|
float[] energies = { 1.75f, .9f, .35f };
|
|
for (int index = 0; index < 3; index++)
|
|
{
|
|
var lightObject = new GameObject("PortraitLight" + index);
|
|
lightObject.transform.SetParent(transform, false);
|
|
lightObject.transform.rotation = Quaternion.Euler(angles[index]);
|
|
Light light = lightObject.AddComponent<Light>();
|
|
light.type = LightType.Directional;
|
|
light.color = colors[index];
|
|
light.intensity = energies[index];
|
|
light.shadows = LightShadows.None;
|
|
light.cullingMask = 1 << Layer;
|
|
light.enabled = false;
|
|
_lights[index] = light;
|
|
}
|
|
}
|
|
|
|
void OnDestroy()
|
|
{
|
|
if (Shared == this)
|
|
Shared = null;
|
|
foreach (Texture texture in _textures.Values)
|
|
{
|
|
if (texture is RenderTexture target)
|
|
target.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>The cached portrait, or null while it is queued (PortraitReady follows).</summary>
|
|
public Texture Portrait(string modelId, bool large = false)
|
|
{
|
|
if (string.IsNullOrEmpty(modelId))
|
|
return null;
|
|
string key = KeyFor(modelId, large);
|
|
if (_textures.TryGetValue(key, out Texture texture))
|
|
return texture;
|
|
if (!_queue.Contains(key))
|
|
_queue.Add(key);
|
|
return null;
|
|
}
|
|
|
|
public int Pending { get { return _queue.Count; } }
|
|
|
|
void LateUpdate()
|
|
{
|
|
for (int count = 0; count < PerFrame && _queue.Count > 0; count++)
|
|
{
|
|
string key = _queue[0];
|
|
_queue.RemoveAt(0);
|
|
RenderNow(key);
|
|
}
|
|
}
|
|
|
|
/// <summary>Renders every queued portrait now (tests, captures).</summary>
|
|
public void Flush()
|
|
{
|
|
while (_queue.Count > 0)
|
|
{
|
|
string key = _queue[0];
|
|
_queue.RemoveAt(0);
|
|
RenderNow(key);
|
|
}
|
|
}
|
|
|
|
void RenderNow(string key)
|
|
{
|
|
bool large = key.EndsWith("@L", StringComparison.Ordinal);
|
|
string modelId = large ? key.Substring(0, key.Length - 2) : key;
|
|
int edge = large ? Large : Small;
|
|
GameObject model = Instantiate(modelId);
|
|
if (model == null)
|
|
{
|
|
_textures[key] = null;
|
|
PortraitReady?.Invoke(key);
|
|
return;
|
|
}
|
|
var target = new RenderTexture(edge, edge, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4, name = "Portrait " + key };
|
|
Frame(model);
|
|
// Only the portrait lights shine; the scene's lights and ambient come back afterwards.
|
|
List<Light> others = FindObjectsByType<Light>(FindObjectsSortMode.None).Where(l => l.enabled && Array.IndexOf(_lights, l) < 0).ToList();
|
|
foreach (Light light in others)
|
|
light.enabled = false;
|
|
foreach (Light light in _lights)
|
|
light.enabled = true;
|
|
SphericalHarmonicsL2 ambient = RenderSettings.ambientProbe;
|
|
var probe = new SphericalHarmonicsL2();
|
|
probe.AddAmbientLight(LuneUiTheme.Hex("a8b4c8").linear * .62f);
|
|
RenderSettings.ambientProbe = probe;
|
|
bool fog = RenderSettings.fog;
|
|
RenderSettings.fog = false;
|
|
_camera.targetTexture = target;
|
|
_camera.Render();
|
|
_camera.targetTexture = null;
|
|
RenderSettings.fog = fog;
|
|
RenderSettings.ambientProbe = ambient;
|
|
foreach (Light light in _lights)
|
|
light.enabled = false;
|
|
foreach (Light light in others)
|
|
light.enabled = true;
|
|
// Destroy takes effect at the end of the frame: hide the model now, or the next portrait
|
|
// of the same frame would show it too.
|
|
model.SetActive(false);
|
|
Destroy(model);
|
|
_textures[key] = target;
|
|
Rendered++;
|
|
PortraitReady?.Invoke(key);
|
|
}
|
|
|
|
GameObject Instantiate(string modelId)
|
|
{
|
|
if (Library == null)
|
|
return null;
|
|
LuneUnitLibrary.UnitModel entry = Library.Find(modelId);
|
|
if (entry == null || entry.Prefab == null)
|
|
return null;
|
|
GameObject model = UnityEngine.Object.Instantiate(entry.Prefab, transform, false);
|
|
model.SetActive(true);
|
|
foreach (Transform node in model.GetComponentsInChildren<Transform>(true))
|
|
node.gameObject.layer = Layer;
|
|
Library.Materials?.Dress(model, 2);
|
|
// Skinned figures stand in their idle pose (Godot preview("idle", .4)).
|
|
if (entry.Controller != null)
|
|
{
|
|
AnimationClip idle = entry.Controller.animationClips.FirstOrDefault(c => c.name == "idle") ?? entry.Controller.animationClips.FirstOrDefault();
|
|
Animator animator = model.GetComponentInChildren<Animator>();
|
|
if (idle != null)
|
|
idle.SampleAnimation(animator != null ? animator.gameObject : model, Mathf.Min(.4f, idle.length));
|
|
}
|
|
return model;
|
|
}
|
|
|
|
/// <summary>Frames the model's bounds: the same view direction for every portrait, size from bounds.</summary>
|
|
void Frame(GameObject model)
|
|
{
|
|
Bounds box = BoundsOf(model);
|
|
Vector3 center = box.center;
|
|
float radius = Mathf.Max(.3f, box.size.magnitude * .5f);
|
|
// Godot view direction (sin yaw cos pitch, sin pitch, cos yaw cos pitch) in Unity's frame (x mirrored).
|
|
var direction = new Vector3(-Mathf.Sin(Yaw) * Mathf.Cos(Pitch), Mathf.Sin(Pitch), Mathf.Cos(Yaw) * Mathf.Cos(Pitch));
|
|
float size = Mathf.Max(box.size.y, Mathf.Max(box.size.x, box.size.z) * .92f) * 1.12f;
|
|
_camera.orthographicSize = size * .5f;
|
|
_camera.nearClipPlane = .05f;
|
|
_camera.farClipPlane = radius * 8f + 10f;
|
|
_camera.transform.position = center + direction * (radius * 3f + 2f);
|
|
_camera.transform.LookAt(center, Vector3.up);
|
|
}
|
|
|
|
static Bounds BoundsOf(GameObject model)
|
|
{
|
|
Renderer[] renderers = model.GetComponentsInChildren<Renderer>(true);
|
|
if (renderers.Length == 0)
|
|
return new Bounds(model.transform.position + Vector3.up * .8f, new Vector3(1f, 1.6f, 1f));
|
|
Bounds result = renderers[0].bounds;
|
|
foreach (Renderer renderer in renderers)
|
|
result.Encapsulate(renderer.bounds);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A real-model portrait on the neutral card background (dark slate gradient, soft floor;
|
|
/// Godot LunePortraitRect). It asks LunePortraits for the texture and fills in when the
|
|
/// render is done. Options: dimmed, a coloured ring, diagonal hatching (attack champions),
|
|
/// a red cross (fallen units).
|
|
/// </summary>
|
|
public sealed class LunePortraitElement : VisualElement
|
|
{
|
|
public string ModelId { get; private set; } = "";
|
|
public bool Large { get; private set; }
|
|
readonly VisualElement _image;
|
|
readonly VisualElement _overlay;
|
|
Texture _texture;
|
|
bool _dimmed;
|
|
Color _ring;
|
|
Color _hatch;
|
|
bool _crossed;
|
|
bool _listening;
|
|
|
|
public LunePortraitElement()
|
|
{
|
|
pickingMode = PickingMode.Ignore;
|
|
style.overflow = Overflow.Hidden;
|
|
style.flexShrink = 0f;
|
|
generateVisualContent += DrawBackground;
|
|
_image = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
_image.style.position = Position.Absolute;
|
|
Add(_image);
|
|
_overlay = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
_overlay.style.position = Position.Absolute;
|
|
_overlay.style.left = 0f;
|
|
_overlay.style.top = 0f;
|
|
_overlay.style.right = 0f;
|
|
_overlay.style.bottom = 0f;
|
|
_overlay.generateVisualContent += DrawOverlay;
|
|
Add(_overlay);
|
|
RegisterCallback<GeometryChangedEvent>(_ => PlaceImage());
|
|
RegisterCallback<DetachFromPanelEvent>(_ => Unlisten());
|
|
}
|
|
|
|
public bool HasPortrait { get { return _texture != null; } }
|
|
|
|
public void ShowModel(string id, bool large = false)
|
|
{
|
|
if (id == ModelId && large == Large && _texture != null)
|
|
return;
|
|
ModelId = id ?? "";
|
|
Large = large;
|
|
LunePortraits cache = LunePortraits.Shared;
|
|
_texture = cache != null ? cache.Portrait(ModelId, large) : null;
|
|
if (_texture == null && cache != null && !_listening)
|
|
{
|
|
cache.PortraitReady += OnReady;
|
|
_listening = true;
|
|
}
|
|
ApplyTexture();
|
|
}
|
|
|
|
void OnReady(string key)
|
|
{
|
|
if (key != LunePortraits.KeyFor(ModelId, Large) || LunePortraits.Shared == null)
|
|
return;
|
|
_texture = LunePortraits.Shared.Portrait(ModelId, Large);
|
|
ApplyTexture();
|
|
Unlisten();
|
|
}
|
|
|
|
void Unlisten()
|
|
{
|
|
if (_listening && LunePortraits.Shared != null)
|
|
LunePortraits.Shared.PortraitReady -= OnReady;
|
|
_listening = false;
|
|
}
|
|
|
|
void ApplyTexture()
|
|
{
|
|
if (_texture is RenderTexture target)
|
|
_image.style.backgroundImage = new StyleBackground(Background.FromRenderTexture(target));
|
|
else if (_texture is Texture2D texture)
|
|
_image.style.backgroundImage = new StyleBackground(texture);
|
|
else
|
|
_image.style.backgroundImage = StyleKeyword.None;
|
|
_image.style.unityBackgroundImageTintColor = _dimmed ? new Color(1f, 1f, 1f, .45f) : Color.white;
|
|
MarkDirtyRepaint();
|
|
}
|
|
|
|
void PlaceImage()
|
|
{
|
|
float edge = Mathf.Min(layout.width, layout.height);
|
|
if (float.IsNaN(edge))
|
|
return;
|
|
_image.style.left = (layout.width - edge) * .5f;
|
|
_image.style.top = (layout.height - edge) * .5f;
|
|
_image.style.width = edge;
|
|
_image.style.height = edge;
|
|
}
|
|
|
|
public bool Dimmed
|
|
{
|
|
get { return _dimmed; }
|
|
set
|
|
{
|
|
if (value == _dimmed)
|
|
return;
|
|
_dimmed = value;
|
|
ApplyTexture();
|
|
}
|
|
}
|
|
|
|
public Color Ring
|
|
{
|
|
get { return _ring; }
|
|
set
|
|
{
|
|
if (value == _ring)
|
|
return;
|
|
_ring = value;
|
|
_overlay.MarkDirtyRepaint();
|
|
}
|
|
}
|
|
|
|
/// <summary>Diagonal hatching over the background (attack champions: temporary units).</summary>
|
|
public Color Hatch
|
|
{
|
|
get { return _hatch; }
|
|
set
|
|
{
|
|
if (value == _hatch)
|
|
return;
|
|
_hatch = value;
|
|
MarkDirtyRepaint();
|
|
}
|
|
}
|
|
|
|
/// <summary>Fallen units: a red cross over the portrait.</summary>
|
|
public bool Crossed
|
|
{
|
|
get { return _crossed; }
|
|
set
|
|
{
|
|
if (value == _crossed)
|
|
return;
|
|
_crossed = value;
|
|
_overlay.MarkDirtyRepaint();
|
|
}
|
|
}
|
|
|
|
void DrawBackground(MeshGenerationContext context)
|
|
{
|
|
var rect = new Rect(0f, 0f, layout.width, layout.height);
|
|
if (rect.width <= 0f || rect.height <= 0f)
|
|
return;
|
|
LunePlates.Gradient(context, LuneHpBar.Corners(rect), rect, LuneUiTheme.Hex("202b37"), LuneUiTheme.Hex("0d1218"));
|
|
Painter2D painter = context.painter2D;
|
|
var floor = new Vector2[20];
|
|
for (int index = 0; index < 20; index++)
|
|
{
|
|
float angle = Mathf.PI * 2f * index / 20f;
|
|
floor[index] = new Vector2(rect.width * .5f + Mathf.Cos(angle) * rect.width * .36f, rect.height * .88f + Mathf.Sin(angle) * rect.height * .06f);
|
|
}
|
|
LunePlates.Polygon(painter, floor, new Color(0f, 0f, 0f, .35f));
|
|
if (_hatch.a > 0f)
|
|
{
|
|
for (float x = -rect.height; x < rect.width; x += 9f)
|
|
{
|
|
var from = new Vector2(Mathf.Max(x, 0f), Mathf.Max(0f, -x));
|
|
var to = new Vector2(Mathf.Min(x + rect.height, rect.width), Mathf.Min(rect.height, rect.width - x));
|
|
LunePlates.Line(painter, from, to, _hatch, 2f);
|
|
}
|
|
}
|
|
if (_texture == null)
|
|
{
|
|
float inset = Mathf.Min(rect.width, rect.height) * .22f;
|
|
LuneGlyphs.Draw(painter, "human", new Rect(inset, inset, rect.width - inset * 2f, rect.height - inset * 2f), new Color(1f, 1f, 1f, .08f));
|
|
}
|
|
}
|
|
|
|
void DrawOverlay(MeshGenerationContext context)
|
|
{
|
|
Painter2D painter = context.painter2D;
|
|
float width = _overlay.layout.width;
|
|
float height = _overlay.layout.height;
|
|
if (_ring.a > 0f)
|
|
LunePlates.Outline(painter, LuneHpBar.Corners(new Rect(1f, 1f, width - 2f, height - 2f)), _ring, 2f);
|
|
if (_crossed)
|
|
{
|
|
float inset = Mathf.Min(width, height) * .22f;
|
|
var color = new Color(1f, .42f, .36f, .85f);
|
|
LunePlates.Line(painter, new Vector2(inset, inset), new Vector2(width - inset, height - inset), color, 2f);
|
|
LunePlates.Line(painter, new Vector2(width - inset, inset), new Vector2(inset, height - inset), color, 2f);
|
|
}
|
|
}
|
|
}
|
|
}
|