using System; using System.Collections.Generic; using System.Linq; using Luneraria.View; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.UIElements; namespace Luneraria.UI { /// /// 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). /// public sealed class LunePortraits : MonoBehaviour { public const int Small = 160; public const int Large = 400; const float Yaw = .42f; const float Pitch = .2f; /// Layer of the portrait stage; the portrait camera renders nothing else. public const int Layer = 31; const int PerFrame = 3; public static LunePortraits Shared { get; private set; } public event Action PortraitReady; public LuneUnitLibrary Library; /// Portraits rendered so far (tests and the performance log). public int Rendered { get; private set; } readonly Dictionary _textures = new Dictionary(); readonly List _queue = new List(); 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(); portraits.Library = library; return portraits; } void Awake() { Shared = this; var cameraObject = new GameObject("PortraitCamera"); cameraObject.transform.SetParent(transform, false); _camera = cameraObject.AddComponent(); _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(); 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.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(); } } /// The cached portrait, or null while it is queued (PortraitReady follows). 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); } } /// Renders every queued portrait now (tests, captures). 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 others = FindObjectsByType(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(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(); if (idle != null) idle.SampleAnimation(animator != null ? animator.gameObject : model, Mathf.Min(.4f, idle.length)); } return model; } /// Frames the model's bounds: the same view direction for every portrait, size from bounds. 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(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; } } /// /// 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). /// 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(_ => PlaceImage()); RegisterCallback(_ => 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(); } } /// Diagonal hatching over the background (attack champions: temporary units). public Color Hatch { get { return _hatch; } set { if (value == _hatch) return; _hatch = value; MarkDirtyRepaint(); } } /// Fallen units: a red cross over the portrait. 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); } } } }