U3B/U3C Zwischenstand: HUD-Bereiche, Eingabe und Befehle
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>
|
|
@ -4,6 +4,8 @@
|
||||||
"references": [
|
"references": [
|
||||||
"Luneraria.Core",
|
"Luneraria.Core",
|
||||||
"Luneraria.View",
|
"Luneraria.View",
|
||||||
|
"Luneraria.UI",
|
||||||
|
"Luneraria.UI.Editor",
|
||||||
"Unity.RenderPipelines.Core.Runtime",
|
"Unity.RenderPipelines.Core.Runtime",
|
||||||
"Unity.RenderPipelines.Universal.Runtime"
|
"Unity.RenderPipelines.Universal.Runtime"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,15 @@ namespace Luneraria.EditorTools
|
||||||
string path = Generated + "/" + id + ".controller";
|
string path = Generated + "/" + id + ".controller";
|
||||||
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path) ?? AnimatorController.CreateAnimatorControllerAtPath(path);
|
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path) ?? AnimatorController.CreateAnimatorControllerAtPath(path);
|
||||||
AnimatorStateMachine machine = controller.layers[0].stateMachine;
|
AnimatorStateMachine machine = controller.layers[0].stateMachine;
|
||||||
foreach (ChildAnimatorState child in machine.states.ToArray())
|
|
||||||
machine.RemoveState(child.state);
|
|
||||||
AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(fbx).OfType<AnimationClip>().Where(c => !c.name.StartsWith("__preview__"))
|
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();
|
.OrderBy(c => c.name == "idle" ? 0 : 1).ThenBy(c => c.name).ToArray();
|
||||||
|
// Unchanged clips: keep the states (rebuilding them gave new file ids on every build).
|
||||||
|
ChildAnimatorState[] current = machine.states;
|
||||||
|
bool same = current.Length == clips.Length && current.Select((child, index) => child.state.name == clips[index].name && child.state.motion == clips[index]).All(match => match);
|
||||||
|
if (same)
|
||||||
|
return controller;
|
||||||
|
foreach (ChildAnimatorState child in machine.states.ToArray())
|
||||||
|
machine.RemoveState(child.state);
|
||||||
foreach (AnimationClip clip in clips)
|
foreach (AnimationClip clip in clips)
|
||||||
{
|
{
|
||||||
AnimatorState state = machine.AddState(clip.name);
|
AnimatorState state = machine.AddState(clip.name);
|
||||||
|
|
|
||||||
|
|
@ -75,14 +75,19 @@ namespace Luneraria.EditorTools
|
||||||
var rig = camera.AddComponent<LuneCameraRig>();
|
var rig = camera.AddComponent<LuneCameraRig>();
|
||||||
rig.Ground = ground;
|
rig.Ground = ground;
|
||||||
rig.Zoom = LuneCameraMath.OverviewZoom;
|
rig.Zoom = LuneCameraMath.OverviewZoom;
|
||||||
// The match: a bot match on the host, its views and the temporary U2 readout.
|
// The match on the host and its views; the game controller with the HUD (U3) lets the
|
||||||
|
// human seat play. Tests and the benchmark start bot matches themselves.
|
||||||
var matchObject = new GameObject("Match");
|
var matchObject = new GameObject("Match");
|
||||||
var driver = matchObject.AddComponent<LuneMatchDriver>();
|
var driver = matchObject.AddComponent<LuneMatchDriver>();
|
||||||
var view = matchObject.AddComponent<LuneMatchView>();
|
var view = matchObject.AddComponent<LuneMatchView>();
|
||||||
view.Driver = driver;
|
view.Driver = driver;
|
||||||
view.Library = library;
|
view.Library = library;
|
||||||
view.ViewCamera = unityCamera;
|
view.ViewCamera = unityCamera;
|
||||||
matchObject.AddComponent<LuneDebugOverlay>().Driver = driver;
|
var game = matchObject.AddComponent<Luneraria.UI.LuneGame>();
|
||||||
|
game.Driver = driver;
|
||||||
|
game.View = view;
|
||||||
|
game.Rig = rig;
|
||||||
|
game.UiAssets = AssetDatabase.LoadAssetAtPath<Luneraria.UI.LuneUiAssets>(LuneUiAssetsBuilder.AssetPath);
|
||||||
var benchmark = matchObject.AddComponent<LuneBenchmark>();
|
var benchmark = matchObject.AddComponent<LuneBenchmark>();
|
||||||
benchmark.Driver = driver;
|
benchmark.Driver = driver;
|
||||||
benchmark.View = view;
|
benchmark.View = view;
|
||||||
|
|
@ -171,6 +176,15 @@ namespace Luneraria.EditorTools
|
||||||
return light;
|
return light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static T Override<T>(VolumeProfile profile) where T : VolumeComponent
|
||||||
|
{
|
||||||
|
if (profile.TryGet(out T component))
|
||||||
|
return component;
|
||||||
|
component = profile.Add<T>(true);
|
||||||
|
AssetDatabase.AddObjectToAsset(component, profile);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ACES tone mapping, glow and colour adjustment of scripts/presentation/lighting.gd.</summary>
|
/// <summary>ACES tone mapping, glow and colour adjustment of scripts/presentation/lighting.gd.</summary>
|
||||||
static VolumeProfile VolumeProfile()
|
static VolumeProfile VolumeProfile()
|
||||||
{
|
{
|
||||||
|
|
@ -180,22 +194,16 @@ namespace Luneraria.EditorTools
|
||||||
profile = ScriptableObject.CreateInstance<VolumeProfile>();
|
profile = ScriptableObject.CreateInstance<VolumeProfile>();
|
||||||
AssetDatabase.CreateAsset(profile, VolumePath);
|
AssetDatabase.CreateAsset(profile, VolumePath);
|
||||||
}
|
}
|
||||||
foreach (VolumeComponent old in profile.components.ToList())
|
// Updated in place: recreating the overrides gave them new file ids on every build.
|
||||||
{
|
var tonemapping = Override<Tonemapping>(profile);
|
||||||
profile.Remove(old.GetType());
|
|
||||||
UnityEngine.Object.DestroyImmediate(old, true);
|
|
||||||
}
|
|
||||||
var tonemapping = profile.Add<Tonemapping>(true);
|
|
||||||
tonemapping.mode.Override(TonemappingMode.ACES);
|
tonemapping.mode.Override(TonemappingMode.ACES);
|
||||||
var bloom = profile.Add<Bloom>(true);
|
var bloom = Override<Bloom>(profile);
|
||||||
bloom.threshold.Override(1.2f);
|
bloom.threshold.Override(1.2f);
|
||||||
bloom.intensity.Override(.55f);
|
bloom.intensity.Override(.55f);
|
||||||
bloom.scatter.Override(.6f);
|
bloom.scatter.Override(.6f);
|
||||||
var colors = profile.Add<ColorAdjustments>(true);
|
var colors = Override<ColorAdjustments>(profile);
|
||||||
colors.contrast.Override(12f);
|
colors.contrast.Override(12f);
|
||||||
colors.saturation.Override(18f);
|
colors.saturation.Override(18f);
|
||||||
foreach (VolumeComponent component in profile.components)
|
|
||||||
AssetDatabase.AddObjectToAsset(component, profile);
|
|
||||||
EditorUtility.SetDirty(profile);
|
EditorUtility.SetDirty(profile);
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
Assets/Luneraria/Tests/UI/PlayMode/HudCaptureTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using Luneraria.View;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
|
||||||
|
namespace Luneraria.UI.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// U3B: the HUD on a real match in the G1 scene, rendered over the camera image at 1920×1080
|
||||||
|
/// (tools/out/hud-*.png). First look at layout, fonts, plates and portraits against the G3
|
||||||
|
/// screenshots.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HudCaptureTests
|
||||||
|
{
|
||||||
|
public static string Root { get { return Path.GetDirectoryName(Application.dataPath); } }
|
||||||
|
|
||||||
|
public static LuneUiAssets Assets()
|
||||||
|
{
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
return UnityEditor.AssetDatabase.LoadAssetAtPath<LuneUiAssets>("Assets/Luneraria/Settings/LuneUi.asset");
|
||||||
|
#else
|
||||||
|
return null;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerator LoadScene()
|
||||||
|
{
|
||||||
|
AsyncOperation loading = SceneManager.LoadSceneAsync("G1_Map", LoadSceneMode.Single);
|
||||||
|
while (!loading.isDone)
|
||||||
|
yield return null;
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composes the camera image and the HUD texture and writes a PNG.</summary>
|
||||||
|
public static void Capture(LuneHud hud, RenderTexture target, string file)
|
||||||
|
{
|
||||||
|
LunePortraits.Shared?.Flush();
|
||||||
|
Texture2D scene = LuneHudCapture.RenderScene(Camera.main, target.width, target.height);
|
||||||
|
Texture2D overlay = LuneHudCapture.Read(target);
|
||||||
|
Texture2D image = LuneHudCapture.Compose(scene, overlay);
|
||||||
|
LuneHudCapture.Save(image, file);
|
||||||
|
Object.Destroy(scene);
|
||||||
|
Object.Destroy(overlay);
|
||||||
|
Object.Destroy(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(600000)]
|
||||||
|
public IEnumerator PlanningHudOverTheScene()
|
||||||
|
{
|
||||||
|
LunePreferences.ResetSession();
|
||||||
|
yield return LoadScene();
|
||||||
|
var driver = Object.FindAnyObjectByType<LuneMatchDriver>();
|
||||||
|
var view = Object.FindAnyObjectByType<LuneMatchView>();
|
||||||
|
var rig = Object.FindAnyObjectByType<LuneCameraRig>();
|
||||||
|
driver.HumanSlot = 1;
|
||||||
|
driver.Seed = 4;
|
||||||
|
driver.Restart();
|
||||||
|
LuneMatch match = driver.Runner.Match;
|
||||||
|
Player me = match.Players[1];
|
||||||
|
List<string> ids = match.BaseIds(me.Bund);
|
||||||
|
foreach (int index in new[] { 1, 0, 2 })
|
||||||
|
Assert.IsTrue(match.Submit(1, Command.Buy(ids[index])).Ok);
|
||||||
|
var target = new RenderTexture(1920, 1080, 0, RenderTextureFormat.ARGB32);
|
||||||
|
var hud = new GameObject("Hud").AddComponent<LuneHud>();
|
||||||
|
hud.Init(match, 1, view.Library, Assets(), target);
|
||||||
|
Vec2 point = match.Map.LanePoint(me.Team, me.Lane, match.Map.LanePaths[me.Lane].Length * .2);
|
||||||
|
rig.LookAt(point, LuneCameraMath.DefaultZoom, true);
|
||||||
|
for (int frame = 0; frame < 30; frame++)
|
||||||
|
{
|
||||||
|
if (frame == 10)
|
||||||
|
LunePortraits.Shared.Flush();
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
Capture(hud, target, Path.Combine(Root, "tools/out/hud-planung-1920.png"));
|
||||||
|
Debug.Log("LUNE_HUD syncs " + hud.Syncs + " sync ms " + hud.SyncMs.ToString("0.000") + " portraits " + LunePortraits.Shared.Rendered + " scale " + hud.UiScale + " size " + hud.LogicalSize);
|
||||||
|
foreach (KeyValuePair<string, Rect> region in hud.Regions())
|
||||||
|
Debug.Log("LUNE_HUD region " + region.Key + " " + region.Value);
|
||||||
|
Assert.Greater(hud.Syncs, 0);
|
||||||
|
Object.Destroy(hud.gameObject);
|
||||||
|
target.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1a267da01c8544b99b29db1b535260a6
|
||||||
257
Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using Luneraria.View;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The real game for the U3 tests (port of the helpers of tools/luneraria/verify_g3.gd): the G1
|
||||||
|
/// scene with its game controller, a human seat, the HUD and the camera rendering into textures
|
||||||
|
/// of one size, so screen points of the camera and of the HUD agree. Input goes through the
|
||||||
|
/// real paths: UI Toolkit pointer events for the HUD, LuneGame.Key and ClickField for keys and
|
||||||
|
/// the field. Nothing sets HUD state directly; public match state (round, HP, gold, claims) is
|
||||||
|
/// set like Godot's g3_views.gd did.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HudSession
|
||||||
|
{
|
||||||
|
public const int Human = 1;
|
||||||
|
public LuneGame Game;
|
||||||
|
public LuneHud Hud;
|
||||||
|
public LuneMatchDriver Driver;
|
||||||
|
public LuneMatchView View;
|
||||||
|
public LuneCameraRig Rig;
|
||||||
|
public Camera Camera;
|
||||||
|
public RenderTexture Target;
|
||||||
|
RenderTexture _sceneTarget;
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
|
||||||
|
public LuneMatch Match { get { return Driver.Runner.Match; } }
|
||||||
|
public LuneMatchRunner Runner { get { return Driver.Runner; } }
|
||||||
|
public LuneHudModel Model { get { return Hud.Model; } }
|
||||||
|
public Player Me { get { return Match.Players[Human]; } }
|
||||||
|
|
||||||
|
public static string Root { get { return Path.GetDirectoryName(Application.dataPath); } }
|
||||||
|
|
||||||
|
/// <summary>Loads the scene and starts a match with the human seat, HUD into a width×height texture.</summary>
|
||||||
|
public IEnumerator Open(int width = 1920, int height = 1080, int seed = 4, float uiScale = 1f, bool reducedMotion = false, int startRound = 1, int extraGold = 0)
|
||||||
|
{
|
||||||
|
LunePreferences.ResetSession(uiScale, reducedMotion);
|
||||||
|
yield return HudCaptureTests.LoadScene();
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
Game = Object.FindAnyObjectByType<LuneGame>();
|
||||||
|
Assert.IsNotNull(Game, "the scene has a game controller");
|
||||||
|
Driver = Game.Driver;
|
||||||
|
View = Game.View;
|
||||||
|
Rig = Game.Rig;
|
||||||
|
Camera = View.ViewCamera != null ? View.ViewCamera : Camera.main;
|
||||||
|
Target = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32);
|
||||||
|
_sceneTarget = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
|
||||||
|
Camera.targetTexture = _sceneTarget;
|
||||||
|
Game.Target = Target;
|
||||||
|
Driver.HumanSlot = Human;
|
||||||
|
Driver.Seed = seed;
|
||||||
|
Driver.StartRound = startRound;
|
||||||
|
Driver.ExtraGold = extraGold;
|
||||||
|
Driver.Speed = 1f;
|
||||||
|
Driver.Restart();
|
||||||
|
yield return Frames(3);
|
||||||
|
Hud = Game.Hud;
|
||||||
|
Assert.AreSame(Driver.Runner.Match, Hud.Match, "the HUD shows the driver's match");
|
||||||
|
Game.ResetCamera(true);
|
||||||
|
yield return Frames(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (Camera != null)
|
||||||
|
Camera.targetTexture = null;
|
||||||
|
Target?.Release();
|
||||||
|
_sceneTarget?.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerator Frames(int count)
|
||||||
|
{
|
||||||
|
for (int index = 0; index < count; index++)
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The HUD rebuilds now (Godot sync()).</summary>
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
Hud.MarkDirty();
|
||||||
|
Hud.Tick(0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HUD time passes (tooltips, feed, rates) without simulating the match.</summary>
|
||||||
|
public void Tick(float seconds, float step = .02f)
|
||||||
|
{
|
||||||
|
for (float time = 0f; time < seconds - 1e-4f; time += step)
|
||||||
|
Hud.Tick(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- pointer input (UI Toolkit events)
|
||||||
|
IPanel Panel { get { return Hud.Root.panel; } }
|
||||||
|
|
||||||
|
public void Pointer(Vector2 at, EventType type)
|
||||||
|
{
|
||||||
|
var source = new Event { type = type, mousePosition = at, button = 0, clickCount = 1 };
|
||||||
|
EventBase evt = type == EventType.MouseDown ? PointerDownEvent.GetPooled(source)
|
||||||
|
: type == EventType.MouseUp ? PointerUpEvent.GetPooled(source)
|
||||||
|
: (EventBase)PointerMoveEvent.GetPooled(source);
|
||||||
|
using (evt)
|
||||||
|
Panel.visualTree.SendEvent(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector2 Center(VisualElement element)
|
||||||
|
{
|
||||||
|
return element.worldBound.center;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MoveTo(Vector2 at) { Pointer(at, EventType.MouseMove); }
|
||||||
|
|
||||||
|
public void ClickAt(Vector2 at)
|
||||||
|
{
|
||||||
|
Pointer(at, EventType.MouseMove);
|
||||||
|
Pointer(at, EventType.MouseDown);
|
||||||
|
Pointer(at, EventType.MouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A real click on an element (pointer move, down and up at its centre).</summary>
|
||||||
|
public void Click(VisualElement element)
|
||||||
|
{
|
||||||
|
Assert.IsNotNull(element);
|
||||||
|
Assert.IsTrue(Visible(element), "click on a hidden element: " + element.name + " " + element.GetType().Name);
|
||||||
|
ClickAt(Center(element));
|
||||||
|
Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A real click on an element that may just have been rebuilt: one layout frame first (a
|
||||||
|
/// player can only click what is drawn), then pointer move, down and up, then a sync.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerator Tap(System.Func<VisualElement> find)
|
||||||
|
{
|
||||||
|
yield return null;
|
||||||
|
VisualElement element = find();
|
||||||
|
Assert.IsNotNull(element, "nothing to tap");
|
||||||
|
Assert.IsTrue(Visible(element), "tap on a hidden element: " + element.GetType().Name);
|
||||||
|
Rect bound = element.worldBound;
|
||||||
|
Assert.IsFalse(float.IsNaN(bound.width) || bound.width <= 0f, "tap on an element without layout: " + element.GetType().Name);
|
||||||
|
ClickAt(bound.center);
|
||||||
|
Sync();
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LuneTooltip Hover(VisualElement element, float wait = .35f)
|
||||||
|
{
|
||||||
|
MoveTo(Center(element));
|
||||||
|
Tick(wait);
|
||||||
|
return Hud.Tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Leave()
|
||||||
|
{
|
||||||
|
MoveTo(new Vector2(-50f, -50f));
|
||||||
|
Tick(.05f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- queries
|
||||||
|
public static bool Visible(VisualElement element)
|
||||||
|
{
|
||||||
|
for (VisualElement node = element; node != null; node = node.parent)
|
||||||
|
{
|
||||||
|
if (node.style.display == DisplayStyle.None)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return element.panel != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All visible text of the HUD (Godot texts(root)).</summary>
|
||||||
|
public string Texts(VisualElement root = null)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
root = root ?? Hud.Root;
|
||||||
|
root.Query<TextElement>().ForEach(text =>
|
||||||
|
{
|
||||||
|
if (Visible(text) && !string.IsNullOrEmpty(text.text))
|
||||||
|
parts.Add(text.text);
|
||||||
|
});
|
||||||
|
return string.Join("\n", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public T First<T>(System.Func<T, bool> where = null) where T : VisualElement
|
||||||
|
{
|
||||||
|
return Hud.Root.Query<T>().ToList().FirstOrDefault(e => Visible(e) && (where == null || where(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LuneButton ButtonWithText(string text, VisualElement root = null)
|
||||||
|
{
|
||||||
|
return (root ?? Hud.Root).Query<LuneButton>().ToList().FirstOrDefault(b => Visible(b) && b.Text == text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- match helpers (public state, like g3_views.gd)
|
||||||
|
public IEnumerator Starter()
|
||||||
|
{
|
||||||
|
yield return Tap(() => ButtonWithText("Startaufstellung"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Plays the running combat to its end through the host loop (no HUD shortcuts).</summary>
|
||||||
|
public IEnumerator FinishCombat(int maxFrames = 20000)
|
||||||
|
{
|
||||||
|
int frames = 0;
|
||||||
|
while (Runner.Current == LuneMatchRunner.Stage.Combat && frames++ < maxFrames)
|
||||||
|
{
|
||||||
|
Runner.Advance(.25);
|
||||||
|
if (frames % 20 == 0)
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
Runner.Advance(.02);
|
||||||
|
yield return Frames(2);
|
||||||
|
Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Steps the combat for some simulated seconds.</summary>
|
||||||
|
public IEnumerator Fight(double seconds)
|
||||||
|
{
|
||||||
|
double done = 0.0;
|
||||||
|
while (done < seconds && Runner.Current == LuneMatchRunner.Stage.Combat)
|
||||||
|
{
|
||||||
|
Runner.Advance(.05);
|
||||||
|
done += .05;
|
||||||
|
}
|
||||||
|
yield return Frames(2);
|
||||||
|
Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- screenshots
|
||||||
|
public void Capture(string relative)
|
||||||
|
{
|
||||||
|
LunePortraits.Shared?.Flush();
|
||||||
|
Camera.Render();
|
||||||
|
Texture2D scene = LuneHudCapture.Read(_sceneTarget);
|
||||||
|
Texture2D overlay = LuneHudCapture.Read(Target);
|
||||||
|
Texture2D image = LuneHudCapture.Compose(scene, overlay);
|
||||||
|
LuneHudCapture.Save(image, Path.Combine(Root, relative));
|
||||||
|
Object.Destroy(scene);
|
||||||
|
Object.Destroy(overlay);
|
||||||
|
Object.Destroy(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits until portraits and the HUD have caught up, then captures.</summary>
|
||||||
|
public IEnumerator Shot(string relative)
|
||||||
|
{
|
||||||
|
Sync();
|
||||||
|
LunePortraits.Shared?.Flush();
|
||||||
|
yield return Frames(3);
|
||||||
|
Capture(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d8a0a7680e2d34215a67b2b3f304f27c
|
||||||
501
Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using Luneraria.View;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// U3 acceptance of the HUD in the real game (port of tools/luneraria/verify_g3.gd, parts A–H):
|
||||||
|
/// A layout at 1152×720 … 2560×1440 and ultrawide, at several UI scales;
|
||||||
|
/// B match status (Ancients, towers, lanes, waves with two and three digits, own gold only);
|
||||||
|
/// C hero, items, talents and normal units (no item slots), lane orders with the grotto warning;
|
||||||
|
/// D attack champions (temporary), the boss grotto in every state, the chooser's lane, banners;
|
||||||
|
/// E Ready lock, bots, privacy, a dropped human seat;
|
||||||
|
/// F input: minimap, lane clicks, hotkeys, field clicks, no click-through, tooltips;
|
||||||
|
/// G events, critical alerts and reduced motion;
|
||||||
|
/// H update rates and the portrait cache.
|
||||||
|
/// Every check is counted; the totals go to tools/out/hud-verify-*.txt.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HudVerifyTests
|
||||||
|
{
|
||||||
|
readonly List<string> _failures = new List<string>();
|
||||||
|
int _checks;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_failures.Clear();
|
||||||
|
_checks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Check(bool ok, string label)
|
||||||
|
{
|
||||||
|
_checks++;
|
||||||
|
if (!ok)
|
||||||
|
_failures.Add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Done(string part)
|
||||||
|
{
|
||||||
|
Debug.Log(string.Format("LUNE_VERIFY {0}: {1} checks, {2} failed{3}", part, _checks, _failures.Count, string.Concat(_failures.Select(f => "\n FAIL " + f))));
|
||||||
|
System.IO.Directory.CreateDirectory(System.IO.Path.Combine(HudSession.Root, "tools/out"));
|
||||||
|
System.IO.File.WriteAllText(System.IO.Path.Combine(HudSession.Root, "tools/out/hud-verify-" + part + ".txt"), part + " " + _checks + " " + _failures.Count + "\n" + string.Join("\n", _failures) + "\n");
|
||||||
|
Assert.IsEmpty(_failures, string.Join("\n", _failures));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Overlap(Rect a, Rect b)
|
||||||
|
{
|
||||||
|
return a.xMin < b.xMax - .5f && b.xMin < a.xMax - .5f && a.yMin < b.yMax - .5f && b.yMin < a.yMax - .5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- A layout
|
||||||
|
static readonly (int w, int h, float[] scales)[] Sizes =
|
||||||
|
{
|
||||||
|
(1152, 720, new[] { 1f }), (1280, 720, new[] { .9f, 1f, 1.3f }), (1920, 1080, new[] { .9f, 1f, 1.15f, 1.3f }),
|
||||||
|
(2560, 1440, new[] { .9f, 1f, 1.3f }), (2560, 1080, new[] { 1f, 1.3f }),
|
||||||
|
};
|
||||||
|
|
||||||
|
void Layout(HudSession s, string label)
|
||||||
|
{
|
||||||
|
Dictionary<string, Rect> regions = s.Hud.Regions();
|
||||||
|
Rect area = s.Hud.Root.Q("lune-hud").layout;
|
||||||
|
foreach (KeyValuePair<string, Rect> region in regions)
|
||||||
|
Check(region.Value.xMin >= -.5f && region.Value.yMin >= -.5f && region.Value.xMax <= area.width + .5f && region.Value.yMax <= area.height + .5f,
|
||||||
|
label + ": " + region.Key + " inside the window " + region.Value + " in " + area.size);
|
||||||
|
string[][] pairs =
|
||||||
|
{
|
||||||
|
new[] { "top", "system" }, new[] { "top", "left" }, new[] { "top", "right" }, new[] { "left", "bottom" }, new[] { "left", "right" },
|
||||||
|
new[] { "right", "ready" }, new[] { "right", "minimap" }, new[] { "bottom", "ready" }, new[] { "bottom", "minimap" }, new[] { "ready", "minimap" },
|
||||||
|
new[] { "system", "right" }, new[] { "left", "minimap" },
|
||||||
|
};
|
||||||
|
foreach (string[] pair in pairs)
|
||||||
|
{
|
||||||
|
if (regions.TryGetValue(pair[0], out Rect a) && regions.TryGetValue(pair[1], out Rect b))
|
||||||
|
Check(!Overlap(a, b), label + ": " + pair[0] + " and " + pair[1] + " overlap " + a + " " + b);
|
||||||
|
}
|
||||||
|
Check(s.Hud.LogicalSize.x >= LuneHud.MinArea.x - 1f || s.Hud.UiScale <= .6f + 1e-4f, label + ": logical width " + s.Hud.LogicalSize);
|
||||||
|
Check(s.Hud.LogicalSize.y >= LuneHud.MinArea.y - 1f || s.Hud.UiScale <= .6f + 1e-4f, label + ": logical height " + s.Hud.LogicalSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(1800000)]
|
||||||
|
public IEnumerator A_Layout()
|
||||||
|
{
|
||||||
|
foreach (var size in Sizes)
|
||||||
|
{
|
||||||
|
foreach (float scale in size.scales)
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open(size.w, size.h, uiScale: scale);
|
||||||
|
string label = size.w + "x" + size.h + "@" + Mathf.RoundToInt(scale * 100f) + "%";
|
||||||
|
float expected = LuneHud.ScaleFor(new Vector2(size.w, size.h), scale, out _);
|
||||||
|
Check(Mathf.Abs(s.Hud.UiScale - expected) < 1e-4f, label + ": UI scale " + s.Hud.UiScale + " expected " + expected);
|
||||||
|
Check(s.Hud.UiScale <= scale + 1e-4f, label + ": scale never above the preference");
|
||||||
|
yield return s.Starter();
|
||||||
|
yield return HudSession.Frames(3);
|
||||||
|
Layout(s, label + " planning");
|
||||||
|
s.Hud.OpenHero();
|
||||||
|
yield return HudSession.Frames(2);
|
||||||
|
VisualElement modal = s.Hud.Root.Query<LuneModalPanel>().First().Panel;
|
||||||
|
Rect area = s.Hud.Root.Q("lune-hud").layout;
|
||||||
|
Rect box = s.Hud.Root.Q("lune-hud").WorldToLocal(modal.worldBound);
|
||||||
|
Check(box.xMin >= 0f && box.yMin >= 0f && box.xMax <= area.width + .5f && box.yMax <= area.height + .5f, label + ": hero window inside " + box);
|
||||||
|
s.Hud.CloseModal();
|
||||||
|
s.Runner.Ready();
|
||||||
|
yield return s.Fight(8.0);
|
||||||
|
yield return HudSession.Frames(2);
|
||||||
|
Layout(s, label + " combat");
|
||||||
|
if (Mathf.Approximately(scale, 1f) && (size.w != 1920 || size.h != 1080))
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/aufloesungen/kampf-" + size.w + "x" + size.h + ".png");
|
||||||
|
s.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Done("A");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- B status
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator B_Status()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
string text = s.Texts();
|
||||||
|
Check(text.Contains("DEIN ANCIENT · BLAU"), "own Ancient caption");
|
||||||
|
Check(text.Contains("GEGNER · ROT"), "enemy Ancient caption");
|
||||||
|
Check(text.Contains("12.000 / 12.000"), "Ancient HP with thousands separator");
|
||||||
|
Check(text.Contains("WELLE 1"), "wave 1");
|
||||||
|
Check(text.Contains("PLANUNG"), "phase badge");
|
||||||
|
foreach (int round in new[] { 21, 99, 120 })
|
||||||
|
{
|
||||||
|
s.Match.RoundNumber = round;
|
||||||
|
s.Sync();
|
||||||
|
yield return HudSession.Frames(2);
|
||||||
|
Check(s.Texts().Contains("WELLE " + round), "unlimited waves: WELLE " + round);
|
||||||
|
Rect top = s.Hud.Regions()["top"];
|
||||||
|
Check(top.width < 1100f, "top panel stays compact at wave " + round + ": " + top.width);
|
||||||
|
}
|
||||||
|
s.Match.RoundNumber = 1;
|
||||||
|
double core = s.Match.Map.BuildingDef("blau_ancient").MaxHp;
|
||||||
|
s.Match.BuildingHp["blau_ancient"] = core * .45;
|
||||||
|
s.Sync();
|
||||||
|
Check(s.Texts().Contains("BESCHÄDIGT"), "own Ancient below 50 %: BESCHÄDIGT");
|
||||||
|
s.Match.BuildingHp["blau_ancient"] = core * .25;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
text = s.Texts();
|
||||||
|
Check(text.Contains("KRITISCH"), "own Ancient below 30 %: KRITISCH");
|
||||||
|
Check(text.Contains("Dein Ancient ist kritisch: 25 % Leben"), "banner for the own critical Ancient");
|
||||||
|
Check(s.Hud.Feed.Texts.Any(t => t.StartsWith("Dein Ancient kritisch")), "event for the own critical Ancient");
|
||||||
|
s.Match.BuildingHp["rot_ancient"] = core * .2;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
Check(s.Hud.Feed.Texts.Any(t => t.StartsWith("Gegnerischer Ancient kritisch")), "event for the enemy's critical Ancient");
|
||||||
|
// A lane tower falls: indicator, threat and event.
|
||||||
|
s.Match.BuildingHp["blau_mid_outer"] = 0.0;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
LuneLaneIndicator mid = s.Hud.Lanes["mid"];
|
||||||
|
Check(!mid.Towers[1].Alive, "fallen Grenzwacht shown as ruin");
|
||||||
|
Check(mid.Level >= LuneLaneThreat.Pressure, "Mid threat after the Grenzwacht fell: " + mid.Level);
|
||||||
|
Check(s.Hud.Feed.Texts.Any(t => t.Contains("MID – Grenzwacht (Blau) zerstört")), "event for the fallen tower");
|
||||||
|
// Own gold only: another seat's gold never reaches the HUD.
|
||||||
|
s.Match.Players[4].Draft.Gold = 98765;
|
||||||
|
s.Match.Players[0].Draft.Gold = 87654;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
text = s.Texts();
|
||||||
|
Check(!text.Contains("98.765") && !text.Contains("87.654"), "other players' gold is never shown");
|
||||||
|
Check(text.Contains(LuneUiTheme.Number(s.Match.GoldOf(HudSession.Human))), "own gold shown");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/b-status.png");
|
||||||
|
s.Close();
|
||||||
|
Done("B");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- C hero, items, units
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator C_HeroItemsUnits()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
yield return s.Tap(() => s.Hud.HeroCard);
|
||||||
|
Check(s.Hud.ModalOpen, "hero card opens the hero window");
|
||||||
|
Check(s.Hud.HeroDetail.RecruitButtons.Count == 3, "three heroes to recruit");
|
||||||
|
int heroCost = LuneContent.Heroes.Values[0].Cost;
|
||||||
|
bool unaffordable = s.Match.GoldOf(HudSession.Human) < heroCost;
|
||||||
|
Check(!unaffordable || s.Hud.HeroDetail.RecruitButtons.Values.All(b => b.Disabled), "recruiting blocked without gold");
|
||||||
|
Check(!unaffordable || s.Texts().Contains("Es fehlen " + (heroCost - s.Match.GoldOf(HudSession.Human)) + " Gold."), "missing gold explained");
|
||||||
|
s.Me.Draft.Gold += 2000;
|
||||||
|
s.Sync();
|
||||||
|
string heroId = LuneContent.Heroes.Keys[0];
|
||||||
|
yield return s.Tap(() => s.Hud.HeroDetail.RecruitButtons[heroId]);
|
||||||
|
Check(s.Model.OwnHero != null && s.Model.OwnHero.HeroId == heroId, "hero recruited through the window");
|
||||||
|
Check(s.Hud.HeroDetail.Tabs != null, "hero window shows the hero tabs");
|
||||||
|
Check(s.Hud.ToastText.Length > 0, "feedback after recruiting");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-held.png");
|
||||||
|
// Items: buy one, the card turns unique; slots fill up; higher items need levels.
|
||||||
|
yield return s.Tap(() => s.Hud.HeroDetail.Tabs.Tabs[LuneHeroDetail.TabItems]);
|
||||||
|
Dictionary<string, LuneItemSlot> cards = s.Hud.HeroDetail.ItemCards;
|
||||||
|
Check(cards.Count == LuneContent.Items.Count, "item shop lists every item");
|
||||||
|
string first = cards.First(c => c.Value.Current == LuneItemSlot.State.Available).Key;
|
||||||
|
yield return s.Tap(() => cards[first]);
|
||||||
|
Check(s.Model.OwnHero.Items.Contains(first), "item bought by click");
|
||||||
|
Check(s.Hud.HeroDetail.ItemCards[first].Current == LuneItemSlot.State.Unique, "carried item is marked GETRAGEN");
|
||||||
|
ItemSpec gated = LuneContent.Items.Values.FirstOrDefault(i => i.MinLevel > 1);
|
||||||
|
if (gated != null)
|
||||||
|
Check(s.Hud.HeroDetail.ItemCards[gated.Id].Current == LuneItemSlot.State.Incompatible, "item above the hero level: STUFE FEHLT");
|
||||||
|
foreach (string id in s.Hud.HeroDetail.ItemCards.Where(c => c.Value.Current == LuneItemSlot.State.Available).Select(c => c.Key).ToList())
|
||||||
|
{
|
||||||
|
if (s.Model.OwnHero.Items.Count >= LuneContent.ItemSlots)
|
||||||
|
break;
|
||||||
|
yield return s.Tap(() => s.Hud.HeroDetail.ItemCards[id]);
|
||||||
|
}
|
||||||
|
if (s.Model.OwnHero.Items.Count >= LuneContent.ItemSlots)
|
||||||
|
Check(s.Hud.HeroDetail.ItemCards.Values.Where(c => !s.Model.OwnHero.Items.Contains(c.ItemId)).All(c => c.Current == LuneItemSlot.State.Full || c.Current == LuneItemSlot.State.Incompatible),
|
||||||
|
"all slots used: other items PLÄTZE VOLL");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-items.png");
|
||||||
|
// Talents: locked at level 1, choosable at level 3.
|
||||||
|
yield return s.Tap(() => s.Hud.HeroDetail.Tabs.Tabs[LuneHeroDetail.TabTalents]);
|
||||||
|
Check(s.Hud.HeroDetail.TalentCards.All(c => c.Disabled), "no talent at level 1");
|
||||||
|
s.Me.Draft.Hero.Xp = LuneContent.LevelXp[LuneContent.TalentLevels[0] - 1];
|
||||||
|
s.Sync();
|
||||||
|
LuneButton option = s.Hud.HeroDetail.TalentCards.First(c => !c.Disabled);
|
||||||
|
yield return s.Tap(() => option);
|
||||||
|
Check(s.Model.OwnHero.Talents.Count == 1, "talent chosen by click");
|
||||||
|
s.Hud.CloseModal();
|
||||||
|
Check(!s.Hud.ModalOpen, "window closes");
|
||||||
|
// Normal units: details, lane order with the grotto warning, no item slots, return.
|
||||||
|
yield return s.Starter();
|
||||||
|
s.Sync();
|
||||||
|
Check(s.Model.OwnRoster.Count == 3, "starter lineup bought");
|
||||||
|
LuneArmyChip chip = s.Hud.Chips.Values.First();
|
||||||
|
yield return s.Tap(() => chip);
|
||||||
|
Check(s.Hud.SelectedUid == chip.Uid, "chip selects the unit");
|
||||||
|
Check(HudSession.Visible(s.Hud.UnitDetail), "unit details shown");
|
||||||
|
Check(s.Hud.UnitDetail.Query<LuneItemSlot>().ToList().Count == 0, "normal units have no item slots");
|
||||||
|
yield return s.Tap(() => s.Hud.UnitDetail.LaneButtons[LuneMatch.Grotto]);
|
||||||
|
Check(s.Model.OwnRoster.First(e => e.Uid == chip.Uid).Lane == LuneMatch.Grotto, "lane order GROTTE by click");
|
||||||
|
Check(s.Texts().Contains(LuneUnitDetail.GrottoWarning), "grotto warning shown");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/c-einheit.png");
|
||||||
|
int before = s.Model.OwnRoster.Count;
|
||||||
|
yield return s.Tap(() => s.Hud.UnitDetail.SellButton);
|
||||||
|
Check(s.Model.OwnRoster.Count == before - 1, "return by click");
|
||||||
|
Check(!HudSession.Visible(s.Hud.UnitDetail), "details close when the unit is gone");
|
||||||
|
s.Close();
|
||||||
|
Done("C");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- D champions and boss
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator D_ChampionsAndBoss()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
s.Me.Draft.Gold += 3000;
|
||||||
|
s.Hud.OpenChampions();
|
||||||
|
s.Sync();
|
||||||
|
LuneChampionPanel panel = s.Hud.ChampionPanel;
|
||||||
|
Check(HudSession.Visible(panel), "champion tab");
|
||||||
|
yield return s.Tap(() => panel.LaneButtons["top"]);
|
||||||
|
Check(panel.TargetLane == "top", "target lane TOP");
|
||||||
|
string championId = panel.Cards.Keys.First();
|
||||||
|
yield return s.Tap(() => panel.Cards[championId]);
|
||||||
|
Check(s.Me.Draft.NewBookings.Count == 1 && s.Me.Draft.NewBookings[0].Lane == "top", "champion booked on TOP by click");
|
||||||
|
Check(s.Texts().Contains("Einsatz in Welle 2 – TOP"), "booking listed with wave and lane");
|
||||||
|
yield return s.Tap(() => panel.Cards[championId]);
|
||||||
|
Check(panel.Cards.Values.All(c => c.Disabled), "at most " + LuneContent.MaxBookings + " bookings per round");
|
||||||
|
Check(panel.Cards.Values.First().Reason.Contains("Höchstens"), "the limit is explained");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/d-champions.png");
|
||||||
|
LuneButton cancel = s.ButtonWithText("×", panel);
|
||||||
|
yield return s.Tap(() => cancel);
|
||||||
|
Check(s.Me.Draft.NewBookings.Count == 1, "booking cancelled by click");
|
||||||
|
s.Hud.OpenTroops();
|
||||||
|
s.Sync();
|
||||||
|
// Boss states from public state.
|
||||||
|
LuneBossIndicator boss = s.Hud.BossCard;
|
||||||
|
Check(boss.BadgeText == "BOSS VERFÜGBAR", "available: " + boss.BadgeText);
|
||||||
|
s.Match.RoundNumber = 3;
|
||||||
|
s.Match.Boss.NextAvailableRound = 6;
|
||||||
|
s.Sync();
|
||||||
|
Check(boss.BadgeText == "COOLDOWN – 3 RUNDEN", "cooldown: " + boss.BadgeText);
|
||||||
|
// The grotto was won in round 2; the boss attacks in round 3, its chooser picks the lane now.
|
||||||
|
s.Match.Boss.Claim = new BossClaim { Team = 0, WonRound = 2, DeployRound = 3, ChooserSlot = HudSession.Human, Lane = "mid" };
|
||||||
|
s.Sync();
|
||||||
|
Check(boss.BadgeText == "EINSATZ NÄCHSTE WELLE", "own boss next wave: " + boss.BadgeText);
|
||||||
|
Check(HudSession.Visible(boss.LaneButtons["bot"]), "the chooser picks the lane in the card");
|
||||||
|
yield return s.Tap(() => boss.LaneButtons["bot"]);
|
||||||
|
Check(s.Me.Draft.BossLane == "bot", "boss lane chosen by click (private until combat)");
|
||||||
|
Check(s.Texts().Contains("Deine SCHLUNDKRONE greift in Welle 3 auf BOTTOM an"), "banner for the own boss");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/d-boss-eigen.png");
|
||||||
|
s.Match.Boss.Claim = new BossClaim { Team = 1, WonRound = 2, DeployRound = 3, ChooserSlot = 4, Lane = "top" };
|
||||||
|
s.Sync();
|
||||||
|
string text = s.Texts();
|
||||||
|
Check(text.Contains("SCHLUNDKRONE (Rot) greift in Welle 3 auf TOP an"), "banner for the enemy boss");
|
||||||
|
Check(text.Contains("ANGEKÜNDIGT") && text.Contains("GEGEN EUCH"), "announced in the wave preview");
|
||||||
|
Check(s.Hud.Lanes["top"].Level >= LuneLaneThreat.Pressure, "enemy boss raises the lane threat");
|
||||||
|
s.Close();
|
||||||
|
Done("D");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- E ready, bots, privacy
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator E_ReadyBotsPrivacy()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
string team = s.Texts();
|
||||||
|
Check(team.Contains("BOT") && team.Contains("PLANT"), "team panel: bots and the planning human");
|
||||||
|
yield return s.Starter();
|
||||||
|
s.Game.Key(KeyCode.Space);
|
||||||
|
s.Sync();
|
||||||
|
yield return HudSession.Frames(1);
|
||||||
|
Check(s.Runner.Current == LuneMatchRunner.Stage.Combat, "Space reports ready; with every bot ready the combat starts");
|
||||||
|
yield return s.FinishCombat();
|
||||||
|
Check(s.Runner.Current == LuneMatchRunner.Stage.Planning, "back in planning after the wave");
|
||||||
|
s.Game.Key(KeyCode.Space);
|
||||||
|
Check(!s.Me.Ready, "Bereit is blocked for a second after a combat (stale clicks)");
|
||||||
|
yield return new WaitForSecondsRealtime(1.05f);
|
||||||
|
s.Sync();
|
||||||
|
int roster = s.Model.OwnRoster.Count;
|
||||||
|
s.Match.SetReady(HudSession.Human, true);
|
||||||
|
s.Sync();
|
||||||
|
Check(s.Model.Locked, "plan locked");
|
||||||
|
Check(s.Hud.ShopCards.Values.All(c => c.Disabled), "cards locked after Ready");
|
||||||
|
s.Game.Key(KeyCode.Alpha1);
|
||||||
|
Check(s.Model.OwnRoster.Count == roster, "hotkey buys nothing after Ready");
|
||||||
|
Check(s.Hud.ToastText == "Bereit gemeldet – die Planung ist verbindlich.", "hotkey explains the lock");
|
||||||
|
Check(s.Texts().Contains("PLANUNG · BEREIT"), "phase badge PLANUNG · BEREIT");
|
||||||
|
// A human seat of the own team drops: a bot plays it on, marked as such.
|
||||||
|
s.Match.Players[0].IsHuman = true;
|
||||||
|
s.Sync();
|
||||||
|
s.Match.Players[0].IsHuman = false;
|
||||||
|
s.Sync();
|
||||||
|
Check(s.Texts().Contains("BOT ÜBERNIMMT"), "dropped human seat: BOT ÜBERNIMMT");
|
||||||
|
// Privacy: the same public picture for every seat, other drafts invisible.
|
||||||
|
string digest = s.Hud.PublicDigest();
|
||||||
|
for (int seat = 0; seat < 6; seat++)
|
||||||
|
Check(new LuneHudModel(s.Match, seat).PublicDigest() == digest, "seat " + seat + " sees the same public state");
|
||||||
|
s.Match.Players[4].Draft.Gold = 77777;
|
||||||
|
s.Sync();
|
||||||
|
Check(!s.Texts().Contains("77.777"), "an enemy draft's gold never appears");
|
||||||
|
s.Close();
|
||||||
|
Done("E");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- F input
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator F_Input()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
// Minimap: a click moves the camera to that map point.
|
||||||
|
LuneMinimap map = s.Hud.Minimap;
|
||||||
|
Vector2 local = new Vector2(map.layout.width * .75f, map.layout.height * .25f);
|
||||||
|
Vec2 expected = map.ToWorld(local);
|
||||||
|
s.ClickAt(map.LocalToWorld(local));
|
||||||
|
yield return HudSession.Frames(1);
|
||||||
|
yield return new WaitForSecondsRealtime(.6f);
|
||||||
|
Check(s.Rig.Focus.DistanceTo(expected) < 3.0, "minimap click moves the camera to " + expected + ", focus " + s.Rig.Focus);
|
||||||
|
// Lane click and hotkeys.
|
||||||
|
yield return s.Tap(() => s.Hud.Lanes["bot"]);
|
||||||
|
yield return new WaitForSecondsRealtime(.6f);
|
||||||
|
LanePath path = s.Match.Map.LanePaths["bot"];
|
||||||
|
Vec2 lanePoint = s.Match.Map.LanePoint(0, "bot", path.Length * .2);
|
||||||
|
Check(s.Rig.Focus.DistanceTo(lanePoint) < 3.0, "lane click focuses the own BOTTOM deployment " + s.Rig.Focus + " vs " + lanePoint);
|
||||||
|
s.Game.Key(KeyCode.F3);
|
||||||
|
yield return new WaitForSecondsRealtime(.6f);
|
||||||
|
Check(s.Rig.Focus.DistanceTo(s.Match.Map.LanePoint(0, "mid", s.Match.Map.LanePaths["mid"].Length * .2)) < 3.0, "F3 focuses Mid");
|
||||||
|
s.Game.Key(KeyCode.G);
|
||||||
|
yield return new WaitForSecondsRealtime(.6f);
|
||||||
|
Check(s.Rig.Focus.DistanceTo(s.Match.Map.GrottoCenter()) < 3.0, "G focuses the grotto");
|
||||||
|
s.Game.Key(KeyCode.B);
|
||||||
|
yield return new WaitForSecondsRealtime(.6f);
|
||||||
|
Check(s.Rig.Focus.DistanceTo(s.Match.Map.CorePosition(0)) < 3.0, "B focuses the own Ancient");
|
||||||
|
s.Game.Key(KeyCode.H);
|
||||||
|
Check(s.Hud.ToastText.StartsWith("Kein Held im Feld"), "H without a hero explains why");
|
||||||
|
// Hotkeys 1–5 buy.
|
||||||
|
int roster = s.Model.OwnRoster.Count;
|
||||||
|
s.Game.Key(KeyCode.Alpha2);
|
||||||
|
Check(s.Model.OwnRoster.Count == roster + 1, "hotkey 2 buys the second card's troop");
|
||||||
|
// No click-through: HUD panels take the pointer, the open field does not.
|
||||||
|
Vector2 topPixel = PanelToPixel(s, HudSession.Center(s.Hud.Root.Q("lune-hud").Query<LunePanel>().First()));
|
||||||
|
Check(s.Hud.IsPointerOverHud(topPixel), "the HUD takes clicks on its panels");
|
||||||
|
Check(!s.Hud.IsPointerOverHud(new Vector2(s.Width * .5f, s.Height * .5f)), "the open field is free");
|
||||||
|
// Field click on the own preview unit selects it.
|
||||||
|
s.Game.FocusLane("mid", true);
|
||||||
|
yield return HudSession.Frames(4);
|
||||||
|
LuneUnitView figure = s.View.Units.FirstOrDefault(v => v.Unit != null && v.Unit.OwnerSlot == HudSession.Human && v.Unit.Kind == "troop");
|
||||||
|
Check(figure != null, "planning shows the own army in formation");
|
||||||
|
if (figure != null)
|
||||||
|
{
|
||||||
|
Vector3 screen = s.Camera.WorldToScreenPoint(figure.AimPoint);
|
||||||
|
s.Game.ClickField(new Vector2(screen.x, screen.y));
|
||||||
|
Check(s.Hud.SelectedUid == figure.Unit.PersistentUid, "field click selects the own unit");
|
||||||
|
s.Game.Key(KeyCode.Escape);
|
||||||
|
Check(s.Hud.SelectedUid.Length == 0, "Esc clears the selection");
|
||||||
|
}
|
||||||
|
// Tooltips: delay, content, gone on leave.
|
||||||
|
LuneUnitCard card = s.Hud.ShopCards.Values.First();
|
||||||
|
LuneTooltip tip = s.Hover(card);
|
||||||
|
Check(tip.Open && tip.Shown.Contains(DataCatalog.GetUnit(card.TypeId).DisplayName), "card tooltip with the unit: " + tip.Shown);
|
||||||
|
s.Leave();
|
||||||
|
Check(!tip.Open, "tooltip hides when the pointer leaves");
|
||||||
|
tip = s.Hover(s.Hud.Lanes["mid"]);
|
||||||
|
Check(tip.Open && tip.Shown.Contains("MID-LANE"), "lane tooltip");
|
||||||
|
yield return s.Shot("docs/port/screenshots/u3/pruefung/f-tooltip.png");
|
||||||
|
s.Leave();
|
||||||
|
// Options: F1 opens, a running combat pauses, Esc closes.
|
||||||
|
s.Runner.Ready();
|
||||||
|
yield return s.Fight(2.0);
|
||||||
|
s.Game.Key(KeyCode.F1);
|
||||||
|
Check(s.Hud.Settings.Open, "F1 opens the options");
|
||||||
|
Check(s.Driver.Paused, "options pause the combat offline");
|
||||||
|
s.Game.Key(KeyCode.Escape);
|
||||||
|
Check(!s.Hud.Settings.Open && !s.Driver.Paused, "Esc closes the options and resumes");
|
||||||
|
s.Close();
|
||||||
|
Done("F");
|
||||||
|
}
|
||||||
|
|
||||||
|
static Vector2 PanelToPixel(HudSession s, Vector2 panel)
|
||||||
|
{
|
||||||
|
float scale = Mathf.Min(s.Width / (float)LuneHud.Reference.x, s.Height / (float)LuneHud.Reference.y) * s.Hud.UiScale;
|
||||||
|
Vector2 pixel = panel * scale;
|
||||||
|
return new Vector2(pixel.x, s.Height - pixel.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- G events and reduced motion
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator G_EventsAndMotion()
|
||||||
|
{
|
||||||
|
foreach (bool reduced in new[] { false, true })
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open(reducedMotion: reduced);
|
||||||
|
for (int index = 0; index < 7; index++)
|
||||||
|
s.Hud.Feed.Push("Ereignis " + index);
|
||||||
|
Check(s.Hud.Feed.Texts.Count == LuneEventFeed.Limit, "event feed holds at most five entries");
|
||||||
|
Check(s.Hud.Feed.Texts[0] == "Ereignis 6", "newest entry on top");
|
||||||
|
s.Tick(LuneEventFeed.Lifetime + .5f, .1f);
|
||||||
|
Check(s.Hud.Feed.Texts.Count == 0, "entries fade after their lifetime");
|
||||||
|
double max = s.Match.Map.BuildingDef("rot_ancient").MaxHp;
|
||||||
|
s.Match.BuildingHp["rot_ancient"] = max * .2;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
var lanes = s.Hud.Lanes["mid"];
|
||||||
|
s.Match.BuildingHp["blau_mid_outer"] = 0.0;
|
||||||
|
s.Match.BuildingHp["blau_mid_inner"] = 0.0;
|
||||||
|
s.Sync();
|
||||||
|
s.Tick(.3f);
|
||||||
|
Check(lanes.Level == LuneLaneThreat.Critical, "both Mid towers fell: KRITISCH");
|
||||||
|
Check(s.Hud.Feed.Texts.Count >= 2, "tower falls reported");
|
||||||
|
Check(lanes.Pulsing == !reduced, (reduced ? "reduced motion: no pulse" : "critical lane pulses") + " on the threat badge");
|
||||||
|
Check(s.Hud.AncientPulsing(1) == !reduced, (reduced ? "reduced motion: no pulse" : "critical Ancient pulses") + " on the Ancient bar");
|
||||||
|
s.Close();
|
||||||
|
}
|
||||||
|
Done("G");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- H rates and portraits
|
||||||
|
[UnityTest]
|
||||||
|
[Timeout(900000)]
|
||||||
|
public IEnumerator H_RatesAndPortraits()
|
||||||
|
{
|
||||||
|
var s = new HudSession();
|
||||||
|
yield return s.Open();
|
||||||
|
int before = s.Hud.Syncs;
|
||||||
|
s.Tick(2f, .01f);
|
||||||
|
int planning = s.Hud.Syncs - before;
|
||||||
|
Check(planning >= 18 && planning <= 22, "planning: 10 syncs per second (" + planning + " in 2 s)");
|
||||||
|
s.Runner.Ready();
|
||||||
|
s.Hud.Tick(.01f);
|
||||||
|
before = s.Hud.Syncs;
|
||||||
|
s.Tick(2f, .01f);
|
||||||
|
int combat = s.Hud.Syncs - before;
|
||||||
|
Check(combat >= 9 && combat <= 11, "combat: 5 syncs per second (" + combat + " in 2 s)");
|
||||||
|
Check(s.Hud.SyncMs < 4.0, "one HUD sync costs " + s.Hud.SyncMs.ToString("0.000") + " ms");
|
||||||
|
LunePortraits cache = LunePortraits.Shared;
|
||||||
|
cache.Portrait("g01");
|
||||||
|
cache.Flush();
|
||||||
|
int rendered = cache.Rendered;
|
||||||
|
Texture first = cache.Portrait("g01");
|
||||||
|
cache.Flush();
|
||||||
|
Texture again = cache.Portrait("g01");
|
||||||
|
Check(first != null && first == again, "portrait cached");
|
||||||
|
Check(cache.Rendered == rendered, "each portrait is rendered once");
|
||||||
|
Debug.Log("LUNE_VERIFY rates planning " + planning + "/2s combat " + combat + "/2s sync " + s.Hud.SyncMs.ToString("0.000") + " ms portraits " + cache.Rendered);
|
||||||
|
s.Close();
|
||||||
|
Done("H");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 65fa379ef7dfa4edd9c70ca58905e9a5
|
||||||
|
|
@ -33,6 +33,7 @@ namespace Luneraria.View.Tests
|
||||||
Application.targetFrameRate = rates[index];
|
Application.targetFrameRate = rates[index];
|
||||||
yield return PlayModeSupport.Load();
|
yield return PlayModeSupport.Load();
|
||||||
var driver = Object.FindAnyObjectByType<LuneMatchDriver>();
|
var driver = Object.FindAnyObjectByType<LuneMatchDriver>();
|
||||||
|
driver.HumanSlot = -1;
|
||||||
driver.Seed = 2;
|
driver.Seed = 2;
|
||||||
driver.StartRound = 1;
|
driver.StartRound = 1;
|
||||||
driver.ExtraGold = 0;
|
driver.ExtraGold = 0;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ namespace Luneraria.View.Tests
|
||||||
var rig = UnityEngine.Object.FindAnyObjectByType<LuneCameraRig>();
|
var rig = UnityEngine.Object.FindAnyObjectByType<LuneCameraRig>();
|
||||||
Camera camera = Camera.main;
|
Camera camera = Camera.main;
|
||||||
rig.Interactive = false;
|
rig.Interactive = false;
|
||||||
|
driver.HumanSlot = -1;
|
||||||
driver.Seed = seed;
|
driver.Seed = seed;
|
||||||
driver.StartRound = startRound;
|
driver.StartRound = startRound;
|
||||||
driver.ExtraGold = extraGold;
|
driver.ExtraGold = extraGold;
|
||||||
|
|
|
||||||
8
Assets/Luneraria/UI/Game.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 06ba2b7d22d09431fb67ab6b1c9b5602
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
537
Assets/Luneraria/UI/Game/LuneGame.cs
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using Luneraria.View;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One Champions of Luneraria match on screen with the HUD (port of the HUD side of
|
||||||
|
/// scripts/luneraria/lune_game.gd): the human player's commands go through the same command
|
||||||
|
/// interface as the bots (LuneMatch.Submit); Ready, tempo and pause go to the host loop; HUD
|
||||||
|
/// clicks and hotkeys move the camera; a left click in the field selects an own unit. During
|
||||||
|
/// planning the own draft army stands in its formation and the own deployment areas light up.
|
||||||
|
/// Offline, planning waits for the human; pause and tempo are local. Nothing here decides a rule.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneGame : MonoBehaviour
|
||||||
|
{
|
||||||
|
public LuneMatchDriver Driver;
|
||||||
|
public LuneMatchView View;
|
||||||
|
public LuneCameraRig Rig;
|
||||||
|
public LuneUiAssets UiAssets;
|
||||||
|
/// <summary>The human seat (Godot default 1: Blau Mid). -1 makes the match a bot match watched from seat 0.</summary>
|
||||||
|
public int HumanSlot = 1;
|
||||||
|
/// <summary>Optional render target for tests and captures (null: the screen).</summary>
|
||||||
|
public RenderTexture Target;
|
||||||
|
|
||||||
|
public LuneHud Hud { get; private set; }
|
||||||
|
public LuneMatch Match { get { return Driver.Runner.Match; } }
|
||||||
|
/// <summary>The human seat of the running match (-1: a bot match, e.g. tests and the benchmark).</summary>
|
||||||
|
int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } }
|
||||||
|
bool Human { get { return Seat >= 0; } }
|
||||||
|
/// <summary>Whose view the HUD shows: the human seat, or in a bot match seat max(0, HumanSlot) as in Godot.</summary>
|
||||||
|
int Viewer { get { return Human ? Seat : Math.Max(0, HumanSlot); } }
|
||||||
|
|
||||||
|
Camera _camera;
|
||||||
|
LuneMatchRunner _runner;
|
||||||
|
LuneMatchRunner.Stage _lastStage = (LuneMatchRunner.Stage)(-1);
|
||||||
|
int _laneCycle;
|
||||||
|
bool _pausedBySettings;
|
||||||
|
VisualElement _leave;
|
||||||
|
readonly List<(Renderer renderer, int team, string lane)> _pads = new List<(Renderer, int, string)>();
|
||||||
|
MaterialPropertyBlock _block;
|
||||||
|
|
||||||
|
void Start()
|
||||||
|
{
|
||||||
|
_camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main;
|
||||||
|
// The benchmark keeps its bot match; otherwise the configured seat plays.
|
||||||
|
if (!LuneBenchmark.Running && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot))
|
||||||
|
{
|
||||||
|
Driver.HumanSlot = HumanSlot;
|
||||||
|
Driver.Restart();
|
||||||
|
}
|
||||||
|
FindPads();
|
||||||
|
Hud = gameObject.GetComponent<LuneHud>() ?? gameObject.AddComponent<LuneHud>();
|
||||||
|
Bind();
|
||||||
|
LuneSettingsPanel.Apply();
|
||||||
|
if (Rig != null)
|
||||||
|
{
|
||||||
|
Rig.PointerBlocked = () => Hud != null && Hud.IsPointerOverHud(Input.mousePosition);
|
||||||
|
Rig.KeysBlocked = () => Hud != null && Hud.Settings.Open;
|
||||||
|
Rig.ResetRequested = () => ResetCamera();
|
||||||
|
}
|
||||||
|
ResetCamera(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(Re)builds the HUD for the driver's current match and wires its events.</summary>
|
||||||
|
void Bind()
|
||||||
|
{
|
||||||
|
_runner = Driver.Runner;
|
||||||
|
_lastStage = (LuneMatchRunner.Stage)(-1);
|
||||||
|
Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target);
|
||||||
|
Hud.CommandRequested += command => Feedback(Send(command));
|
||||||
|
Hud.UndoRequested += () => Feedback(Human ? Match.Undo(Seat) : new CommandResult(false, "Kein eigener Platz."));
|
||||||
|
Hud.ReadyRequested += OnReady;
|
||||||
|
Hud.StarterRequested += Starter;
|
||||||
|
Hud.SpeedRequested += CycleSpeed;
|
||||||
|
Hud.PauseRequested += TogglePause;
|
||||||
|
Hud.LaneFocusRequested += lane => FocusLane(lane);
|
||||||
|
Hud.MapFocusRequested += point => Rig?.LookAt(point);
|
||||||
|
Hud.CameraRequested += FocusTarget;
|
||||||
|
Hud.MenuRequested += AskLeave;
|
||||||
|
Hud.RematchRequested += Rematch;
|
||||||
|
Hud.SettingsRequested += () => Hud.Settings.Show();
|
||||||
|
Hud.Settings.Opened += () =>
|
||||||
|
{
|
||||||
|
// A running combat pauses offline while the options are open (lune_settings.gd).
|
||||||
|
_pausedBySettings = _runner.Current == LuneMatchRunner.Stage.Combat && !Driver.Paused;
|
||||||
|
if (_pausedBySettings)
|
||||||
|
Driver.Paused = true;
|
||||||
|
};
|
||||||
|
Hud.Settings.Closed += () =>
|
||||||
|
{
|
||||||
|
if (_pausedBySettings)
|
||||||
|
Driver.Paused = false;
|
||||||
|
_pausedBySettings = false;
|
||||||
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
|
};
|
||||||
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
|
BuildLeaveDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandResult Send(Command command)
|
||||||
|
{
|
||||||
|
if (!Human)
|
||||||
|
return new CommandResult(false, "Zuschauer können nicht planen.");
|
||||||
|
return Match.Submit(Seat, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Feedback(CommandResult result)
|
||||||
|
{
|
||||||
|
Hud.Notify(result.Message, !result.Ok);
|
||||||
|
RebuildPreview();
|
||||||
|
Hud.MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Starter()
|
||||||
|
{
|
||||||
|
List<string> ids = Match.BaseIds(Match.Players[Viewer].Bund);
|
||||||
|
foreach (int index in new[] { 1, 0, 2 })
|
||||||
|
{
|
||||||
|
CommandResult result = Send(Command.Buy(ids[index]));
|
||||||
|
if (!result.Ok)
|
||||||
|
{
|
||||||
|
Feedback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Feedback(new CommandResult(true, "Startaufstellung: Front, Nahkampf und Fernkampf gekauft."));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnReady()
|
||||||
|
{
|
||||||
|
if (Match.Phase != Phase.Planning || !Human)
|
||||||
|
return;
|
||||||
|
_runner.Ready();
|
||||||
|
Hud.MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CycleSpeed()
|
||||||
|
{
|
||||||
|
Driver.CycleSpeed();
|
||||||
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TogglePause()
|
||||||
|
{
|
||||||
|
if (Driver.TogglePause())
|
||||||
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Rematch()
|
||||||
|
{
|
||||||
|
Driver.Seed += 1;
|
||||||
|
Driver.Restart();
|
||||||
|
View.Preview = null;
|
||||||
|
Bind();
|
||||||
|
ResetCamera(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- camera targets
|
||||||
|
int OwnTeam { get { return Match.Players[Viewer].Team; } }
|
||||||
|
|
||||||
|
public void FocusLane(string lane, bool instant = false)
|
||||||
|
{
|
||||||
|
if (Rig == null)
|
||||||
|
return;
|
||||||
|
LanePath path = Match.Map.LanePaths[lane];
|
||||||
|
// Planning looks at the own deployment and inner tower, combat at the contested middle.
|
||||||
|
double fraction = Match.Phase == Phase.Planning ? .2 : .42;
|
||||||
|
Rig.LookAt(Match.Map.LanePoint(OwnTeam, lane, path.Length * fraction), 24f, instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FocusTarget(string target)
|
||||||
|
{
|
||||||
|
if (Rig == null)
|
||||||
|
return;
|
||||||
|
switch (target)
|
||||||
|
{
|
||||||
|
case "grotto":
|
||||||
|
Rig.LookAt(Match.Map.GrottoCenter(), 22f);
|
||||||
|
break;
|
||||||
|
case "base":
|
||||||
|
Rig.LookAt(Match.Map.CorePosition(OwnTeam), 26f);
|
||||||
|
break;
|
||||||
|
case "enemy_base":
|
||||||
|
Rig.LookAt(Match.Map.CorePosition(1 - OwnTeam), 26f);
|
||||||
|
break;
|
||||||
|
case "hero":
|
||||||
|
FocusHero();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (Array.IndexOf(LuneMap.Lanes, target) >= 0)
|
||||||
|
FocusLane(target);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The own hero: its planning preview or, in combat, the live unit.</summary>
|
||||||
|
LuneUnit OwnHeroUnit()
|
||||||
|
{
|
||||||
|
if (!Human)
|
||||||
|
return null;
|
||||||
|
LuneCombat source = Match.Phase == Phase.Combat ? Match.Combat : View.Preview;
|
||||||
|
return source?.Units.FirstOrDefault(u => u.Kind == "hero" && u.OwnerSlot == Seat && u.Alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool FocusHero()
|
||||||
|
{
|
||||||
|
LuneUnit unit = OwnHeroUnit();
|
||||||
|
if (unit == null)
|
||||||
|
{
|
||||||
|
Hud.Notify("Kein Held im Feld – im Reiter „Held“ anwerben.", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Rig?.LookAt(unit.Position, 16f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetCamera(bool instant = false)
|
||||||
|
{
|
||||||
|
if (Rig == null)
|
||||||
|
return;
|
||||||
|
string lane = Match.Players[Viewer].Lane;
|
||||||
|
Rig.ResetView(Match.Map.LanePoint(OwnTeam, lane, Match.Map.LanePaths[lane].Length * .2), instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- planning preview and deployment areas
|
||||||
|
void FindPads()
|
||||||
|
{
|
||||||
|
_pads.Clear();
|
||||||
|
foreach (Renderer renderer in FindObjectsByType<Renderer>(FindObjectsSortMode.None))
|
||||||
|
{
|
||||||
|
string name = renderer.gameObject.name;
|
||||||
|
if (!name.StartsWith("Deployment_", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
string[] parts = name.Split('_');
|
||||||
|
if (parts.Length >= 3 && int.TryParse(parts[1], out int team))
|
||||||
|
_pads.Add((renderer, team, parts[2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Planning shows the own formation areas; lanes that hold own units light up (Godot show_deployment).</summary>
|
||||||
|
void UpdateDeployment()
|
||||||
|
{
|
||||||
|
bool planning = Match.Phase == Phase.Planning && Human;
|
||||||
|
var used = new Dictionary<string, int>();
|
||||||
|
if (Human)
|
||||||
|
{
|
||||||
|
Draft draft = Match.Players[Seat].Draft;
|
||||||
|
foreach (RosterEntry entry in draft.Roster)
|
||||||
|
used[entry.Lane] = used.GetValueOrDefault(entry.Lane, 0) + 1;
|
||||||
|
if (draft.Hero != null)
|
||||||
|
used[draft.Hero.Lane] = used.GetValueOrDefault(draft.Hero.Lane, 0) + 1;
|
||||||
|
}
|
||||||
|
_block ??= new MaterialPropertyBlock();
|
||||||
|
foreach (var pad in _pads)
|
||||||
|
{
|
||||||
|
if (!Human)
|
||||||
|
continue;
|
||||||
|
bool visible = planning && pad.team == OwnTeam;
|
||||||
|
if (pad.renderer.enabled != visible)
|
||||||
|
pad.renderer.enabled = visible;
|
||||||
|
if (!visible)
|
||||||
|
continue;
|
||||||
|
pad.renderer.GetPropertyBlock(_block);
|
||||||
|
_block.SetFloat("_Highlight", used.GetValueOrDefault(pad.lane, 0) > 0 ? 1f : 0f);
|
||||||
|
pad.renderer.SetPropertyBlock(_block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The human player's current draft army in its formation (Godot _rebuild_preview).</summary>
|
||||||
|
void RebuildPreview()
|
||||||
|
{
|
||||||
|
UpdateDeployment();
|
||||||
|
if (Match.Phase != Phase.Planning || !Human)
|
||||||
|
return;
|
||||||
|
var preview = new LuneCombat(Match.Map, Match.Balance, Match.RoundNumber);
|
||||||
|
Player player = Match.Players[Seat];
|
||||||
|
var perLane = new Dictionary<string, int>();
|
||||||
|
var entries = new List<(UnitDef def, string lane, string uid, string kind)>();
|
||||||
|
foreach (RosterEntry entry in player.Draft.Roster)
|
||||||
|
entries.Add((DataCatalog.GetUnit(entry.TypeId), entry.Lane, entry.Uid, "troop"));
|
||||||
|
HeroState hero = player.Draft.Hero;
|
||||||
|
if (hero != null)
|
||||||
|
entries.Add((LuneContent.HeroDefinition(hero), hero.Lane, hero.Uid, "hero"));
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
int index = perLane.GetValueOrDefault(entry.lane, 0);
|
||||||
|
perLane[entry.lane] = index + 1;
|
||||||
|
LuneUnit unit;
|
||||||
|
if (entry.lane == LuneMatch.Grotto)
|
||||||
|
{
|
||||||
|
// Grotto orders preview at the team's grotto entrance.
|
||||||
|
Vec2 at = Match.Map.GrottoEntry(player.Team) + new Vec2(index % 3 - 1.0, index / 3) * 1.1;
|
||||||
|
unit = preview.SpawnAt(entry.kind, player.Team, "grotto", entry.def, at, Seat, entry.uid);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unit = preview.Spawn(entry.kind, player.Team, entry.lane, entry.def, Math.Max(.4, 7.2 - (index / 4) * 1.5), (index % 4 - 1.5) * 1.2, Seat, entry.uid);
|
||||||
|
}
|
||||||
|
unit.PreviousPosition = unit.Position;
|
||||||
|
}
|
||||||
|
View.Preview = preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- frame
|
||||||
|
void Update()
|
||||||
|
{
|
||||||
|
if (Driver.Runner != _runner)
|
||||||
|
{
|
||||||
|
// Somebody restarted the driver (tests, rematch from elsewhere): follow it.
|
||||||
|
View.Preview = null;
|
||||||
|
Bind();
|
||||||
|
}
|
||||||
|
TrackStage();
|
||||||
|
HandleKeys();
|
||||||
|
HandleClicks();
|
||||||
|
if (Rig != null && _camera != null)
|
||||||
|
Hud.Minimap.SetCamera(Rig.Focus, Rig.Zoom, Rig.Footprint(_camera));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrackStage()
|
||||||
|
{
|
||||||
|
LuneMatchRunner.Stage stage = _runner.Current;
|
||||||
|
if (stage == _lastStage)
|
||||||
|
return;
|
||||||
|
LuneMatchRunner.Stage previous = _lastStage;
|
||||||
|
_lastStage = stage;
|
||||||
|
if (stage == LuneMatchRunner.Stage.Planning)
|
||||||
|
{
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
else if (stage == LuneMatchRunner.Stage.Combat)
|
||||||
|
{
|
||||||
|
View.Preview = null;
|
||||||
|
UpdateDeployment();
|
||||||
|
}
|
||||||
|
else if (stage == LuneMatchRunner.Stage.Over && Rig != null && Match.Combat != null)
|
||||||
|
{
|
||||||
|
Driver.Paused = false;
|
||||||
|
int fallen = Match.Combat.DestroyedCores.Contains(0) ? 0 : 1;
|
||||||
|
Rig.LookAt(Match.Map.CorePosition(fallen), 26f);
|
||||||
|
}
|
||||||
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly KeyCode[] GameKeys =
|
||||||
|
{
|
||||||
|
KeyCode.Escape, KeyCode.F1, KeyCode.M, KeyCode.F11, KeyCode.Space, KeyCode.P, KeyCode.Tab, KeyCode.F2, KeyCode.F3, KeyCode.F4,
|
||||||
|
KeyCode.B, KeyCode.G, KeyCode.H, KeyCode.Alpha1, KeyCode.Alpha2, KeyCode.Alpha3, KeyCode.Alpha4, KeyCode.Alpha5,
|
||||||
|
KeyCode.Keypad1, KeyCode.Keypad2, KeyCode.Keypad3, KeyCode.Keypad4, KeyCode.Keypad5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Game keys (Godot _input); they act even while a HUD button has focus.</summary>
|
||||||
|
void HandleKeys()
|
||||||
|
{
|
||||||
|
foreach (KeyCode code in GameKeys)
|
||||||
|
{
|
||||||
|
if (Input.GetKeyDown(code))
|
||||||
|
Key(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Blocked { get { return Hud.Settings.Open || (_leave != null && LuneUi.Visible(_leave)); } }
|
||||||
|
|
||||||
|
/// <summary>One game key, as pressed by the player (also the entry point of the playtest).</summary>
|
||||||
|
public void Key(KeyCode code)
|
||||||
|
{
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case KeyCode.Escape:
|
||||||
|
if (_leave != null && LuneUi.Visible(_leave))
|
||||||
|
LuneUi.Show(_leave, false);
|
||||||
|
else
|
||||||
|
Hud.HandleEscape();
|
||||||
|
return;
|
||||||
|
case KeyCode.F1:
|
||||||
|
if (Hud.Settings.Open)
|
||||||
|
Hud.Settings.Close();
|
||||||
|
else
|
||||||
|
Hud.Settings.Show();
|
||||||
|
return;
|
||||||
|
case KeyCode.M:
|
||||||
|
LunePreferences.Muted = !LunePreferences.Muted;
|
||||||
|
LunePreferences.Commit();
|
||||||
|
LuneSettingsPanel.Apply();
|
||||||
|
return;
|
||||||
|
case KeyCode.F11:
|
||||||
|
LunePreferences.Fullscreen = !LunePreferences.Fullscreen;
|
||||||
|
LunePreferences.Commit();
|
||||||
|
LuneSettingsPanel.Apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Blocked)
|
||||||
|
return;
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case KeyCode.Space:
|
||||||
|
if (Match.Phase == Phase.Planning)
|
||||||
|
{
|
||||||
|
// A Space meant as pause at the very end of a combat must not skip the planning.
|
||||||
|
if (!Hud.ReadyBlocked())
|
||||||
|
OnReady();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TogglePause();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case KeyCode.P:
|
||||||
|
TogglePause();
|
||||||
|
return;
|
||||||
|
case KeyCode.Tab:
|
||||||
|
_laneCycle = (_laneCycle + 1) % 3;
|
||||||
|
FocusLane(LuneMap.Lanes[_laneCycle]);
|
||||||
|
return;
|
||||||
|
case KeyCode.F2:
|
||||||
|
case KeyCode.F3:
|
||||||
|
case KeyCode.F4:
|
||||||
|
// F1 stays the options key; F2–F4 jump to Top, Mid and Bottom.
|
||||||
|
_laneCycle = code - KeyCode.F2;
|
||||||
|
FocusLane(LuneMap.Lanes[_laneCycle]);
|
||||||
|
return;
|
||||||
|
case KeyCode.B:
|
||||||
|
FocusTarget("base");
|
||||||
|
return;
|
||||||
|
case KeyCode.G:
|
||||||
|
FocusTarget("grotto");
|
||||||
|
return;
|
||||||
|
case KeyCode.H:
|
||||||
|
FocusHero();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int index = code >= KeyCode.Alpha1 && code <= KeyCode.Alpha5 ? code - KeyCode.Alpha1 : code >= KeyCode.Keypad1 && code <= KeyCode.Keypad5 ? code - KeyCode.Keypad1 : -1;
|
||||||
|
if (index < 0 || Match.Phase != Phase.Planning || !Human)
|
||||||
|
return;
|
||||||
|
if (Match.Players[Seat].Ready)
|
||||||
|
{
|
||||||
|
// After Ready the plan is binding (the host refuses); the keys do nothing.
|
||||||
|
Hud.Notify("Bereit gemeldet – die Planung ist verbindlich.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<string> ids = Match.BaseIds(Match.Players[Seat].Bund);
|
||||||
|
Feedback(Send(Command.Buy(ids[index])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Left click in the field selects an own unit (details in the HUD); it never orders anything.</summary>
|
||||||
|
void HandleClicks()
|
||||||
|
{
|
||||||
|
if (Input.GetMouseButtonDown(0))
|
||||||
|
ClickField(Input.mousePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A left click at a screen point (pixels, origin bottom left); HUD areas take it themselves.</summary>
|
||||||
|
public void ClickField(Vector2 screen)
|
||||||
|
{
|
||||||
|
if (_camera == null || Blocked || Hud.IsPointerOverHud(screen))
|
||||||
|
return;
|
||||||
|
LuneUnit picked = PickOwnUnit(screen);
|
||||||
|
if (picked != null && picked.Kind == "hero")
|
||||||
|
Hud.OpenHero();
|
||||||
|
else if (picked != null)
|
||||||
|
Hud.SelectUnit(picked.PersistentUid);
|
||||||
|
else if (Hud.SelectedUid.Length > 0)
|
||||||
|
Hud.ClearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The own troop or hero whose figure is nearest to a screen point (within 34 px), or null.</summary>
|
||||||
|
public LuneUnit PickOwnUnit(Vector2 screen)
|
||||||
|
{
|
||||||
|
if (!Human)
|
||||||
|
return null;
|
||||||
|
LuneUnit best = null;
|
||||||
|
float bestDistance = 34f;
|
||||||
|
foreach (LuneUnitView view in View.Units)
|
||||||
|
{
|
||||||
|
LuneUnit unit = view.Unit;
|
||||||
|
if (unit == null || !unit.Alive || unit.OwnerSlot != Seat || (unit.Kind != "troop" && unit.Kind != "hero") || !view.gameObject.activeInHierarchy)
|
||||||
|
continue;
|
||||||
|
Vector3 at = _camera.WorldToScreenPoint(view.AimPoint);
|
||||||
|
if (at.z <= 0f)
|
||||||
|
continue;
|
||||||
|
float distance = Vector2.Distance(new Vector2(at.x, at.y), screen);
|
||||||
|
if (distance < bestDistance)
|
||||||
|
{
|
||||||
|
bestDistance = distance;
|
||||||
|
best = unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- leaving the match
|
||||||
|
void BuildLeaveDialog()
|
||||||
|
{
|
||||||
|
_leave = new VisualElement();
|
||||||
|
_leave.style.position = Position.Absolute;
|
||||||
|
_leave.style.left = 0f;
|
||||||
|
_leave.style.top = 0f;
|
||||||
|
_leave.style.right = 0f;
|
||||||
|
_leave.style.bottom = 0f;
|
||||||
|
_leave.style.backgroundColor = new Color(.02f, .03f, .04f, .72f);
|
||||||
|
_leave.style.justifyContent = Justify.Center;
|
||||||
|
_leave.style.alignItems = Align.Center;
|
||||||
|
var panel = new LunePanel(LunePanels.Menu);
|
||||||
|
panel.style.minWidth = 520f;
|
||||||
|
_leave.Add(panel);
|
||||||
|
VisualElement column = LuneUi.Box(true, 14f, panel);
|
||||||
|
column.style.alignItems = Align.Center;
|
||||||
|
LuneUi.Label("Partie verlassen?", "HeadingLabel", column);
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Ein Hauptmenü gibt es im Unity-Port noch nicht; Verlassen beendet das Spiel.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||||
|
VisualElement row = LuneUi.Box(false, 12f, column);
|
||||||
|
new LuneButton("SPIEL BEENDEN", LuneButtons.Primary, row).Clicked += Quit;
|
||||||
|
new LuneButton("Zurück", LuneButtons.Secondary, row).Clicked += () => LuneUi.Show(_leave, false);
|
||||||
|
LuneUi.Show(_leave, false);
|
||||||
|
Hud.Root.Add(_leave);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AskLeave()
|
||||||
|
{
|
||||||
|
LuneUi.Show(_leave, true);
|
||||||
|
_leave.BringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Quit()
|
||||||
|
{
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
UnityEditor.EditorApplication.isPlaying = false;
|
||||||
|
#else
|
||||||
|
Application.Quit();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Game/LuneGame.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2b6ed0206a9c34173b3221328b34a71a
|
||||||
8
Assets/Luneraria/UI/Hud.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e06febeeee2b44cb1b682b23e37c8dd7
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
329
Assets/Luneraria/UI/Hud/LuneArmyPanels.cs
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attack champions (Godot LuneChampionPanel): target lane, the three champion cards (copper,
|
||||||
|
/// hatched, "1 WELLE") and the own bookings ("Einsatz in Welle 15 – MID"). Champions are bought
|
||||||
|
/// in planning n, fight once in wave n+1 and then vanish; they never join the permanent army.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneChampionPanel : VisualElement
|
||||||
|
{
|
||||||
|
public event Action<Command> CommandRequested;
|
||||||
|
public string TargetLane = "mid";
|
||||||
|
public readonly Dictionary<string, LuneUnitCard> Cards = new Dictionary<string, LuneUnitCard>();
|
||||||
|
public readonly Dictionary<string, LuneButton> LaneButtons = new Dictionary<string, LuneButton>();
|
||||||
|
readonly VisualElement _bookings;
|
||||||
|
readonly Label _count;
|
||||||
|
string _signature = "";
|
||||||
|
LuneHudModel _model;
|
||||||
|
|
||||||
|
public LuneChampionPanel()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
style.alignItems = Align.FlexStart;
|
||||||
|
VisualElement lanes = LuneUi.Box(true, 4f, this);
|
||||||
|
LuneUi.Label("ZIEL", "HudCaps", lanes);
|
||||||
|
foreach (string lane in LuneMap.Lanes)
|
||||||
|
{
|
||||||
|
var button = new LuneButton(LuneUi.Upper(LuneMap.LaneNames[lane]), LuneButtons.HudToggle, lanes);
|
||||||
|
button.style.minWidth = 86f;
|
||||||
|
button.On = lane == TargetLane;
|
||||||
|
string captured = lane;
|
||||||
|
button.Clicked += () =>
|
||||||
|
{
|
||||||
|
TargetLane = captured;
|
||||||
|
_signature = "";
|
||||||
|
foreach (KeyValuePair<string, LuneButton> pair in LaneButtons)
|
||||||
|
pair.Value.On = pair.Key == captured;
|
||||||
|
if (_model != null)
|
||||||
|
Apply(_model);
|
||||||
|
};
|
||||||
|
LaneButtons[lane] = button;
|
||||||
|
LuneTooltip.Attach(button, () => new LuneTip { Title = "Ziel-Lane " + LuneMap.LaneNames[captured], Text = "Gebuchte Champions greifen in der nächsten Welle auf dieser Lane an." });
|
||||||
|
}
|
||||||
|
VisualElement row = LuneUi.Box(false, 6f, this);
|
||||||
|
row.style.marginLeft = 10f;
|
||||||
|
row.style.alignItems = Align.FlexStart;
|
||||||
|
foreach (KeyValuePair<string, ChampionSpec> pair in LuneContent.Champions)
|
||||||
|
{
|
||||||
|
string championId = pair.Key;
|
||||||
|
ChampionSpec spec = pair.Value;
|
||||||
|
var card = new LuneUnitCard();
|
||||||
|
row.Add(card);
|
||||||
|
string role = championId == "ch_schutz" ? "schutz" : championId == "ch_druck" ? "druck" : championId == "ch_belagerung" ? "siege" : "melee";
|
||||||
|
card.Setup(championId, spec.Model, spec.Name, role, spec.Cost, "", true, DataCatalog.GetSend(spec.Base));
|
||||||
|
card.Clicked += () => CommandRequested?.Invoke(Command.ChampionBook(championId, TargetLane));
|
||||||
|
Cards[championId] = card;
|
||||||
|
LuneTooltip.Attach(card, () => Tip(championId));
|
||||||
|
}
|
||||||
|
VisualElement right = LuneUi.Box(true, 4f, this);
|
||||||
|
right.style.marginLeft = 10f;
|
||||||
|
right.style.flexGrow = 1f;
|
||||||
|
right.style.flexShrink = 1f;
|
||||||
|
right.style.minWidth = 200f;
|
||||||
|
VisualElement head = LuneUi.Box(false, 0f, right);
|
||||||
|
LuneUi.Label("EINSÄTZE", "HudCaps", head).style.flexGrow = 1f;
|
||||||
|
_count = LuneUi.Label("", "HudMuted", head);
|
||||||
|
_bookings = LuneUi.Box(true, 3f, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(LuneHudModel model)
|
||||||
|
{
|
||||||
|
_model = model;
|
||||||
|
List<LuneHudModel.BookingView> bookings = model.OwnBookings();
|
||||||
|
int fresh = model.Planning ? model.Me.Draft.NewBookings.Count : 0;
|
||||||
|
int gold = model.OwnGold;
|
||||||
|
string signature = string.Join(",", bookings) + "|" + gold + "|" + model.Planning + "|" + model.Locked + "|" + TargetLane + "|" + model.State.RoundNumber;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
bool canAct = model.Planning && !model.Locked;
|
||||||
|
foreach (KeyValuePair<string, LuneUnitCard> pair in Cards)
|
||||||
|
{
|
||||||
|
int cost = LuneContent.Champions[pair.Key].Cost;
|
||||||
|
string why = "";
|
||||||
|
if (!model.Planning)
|
||||||
|
why = "Buchungen nur in der Planung.";
|
||||||
|
else if (model.Locked)
|
||||||
|
why = "Bereit gemeldet – die Planung ist verbindlich.";
|
||||||
|
else if (fresh >= LuneContent.MaxBookings)
|
||||||
|
why = "Höchstens " + LuneContent.MaxBookings + " Champions pro Runde.";
|
||||||
|
else if (gold < cost)
|
||||||
|
why = "Es fehlen " + (cost - gold) + " Gold.";
|
||||||
|
pair.Value.SetAvailable(why.Length == 0, why, !canAct, gold < cost);
|
||||||
|
}
|
||||||
|
_count.text = fresh + "/" + LuneContent.MaxBookings + " diese Runde";
|
||||||
|
_bookings.Clear();
|
||||||
|
if (bookings.Count == 0)
|
||||||
|
{
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Keine Champions gebucht. Kauf jetzt – Einsatz in Welle " + (model.State.RoundNumber + (model.Planning ? 1 : 2)) + ".", "HudMuted", _bookings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (LuneHudModel.BookingView booking in bookings)
|
||||||
|
{
|
||||||
|
VisualElement line = LuneUi.Box(false, 6f, _bookings);
|
||||||
|
LuneUi.Glyph("hourglass", LuneUiTheme.Champion, 14f, line);
|
||||||
|
bool running = booking.DeployRound == model.State.RoundNumber;
|
||||||
|
VisualElement texts = LuneUi.Box(true, -2f, line);
|
||||||
|
texts.style.flexGrow = 1f;
|
||||||
|
LuneUi.Label(LuneContent.Champions[booking.ChampionId].Name, "HudText", texts);
|
||||||
|
LuneUi.Label("Einsatz in Welle " + booking.DeployRound + " – " + LuneUi.Upper(LuneMap.LaneNames[booking.Lane]) + (running ? " (jetzt)" : ""), "HudMuted", texts);
|
||||||
|
if (booking.Fresh)
|
||||||
|
{
|
||||||
|
var cancel = new LuneButton("×", LuneButtons.HudIconButton, line);
|
||||||
|
cancel.Disabled = !canAct;
|
||||||
|
string uid = booking.Uid;
|
||||||
|
cancel.Clicked += () => CommandRequested?.Invoke(Command.ChampionCancel(uid));
|
||||||
|
LuneTooltip.Attach(cancel, () => new LuneTip { Title = "Buchung zurücknehmen", Text = "Volle Erstattung, solange die Planung läuft." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip Tip(string championId)
|
||||||
|
{
|
||||||
|
ChampionSpec spec = LuneContent.Champions[championId];
|
||||||
|
int round = _model != null ? _model.State.RoundNumber + 1 : 2;
|
||||||
|
UnitDef definition = LuneContent.ChampionDefinition(championId, round, _model != null ? _model.State.Balance : LuneBalance.Default());
|
||||||
|
var tip = new LuneTip
|
||||||
|
{
|
||||||
|
Title = spec.Name + " · Angriffschampion", Glyph = "champion", GlyphColor = LuneUiTheme.Champion,
|
||||||
|
Subtitle = spec.RoleText + " · " + spec.Cost + " Gold · Einsatz in Welle " + round + " – " + LuneUi.Upper(LuneMap.LaneNames[TargetLane]),
|
||||||
|
Text = spec.Text,
|
||||||
|
Note = "Einmaliger Einsatz: kämpft eine Welle lang und verschwindet danach. Die Ankündigung ist für alle sichtbar; sein Tod bringt dem Gegner " + definition.Bounty + " Gold.",
|
||||||
|
Warn = Cards[championId].Reason,
|
||||||
|
};
|
||||||
|
tip.Stats.AddRange(LuneStats.Rows(definition));
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The selected normal unit (Godot LuneUnitDetail): portrait, name, stage, role, HP, damage,
|
||||||
|
/// tempo, range, armour, ability, upgrade with cost and stat change, return value, and the lane
|
||||||
|
/// order with TOP, MID, BOTTOM and GROTTE (only allowed options active). Normal units carry no
|
||||||
|
/// items. In combat or after Ready it only informs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneUnitDetail : LunePanel
|
||||||
|
{
|
||||||
|
public const string GrottoWarning = "Diese Einheit fehlt deiner Lane in der nächsten Kampfphase.";
|
||||||
|
public event Action<Command> CommandRequested;
|
||||||
|
public event Action Closed;
|
||||||
|
public string Uid = "";
|
||||||
|
readonly LunePortraitElement _portrait;
|
||||||
|
readonly Label _name;
|
||||||
|
readonly Label _sub;
|
||||||
|
readonly LuneHpBar _live;
|
||||||
|
readonly VisualElement _stats;
|
||||||
|
readonly Label _ability;
|
||||||
|
readonly Label _types;
|
||||||
|
readonly Label _upgradeTitle;
|
||||||
|
readonly Label _upgradeDelta;
|
||||||
|
public readonly LuneButton UpgradeButton;
|
||||||
|
public readonly LuneButton SellButton;
|
||||||
|
readonly Label _sellNote;
|
||||||
|
public readonly Dictionary<string, LuneButton> LaneButtons = new Dictionary<string, LuneButton>();
|
||||||
|
readonly Label _warning;
|
||||||
|
readonly Label _locked;
|
||||||
|
string _signature = "";
|
||||||
|
|
||||||
|
public LuneUnitDetail() : base(LunePanels.Hud)
|
||||||
|
{
|
||||||
|
VisualElement column = LuneUi.Box(true, 5f, this);
|
||||||
|
VisualElement head = LuneUi.Box(false, 10f, column);
|
||||||
|
head.style.alignItems = Align.FlexStart;
|
||||||
|
_portrait = new LunePortraitElement();
|
||||||
|
_portrait.style.width = 76f;
|
||||||
|
_portrait.style.height = 76f;
|
||||||
|
head.Add(_portrait);
|
||||||
|
VisualElement titles = LuneUi.Box(true, 2f, head);
|
||||||
|
titles.style.flexGrow = 1f;
|
||||||
|
titles.style.flexShrink = 1f;
|
||||||
|
titles.style.minWidth = 0f;
|
||||||
|
_name = LuneUi.Wrap(LuneUi.Label("", "HudName", titles));
|
||||||
|
_sub = LuneUi.Wrap(LuneUi.Label("", "HudMuted", titles));
|
||||||
|
_live = new LuneHpBar(0f, 6f);
|
||||||
|
titles.Add(_live);
|
||||||
|
var close = new LuneButton(null, LuneButtons.HudIconButton, head);
|
||||||
|
close.style.width = 32f;
|
||||||
|
close.style.height = 30f;
|
||||||
|
LuneUi.Glyph("close", LuneUiTheme.Muted, 12f, close);
|
||||||
|
close.Clicked += () => Closed?.Invoke();
|
||||||
|
LuneTooltip.Attach(close, () => new LuneTip { Title = "Auswahl aufheben", Text = "Esc" });
|
||||||
|
_stats = LuneUi.Box(true, 2f, column);
|
||||||
|
LuneTooltip.Attach(_stats, StatsTip);
|
||||||
|
_types = LuneUi.Wrap(LuneUi.Label("", "HudMuted", column));
|
||||||
|
_ability = LuneUi.Wrap(LuneUi.Label("", "HudText", column));
|
||||||
|
VisualElement upgradeBox = LuneUi.Box(true, 2f, column);
|
||||||
|
VisualElement upgradeRow = LuneUi.Box(false, 8f, upgradeBox);
|
||||||
|
_upgradeTitle = LuneUi.Wrap(LuneUi.Label("", "HudText", upgradeRow));
|
||||||
|
_upgradeTitle.style.flexGrow = 1f;
|
||||||
|
UpgradeButton = new LuneButton("", LuneButtons.HudPrimary, upgradeRow);
|
||||||
|
UpgradeButton.Clicked += () => CommandRequested?.Invoke(Command.Upgrade(Uid));
|
||||||
|
_upgradeDelta = LuneUi.Wrap(LuneUi.Label("", "HudMuted", upgradeBox));
|
||||||
|
VisualElement laneRow = LuneUi.Box(false, 4f, column);
|
||||||
|
foreach (string lane in LuneMap.Lanes.Concat(new[] { LuneMatch.Grotto }))
|
||||||
|
{
|
||||||
|
string title = lane == LuneMatch.Grotto ? "GROTTE" : LuneUi.Upper(LuneMap.LaneNames[lane]);
|
||||||
|
var button = new LuneButton(title, LuneButtons.HudToggle, laneRow);
|
||||||
|
button.style.flexGrow = 1f;
|
||||||
|
string captured = lane;
|
||||||
|
button.Clicked += () => CommandRequested?.Invoke(Command.Assign(Uid, captured));
|
||||||
|
LaneButtons[lane] = button;
|
||||||
|
LuneTooltip.Attach(button, () => new LuneTip { Title = "Einsatz: " + title,
|
||||||
|
Text = captured == LuneMatch.Grotto ? GrottoWarning : "Kämpft in der nächsten Welle auf der " + LuneMap.LaneNames[captured] + "-Lane." });
|
||||||
|
}
|
||||||
|
_warning = LuneUi.Wrap(LuneUi.Label(GrottoWarning, "HudText", column));
|
||||||
|
LuneUi.SetColor(_warning, LuneUiTheme.Warn);
|
||||||
|
VisualElement sellRow = LuneUi.Box(false, 8f, column);
|
||||||
|
SellButton = new LuneButton("", LuneButtons.HudButton, sellRow);
|
||||||
|
SellButton.Clicked += () => CommandRequested?.Invoke(Command.Sell(Uid));
|
||||||
|
_sellNote = LuneUi.Wrap(LuneUi.Label("", "HudMuted", sellRow));
|
||||||
|
_sellNote.style.flexGrow = 1f;
|
||||||
|
_locked = LuneUi.Wrap(LuneUi.Label("", "HudMuted", column));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>entry: own roster entry; live: the unit in combat or null.</summary>
|
||||||
|
public void Apply(RosterEntry entry, LuneHudModel model, LuneUnit live)
|
||||||
|
{
|
||||||
|
Uid = entry.Uid;
|
||||||
|
LuneHudModel.TroopInfo data = model.Troop(entry.TypeId);
|
||||||
|
bool planning = model.Planning;
|
||||||
|
bool locked = model.Locked;
|
||||||
|
int gold = model.OwnGold;
|
||||||
|
string signature = entry.Uid + ":" + entry.TypeId + ":" + entry.Lane + ":" + entry.Invested + ":" + entry.Fresh + "|" + planning + "|" + locked + "|" + gold + "|"
|
||||||
|
+ model.State.GrottoAvailable() + "|" + (live != null) + "|" + model.State.RoundNumber;
|
||||||
|
if (live != null)
|
||||||
|
{
|
||||||
|
_live.Fraction = live.Alive ? (float)(live.Hp / Math.Max(1.0, live.MaxHp)) : 0f;
|
||||||
|
_live.Fill = LuneHpBar.HealthColor(_live.Fraction, LuneUiTheme.TeamColor(model.Team));
|
||||||
|
}
|
||||||
|
LuneUi.Show(_live, live != null);
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
UnitDef def = data.Def;
|
||||||
|
_portrait.ShowModel(entry.TypeId);
|
||||||
|
_name.text = def.DisplayName;
|
||||||
|
string laneText = entry.Lane == LuneMatch.Grotto ? "Grotte" : LuneMap.LaneNames[entry.Lane];
|
||||||
|
_sub.text = LuneStats.RoleName(data.Role) + " · Stufe " + (data.Stage == 2 ? "II" : "I") + " · " + laneText + (live != null && !live.Alive ? " · gefallen" : "");
|
||||||
|
_stats.Clear();
|
||||||
|
List<string[]> rows = LuneStats.ShortRows(def);
|
||||||
|
for (int index = 0; index < 4; index += 2)
|
||||||
|
{
|
||||||
|
VisualElement line = LuneUi.Box(false, 6f, _stats);
|
||||||
|
for (int cell = index; cell < index + 2; cell++)
|
||||||
|
{
|
||||||
|
LuneUi.Glyph(rows[cell][0], LuneUiTheme.Muted, 14f, line);
|
||||||
|
Label value = LuneUi.Clip(LuneUi.Label(rows[cell][1], "HudText", line));
|
||||||
|
value.style.flexGrow = 1f;
|
||||||
|
value.style.flexBasis = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_types.text = "Rüstung " + LuneStats.ArmorName(def.ArmorType) + " · Angriff " + LuneStats.DamageName(def.AttackType) + ", stark gegen " + LuneStats.StrongAgainst(def.AttackType);
|
||||||
|
_ability.text = def.AbilityDescription ?? "";
|
||||||
|
bool canAct = planning && !locked;
|
||||||
|
string upgrade = def.UpgradeId ?? "";
|
||||||
|
if (upgrade.Length == 0)
|
||||||
|
{
|
||||||
|
_upgradeTitle.text = "Höchste Stufe erreicht.";
|
||||||
|
LuneUi.Show(UpgradeButton, false);
|
||||||
|
_upgradeDelta.text = "";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UnitDef next = DataCatalog.GetUnit(upgrade);
|
||||||
|
_upgradeTitle.text = "Aufwerten → " + next.DisplayName;
|
||||||
|
LuneUi.Show(UpgradeButton, true);
|
||||||
|
UpgradeButton.Text = "↑ " + next.Cost;
|
||||||
|
UpgradeButton.Disabled = !canAct || gold < next.Cost;
|
||||||
|
string delta = "Leben " + LuneUiTheme.Number(def.MaxHp) + " → " + LuneUiTheme.Number(next.MaxHp) + " · Schaden " + LuneUiTheme.Decimal(def.Damage) + " → " + LuneUiTheme.Decimal(next.Damage);
|
||||||
|
if (Math.Abs(def.AttackRange - next.AttackRange) > 1e-9)
|
||||||
|
delta += " · Reichweite " + LuneUiTheme.Decimal(def.AttackRange) + " → " + LuneUiTheme.Decimal(next.AttackRange) + " m";
|
||||||
|
if (canAct && gold < next.Cost)
|
||||||
|
delta += " · es fehlen " + (next.Cost - gold) + " Gold";
|
||||||
|
_upgradeDelta.text = delta;
|
||||||
|
}
|
||||||
|
LuneUi.Show(_upgradeDelta, _upgradeDelta.text.Length > 0);
|
||||||
|
int refund = entry.Fresh ? entry.Invested : (int)Math.Floor(entry.Invested * model.State.Balance.SellRefund);
|
||||||
|
SellButton.Text = "Zurückgeben +" + refund;
|
||||||
|
SellButton.Disabled = !canAct;
|
||||||
|
LuneUi.Show(SellButton, planning);
|
||||||
|
_sellNote.text = entry.Fresh ? "voller Preis" : LuneUiTheme.Round(model.State.Balance.SellRefund * 100.0) + " % von " + entry.Invested;
|
||||||
|
LuneUi.Show(_sellNote, planning);
|
||||||
|
bool grottoOpen = model.State.GrottoAvailable();
|
||||||
|
foreach (KeyValuePair<string, LuneButton> pair in LaneButtons)
|
||||||
|
{
|
||||||
|
pair.Value.On = entry.Lane == pair.Key;
|
||||||
|
pair.Value.Disabled = !canAct || (pair.Key == LuneMatch.Grotto && !grottoOpen);
|
||||||
|
}
|
||||||
|
LuneUi.Show(_warning, entry.Lane == LuneMatch.Grotto);
|
||||||
|
if (!planning)
|
||||||
|
_locked.text = "Im Kampf gibt es keine Befehle; Aufwertung, Rückgabe und Einsatz wieder in der Planung.";
|
||||||
|
else if (locked)
|
||||||
|
_locked.text = "Bereit gemeldet: Die Planung ist verbindlich.";
|
||||||
|
else if (!grottoOpen)
|
||||||
|
_locked.text = "Grotte gesperrt bis Welle " + model.State.Boss.NextAvailableRound + ".";
|
||||||
|
else
|
||||||
|
_locked.text = "";
|
||||||
|
LuneUi.Show(_locked, _locked.text.Length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip StatsTip()
|
||||||
|
{
|
||||||
|
string type = _portrait.ModelId;
|
||||||
|
if (string.IsNullOrEmpty(type))
|
||||||
|
return null;
|
||||||
|
UnitDef data = DataCatalog.GetUnit(type);
|
||||||
|
var tip = new LuneTip { Title = data.DisplayName, Note = "Ein Angriff pro Sekunde mal Tempo; Schaden je Treffer vor Rüstung. Stark: 130 %, schwach: 75 % (Rüstungsmatrix)." };
|
||||||
|
tip.Stats.AddRange(LuneStats.Rows(data, true));
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneArmyPanels.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 38d985ede218049588b1e5db6966e4a9
|
||||||
684
Assets/Luneraria/UI/Hud/LuneComponents.cs
Normal file
|
|
@ -0,0 +1,684 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>Where a HUD click sends the camera: a lane, a map point or a named target (base, enemy_base, grotto, hero).</summary>
|
||||||
|
public sealed class LuneFocusTarget
|
||||||
|
{
|
||||||
|
public string Lane = "";
|
||||||
|
public Vec2? Point;
|
||||||
|
public string Camera = "";
|
||||||
|
|
||||||
|
public bool IsEmpty { get { return Lane.Length == 0 && Point == null && Camera.Length == 0; } }
|
||||||
|
public static LuneFocusTarget ForLane(string lane) { return new LuneFocusTarget { Lane = lane ?? "" }; }
|
||||||
|
public static LuneFocusTarget ForPoint(Vec2 point) { return new LuneFocusTarget { Point = point }; }
|
||||||
|
public static LuneFocusTarget ForCamera(string target) { return new LuneFocusTarget { Camera = target ?? "" }; }
|
||||||
|
|
||||||
|
public override string ToString() { return Lane + "|" + (Point.HasValue ? Point.Value.ToString() : "") + "|" + Camera; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colours of the armour and damage types (G3 open point, Legion TD principle): a damage type
|
||||||
|
/// has the colour of the armour it hits for 130 % (DamageMatrix: type i is strong against armour i).
|
||||||
|
/// </summary>
|
||||||
|
public static class LuneTypeColors
|
||||||
|
{
|
||||||
|
public static readonly Color[] Types = { LuneUiTheme.Hex("e2b46b"), LuneUiTheme.Hex("7fb2e5"), LuneUiTheme.Hex("c39bf0") };
|
||||||
|
|
||||||
|
public static Color Armor(int armorType) { return Types[Mathf.Clamp(armorType, 0, 2)]; }
|
||||||
|
|
||||||
|
public static Color Damage(int attackType)
|
||||||
|
{
|
||||||
|
int row = Mathf.Clamp(attackType, 0, 2);
|
||||||
|
for (int armor = 0; armor < 3; armor++)
|
||||||
|
{
|
||||||
|
if (LuneUnit.DamageMatrix[row, armor] == 1.30)
|
||||||
|
return Types[armor];
|
||||||
|
}
|
||||||
|
return LuneUiTheme.Muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The viewer's own gold with the coin symbol and the base income of the next settlement
|
||||||
|
/// (Godot LuneResourceDisplay). It is only ever fed the own seat's gold.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneResourceDisplay : VisualElement
|
||||||
|
{
|
||||||
|
public readonly Label Amount;
|
||||||
|
public readonly Label Detail;
|
||||||
|
int _shown = -1;
|
||||||
|
float _flash;
|
||||||
|
|
||||||
|
public LuneResourceDisplay()
|
||||||
|
{
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
LuneUi.Glyph("coin", LuneUiTheme.Gold, 30f, this);
|
||||||
|
VisualElement column = LuneUi.Box(true, -2f, this);
|
||||||
|
column.style.marginLeft = 8f;
|
||||||
|
Amount = LuneUi.Label("0", "HudGold", column);
|
||||||
|
Detail = LuneUi.Label("", "HudMuted", column);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetValues(int gold, int income, bool showIncome = true)
|
||||||
|
{
|
||||||
|
if (gold != _shown)
|
||||||
|
{
|
||||||
|
if (_shown >= 0 && !LunePreferences.ReducedMotion)
|
||||||
|
_flash = .45f;
|
||||||
|
_shown = gold;
|
||||||
|
Amount.text = LuneUiTheme.Number(gold);
|
||||||
|
}
|
||||||
|
string line = showIncome ? "+" + LuneUiTheme.Number(income) + " nächste Runde" : "";
|
||||||
|
if (Detail.text != line)
|
||||||
|
Detail.text = line;
|
||||||
|
LuneUi.Show(Detail, showIncome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(float delta)
|
||||||
|
{
|
||||||
|
if (_flash <= 0f)
|
||||||
|
return;
|
||||||
|
_flash = Mathf.Max(0f, _flash - delta);
|
||||||
|
// Godot modulate (1.35, 1.2, .8) fading back to white.
|
||||||
|
float t = _flash / .45f;
|
||||||
|
Color gold = LuneUiTheme.Gold;
|
||||||
|
var flash = new Color(Mathf.Min(1f, gold.r * 1.35f), Mathf.Min(1f, gold.g * 1.2f), gold.b * .8f, 1f);
|
||||||
|
LuneUi.SetColor(Amount, Color.Lerp(gold, flash, t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The own team (Godot LuneTeamPanel): per seat the name, lane, hero (committed hero of the
|
||||||
|
/// others, own draft for the viewer), readiness during planning and whether a human, a bot or –
|
||||||
|
/// after a disconnect – a bot on a human's seat plays it. Gold or plans of others never appear.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneTeamPanel : VisualElement
|
||||||
|
{
|
||||||
|
sealed class Row
|
||||||
|
{
|
||||||
|
public VisualElement Element;
|
||||||
|
public LuneGlyphElement Status;
|
||||||
|
public LunePortraitElement Portrait;
|
||||||
|
public Label Name;
|
||||||
|
public Label Lane;
|
||||||
|
public LuneStatusBadge Badge;
|
||||||
|
public LuneHudModel.MemberInfo Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly List<Row> _rows = new List<Row>();
|
||||||
|
readonly Label _header;
|
||||||
|
readonly LuneGlyphElement _emblem;
|
||||||
|
string _signature = "";
|
||||||
|
|
||||||
|
public LuneTeamPanel()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
var column = (LuneBox)LuneUi.Box(true, 3f, this);
|
||||||
|
VisualElement head = LuneUi.Box(false, 6f, column);
|
||||||
|
_emblem = LuneUi.Glyph("emblem_0", LuneUiTheme.Team[0], 15f, head);
|
||||||
|
_header = LuneUi.Label("DEIN TEAM", "HudCaps", head);
|
||||||
|
for (int index = 0; index < 3; index++)
|
||||||
|
{
|
||||||
|
VisualElement element = LuneUi.Box(false, 6f, column);
|
||||||
|
element.pickingMode = PickingMode.Position;
|
||||||
|
element.style.minHeight = 28f;
|
||||||
|
var row = new Row { Element = element };
|
||||||
|
row.Status = LuneUi.Glyph("human", LuneUiTheme.Muted, 15f, element);
|
||||||
|
row.Portrait = new LunePortraitElement();
|
||||||
|
row.Portrait.style.width = 26f;
|
||||||
|
row.Portrait.style.height = 26f;
|
||||||
|
element.Add(row.Portrait);
|
||||||
|
row.Name = LuneUi.Clip(LuneUi.Label("", "HudText", element));
|
||||||
|
row.Name.style.flexGrow = 1f;
|
||||||
|
row.Lane = LuneUi.Label("", "HudMuted", element);
|
||||||
|
row.Badge = new LuneStatusBadge();
|
||||||
|
element.Add(row.Badge);
|
||||||
|
_rows.Add(row);
|
||||||
|
int captured = index;
|
||||||
|
LuneTooltip.Attach(element, () => Tip(captured));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(List<LuneHudModel.MemberInfo> members, int team, bool planning, bool networked)
|
||||||
|
{
|
||||||
|
string signature = string.Join(",", members) + "|" + team + "|" + planning + "|" + networked;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
_emblem.Glyph = "emblem_" + team;
|
||||||
|
_emblem.Color = LuneUiTheme.TeamColor(team);
|
||||||
|
_header.text = "DEIN TEAM · " + LuneUi.Upper(LuneUiTheme.TeamName(team));
|
||||||
|
for (int index = 0; index < Mathf.Min(3, members.Count); index++)
|
||||||
|
{
|
||||||
|
LuneHudModel.MemberInfo member = members[index];
|
||||||
|
Row row = _rows[index];
|
||||||
|
row.Data = member;
|
||||||
|
string status = member.Status;
|
||||||
|
row.Status.Glyph = status == "self" ? "star" : status == "bot" ? "bot" : status == "offline" ? "offline" : "human";
|
||||||
|
row.Status.Color = status == "self" ? LuneUiTheme.BronzeLight : status == "offline" ? LuneUiTheme.Danger : LuneUiTheme.Muted;
|
||||||
|
row.Name.text = member.Name;
|
||||||
|
LuneUi.SetColor(row.Name, member.IsSelf ? LuneUiTheme.BronzeLight : LuneUiTheme.Text);
|
||||||
|
row.Lane.text = LuneUi.Upper(LuneMap.LaneNames[member.Lane]);
|
||||||
|
LuneUi.Show(row.Portrait, member.HeroId.Length > 0);
|
||||||
|
if (member.HeroId.Length > 0)
|
||||||
|
row.Portrait.ShowModel(LuneContent.Heroes[member.HeroId].Model);
|
||||||
|
if (status == "offline")
|
||||||
|
row.Badge.SetState("BOT ÜBERNIMMT", LuneUiTheme.Danger, "offline");
|
||||||
|
else if (status == "bot")
|
||||||
|
row.Badge.SetState("BOT", LuneUiTheme.Muted, "bot");
|
||||||
|
else if (planning && member.Ready)
|
||||||
|
row.Badge.SetState("BEREIT", LuneUiTheme.Good, "check");
|
||||||
|
else if (planning)
|
||||||
|
row.Badge.SetState("PLANT", LuneUiTheme.Warn, "clock");
|
||||||
|
else
|
||||||
|
row.Badge.SetState("IM KAMPF", LuneUiTheme.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip Tip(int index)
|
||||||
|
{
|
||||||
|
LuneHudModel.MemberInfo member = _rows[index].Data;
|
||||||
|
if (member == null)
|
||||||
|
return null;
|
||||||
|
string kind = member.Status == "self" ? "Du" : member.Status == "human" ? "Mensch" : member.Status == "bot" ? "Bot" : "Verbindung getrennt – ein Bot spielt den Platz weiter";
|
||||||
|
string hero = "kein Held";
|
||||||
|
if (member.HeroId.Length > 0)
|
||||||
|
hero = LuneContent.Heroes[member.HeroId].Name + " · Stufe " + member.HeroLevel;
|
||||||
|
var tip = new LuneTip
|
||||||
|
{
|
||||||
|
Title = member.Name,
|
||||||
|
Subtitle = LuneMap.LaneNames[member.Lane] + "-Lane · " + ContentData.FactionNames.GetValueOrDefault(member.Bund, ""),
|
||||||
|
Note = member.IsSelf ? "" : "Gold und laufende Planung anderer Spieler bleiben privat.",
|
||||||
|
};
|
||||||
|
tip.Stat("human", "Spieler", kind).Stat("crown", "Held", hero).Stat("check", "Planung", member.Ready ? "bereit" : "plant noch");
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase card (Godot LuneUnitCard): real-model portrait, name, role, gold price, hotkey and
|
||||||
|
/// availability; the armour and damage type as coloured symbols (G3 open point). The champion
|
||||||
|
/// variant uses the copper card style, a hatched portrait and the "1 WELLE" band at the bottom
|
||||||
|
/// of the portrait, so temporary attack champions never read like permanent troops.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneUnitCard : LuneButton
|
||||||
|
{
|
||||||
|
static readonly Dictionary<string, string> RoleGlyphs = new Dictionary<string, string>
|
||||||
|
{ { "front", "tank" }, { "melee", "melee" }, { "ranged", "ranged" }, { "splash", "splash" }, { "support", "support" }, { "schutz", "tank" }, { "druck", "tempo" }, { "siege", "siege" } };
|
||||||
|
static readonly Dictionary<string, string> RoleText = new Dictionary<string, string>
|
||||||
|
{ { "front", "Tank" }, { "melee", "Nahkampf" }, { "ranged", "Fernkampf" }, { "splash", "Fläche" }, { "support", "Support" }, { "schutz", "Schutz" }, { "druck", "Druck" }, { "siege", "Belagerung" } };
|
||||||
|
|
||||||
|
public string TypeId = "";
|
||||||
|
public bool Champion;
|
||||||
|
public int Cost;
|
||||||
|
public string Reason = "";
|
||||||
|
public readonly LunePortraitElement Portrait;
|
||||||
|
readonly Label _name;
|
||||||
|
readonly LuneGlyphElement _roleGlyph;
|
||||||
|
readonly Label _role;
|
||||||
|
readonly Label _cost;
|
||||||
|
readonly Label _key;
|
||||||
|
readonly VisualElement _band;
|
||||||
|
readonly LuneGlyphElement _lock;
|
||||||
|
readonly LuneGlyphElement _armor;
|
||||||
|
readonly LuneGlyphElement _attack;
|
||||||
|
|
||||||
|
public LuneUnitCard() : base(null, LuneButtons.HudCard)
|
||||||
|
{
|
||||||
|
style.width = 116f;
|
||||||
|
style.height = 132f;
|
||||||
|
style.flexDirection = FlexDirection.Column;
|
||||||
|
style.alignItems = Align.Stretch;
|
||||||
|
style.justifyContent = Justify.FlexStart;
|
||||||
|
style.paddingLeft = 6f;
|
||||||
|
style.paddingRight = 6f;
|
||||||
|
style.paddingTop = 6f;
|
||||||
|
style.paddingBottom = 5f;
|
||||||
|
Portrait = new LunePortraitElement();
|
||||||
|
Portrait.style.minHeight = 62f;
|
||||||
|
Portrait.style.flexGrow = 1f;
|
||||||
|
Add(Portrait);
|
||||||
|
_key = LuneUi.Label("", "HudCaps", Portrait);
|
||||||
|
_key.style.position = Position.Absolute;
|
||||||
|
_key.style.left = 4f;
|
||||||
|
_key.style.top = 1f;
|
||||||
|
// Armour and damage type, top right (Legion TD: what it is good against).
|
||||||
|
VisualElement types = LuneUi.Box(false, 2f, Portrait);
|
||||||
|
types.style.position = Position.Absolute;
|
||||||
|
types.style.right = 3f;
|
||||||
|
types.style.top = 3f;
|
||||||
|
_attack = LuneUi.Glyph("sword", LuneUiTheme.Muted, 13f, types);
|
||||||
|
_armor = LuneUi.Glyph("armor", LuneUiTheme.Muted, 13f, types);
|
||||||
|
_band = LuneUi.Box(false, 2f, Portrait);
|
||||||
|
_band.style.position = Position.Absolute;
|
||||||
|
_band.style.left = 0f;
|
||||||
|
_band.style.right = 0f;
|
||||||
|
_band.style.bottom = 0f;
|
||||||
|
_band.style.height = 16f;
|
||||||
|
_band.style.justifyContent = Justify.Center;
|
||||||
|
_band.style.backgroundColor = new Color(0f, 0f, 0f, .62f);
|
||||||
|
LuneUi.Glyph("hourglass", LuneUiTheme.Champion, 12f, _band);
|
||||||
|
LuneUi.SetColor(LuneUi.Label("1 WELLE", "HudCaps", _band, 14f), LuneUiTheme.Champion);
|
||||||
|
LuneUi.Show(_band, false);
|
||||||
|
_lock = LuneUi.Glyph("lock", LuneUiTheme.Muted, 18f, Portrait);
|
||||||
|
_lock.style.position = Position.Absolute;
|
||||||
|
_lock.style.left = new Length(50f, LengthUnit.Percent);
|
||||||
|
_lock.style.top = new Length(50f, LengthUnit.Percent);
|
||||||
|
_lock.style.translate = new Translate(new Length(-50f, LengthUnit.Percent), new Length(-50f, LengthUnit.Percent));
|
||||||
|
LuneUi.Show(_lock, false);
|
||||||
|
_name = LuneUi.Clip(LuneUi.Label("", "HudText", this));
|
||||||
|
_name.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||||
|
_name.style.marginTop = 1f;
|
||||||
|
VisualElement roleRow = LuneUi.Box(false, 4f, this);
|
||||||
|
roleRow.style.justifyContent = Justify.Center;
|
||||||
|
roleRow.style.marginTop = 1f;
|
||||||
|
_roleGlyph = LuneUi.Glyph("", LuneUiTheme.Muted, 14f, roleRow);
|
||||||
|
_role = LuneUi.Label("", "HudMuted", roleRow);
|
||||||
|
VisualElement costRow = LuneUi.Box(false, 4f, this);
|
||||||
|
costRow.style.justifyContent = Justify.Center;
|
||||||
|
costRow.style.marginTop = 1f;
|
||||||
|
LuneUi.Glyph("coin", LuneUiTheme.Gold, 15f, costRow);
|
||||||
|
_cost = LuneUi.Label("", "HudValue", costRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Setup(string id, string modelId, string title, string roleKey, int price, string hotkey = "", bool champion = false, UnitDef definition = null)
|
||||||
|
{
|
||||||
|
TypeId = id;
|
||||||
|
Champion = champion;
|
||||||
|
Cost = price;
|
||||||
|
Look = champion ? LuneButtons.HudChampionCard : LuneButtons.HudCard;
|
||||||
|
Portrait.ShowModel(modelId);
|
||||||
|
_name.text = title;
|
||||||
|
_roleGlyph.Glyph = RoleGlyphs.TryGetValue(roleKey ?? "", out string glyph) ? glyph : "melee";
|
||||||
|
_role.text = RoleText.TryGetValue(roleKey ?? "", out string text) ? text : roleKey;
|
||||||
|
_cost.text = price.ToString();
|
||||||
|
_key.text = hotkey;
|
||||||
|
LuneUi.Show(_key, hotkey.Length > 0);
|
||||||
|
LuneUi.Show(_band, champion);
|
||||||
|
Portrait.Hatch = champion ? LuneUiTheme.WithAlpha(LuneUiTheme.Champion, .13f) : Color.clear;
|
||||||
|
if (definition != null)
|
||||||
|
{
|
||||||
|
_attack.Color = LuneTypeColors.Damage(definition.AttackType);
|
||||||
|
_armor.Color = LuneTypeColors.Armor(definition.ArmorType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ok = purchasable now; otherwise `why` explains it (tooltip) and the card greys out.</summary>
|
||||||
|
public void SetAvailable(bool ok, string why = "", bool locked = false, bool unaffordable = false)
|
||||||
|
{
|
||||||
|
Reason = why ?? "";
|
||||||
|
Disabled = !ok;
|
||||||
|
Portrait.Dimmed = !ok;
|
||||||
|
LuneUi.Show(_lock, locked);
|
||||||
|
LuneUi.SetColor(_cost, unaffordable ? LuneUiTheme.Danger : LuneUiTheme.Bright);
|
||||||
|
LuneUi.SetColor(_name, ok ? LuneUiTheme.Text : LuneUiTheme.Muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One own troop in the army strip (Godot LuneArmyChip): real-model portrait, a band with its
|
||||||
|
/// lane (T, M, B or G) and stage (II for upgraded troops). Selected = pressed style. In combat a
|
||||||
|
/// fallen troop is dimmed and crossed out.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneArmyChip : LuneButton
|
||||||
|
{
|
||||||
|
public const float Band = 15f;
|
||||||
|
public string Uid = "";
|
||||||
|
public string TypeId = "";
|
||||||
|
public string Lane = "mid";
|
||||||
|
public readonly LunePortraitElement Portrait;
|
||||||
|
readonly Label _lane;
|
||||||
|
readonly Label _stage;
|
||||||
|
bool _fallen;
|
||||||
|
|
||||||
|
public LuneArmyChip() : base(null, LuneButtons.HudChip)
|
||||||
|
{
|
||||||
|
style.flexDirection = FlexDirection.Column;
|
||||||
|
style.alignItems = Align.Stretch;
|
||||||
|
style.justifyContent = Justify.FlexStart;
|
||||||
|
style.paddingLeft = 2f;
|
||||||
|
style.paddingRight = 2f;
|
||||||
|
style.paddingTop = 2f;
|
||||||
|
style.paddingBottom = 1f;
|
||||||
|
Portrait = new LunePortraitElement();
|
||||||
|
Portrait.style.flexGrow = 1f;
|
||||||
|
Add(Portrait);
|
||||||
|
VisualElement band = LuneUi.Box(false, 0f, this);
|
||||||
|
band.style.height = Band;
|
||||||
|
band.style.backgroundColor = new Color(0f, 0f, 0f, .55f);
|
||||||
|
band.style.justifyContent = Justify.SpaceBetween;
|
||||||
|
band.style.paddingLeft = 3f;
|
||||||
|
band.style.paddingRight = 3f;
|
||||||
|
_lane = LuneUi.Label("", "HudCaps", band, 14f);
|
||||||
|
_stage = LuneUi.Label("II", "HudCaps", band, 14f);
|
||||||
|
LuneUi.SetColor(_stage, LuneUiTheme.Gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Setup(RosterEntry entry, float edge)
|
||||||
|
{
|
||||||
|
Uid = entry.Uid;
|
||||||
|
TypeId = entry.TypeId;
|
||||||
|
Lane = entry.Lane;
|
||||||
|
style.width = edge;
|
||||||
|
style.height = edge + Band;
|
||||||
|
Portrait.ShowModel(TypeId);
|
||||||
|
bool grotto = Lane == LuneMatch.Grotto;
|
||||||
|
_lane.text = grotto ? "G" : LuneMap.LaneNames.TryGetValue(Lane, out string name) ? name.Substring(0, 1) : "?";
|
||||||
|
LuneUi.SetColor(_lane, grotto ? LuneUiTheme.Team[2] : LuneUiTheme.BronzeLight);
|
||||||
|
LuneUi.Show(_stage, LuneStats.StageOf(TypeId) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Fallen
|
||||||
|
{
|
||||||
|
get { return _fallen; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _fallen)
|
||||||
|
return;
|
||||||
|
_fallen = value;
|
||||||
|
Portrait.Dimmed = value;
|
||||||
|
Portrait.Crossed = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hero item (Godot LuneItemSlot): one of the three slots (compact: symbol only) or a card in
|
||||||
|
/// the item shop (symbol, name, cost, main effect, state). States say why something cannot be
|
||||||
|
/// done: equipped, available, unaffordable, incompatible (hero level), unique (already carried),
|
||||||
|
/// slots full, locked (plan committed) or an empty slot.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneItemSlot : LuneButton
|
||||||
|
{
|
||||||
|
public enum State { Empty, Equipped, Available, Unaffordable, Incompatible, Unique, Full, Locked }
|
||||||
|
|
||||||
|
static readonly Dictionary<State, string> StateText = new Dictionary<State, string>
|
||||||
|
{
|
||||||
|
{ State.Equipped, "AUSGERÜSTET" }, { State.Available, "KAUFBAR" }, { State.Unaffordable, "ZU TEUER" }, { State.Incompatible, "STUFE FEHLT" },
|
||||||
|
{ State.Unique, "GETRAGEN" }, { State.Full, "PLÄTZE VOLL" }, { State.Locked, "GESPERRT" }, { State.Empty, "LEER" },
|
||||||
|
};
|
||||||
|
static readonly Dictionary<string, Color> ItemColors = new Dictionary<string, Color>
|
||||||
|
{
|
||||||
|
{ "basaltschild", LuneUiTheme.Hex("9fb2c8") }, { "glutklinge", LuneUiTheme.Hex("ff9a5a") }, { "rankenmantel", LuneUiTheme.Hex("8fd08a") },
|
||||||
|
{ "funkenfokus", LuneUiTheme.Hex("f5d76a") }, { "quellamulett", LuneUiTheme.Hex("7ad6e6") }, { "kristallsplitter", LuneUiTheme.Hex("c7a2ff") },
|
||||||
|
};
|
||||||
|
|
||||||
|
public string ItemId = "";
|
||||||
|
public State Current = State.Empty;
|
||||||
|
public readonly bool Compact;
|
||||||
|
readonly LuneGlyphElement _glyph;
|
||||||
|
readonly Label _name;
|
||||||
|
readonly Label _effect;
|
||||||
|
readonly Label _cost;
|
||||||
|
readonly LuneStatusBadge _badge;
|
||||||
|
|
||||||
|
public static Color ColorOf(string id)
|
||||||
|
{
|
||||||
|
return ItemColors.TryGetValue(id ?? "", out Color color) ? color : LuneUiTheme.Muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LuneItemSlot(bool compact = true) : base(null, compact ? LuneButtons.HudChip : LuneButtons.HudCard)
|
||||||
|
{
|
||||||
|
Compact = compact;
|
||||||
|
if (compact)
|
||||||
|
{
|
||||||
|
style.width = 30f;
|
||||||
|
style.height = 30f;
|
||||||
|
style.paddingLeft = 5f;
|
||||||
|
style.paddingRight = 5f;
|
||||||
|
style.paddingTop = 5f;
|
||||||
|
style.paddingBottom = 5f;
|
||||||
|
_glyph = LuneUi.Glyph("", Color.white, 20f, this);
|
||||||
|
_glyph.style.flexGrow = 1f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
style.minHeight = 74f;
|
||||||
|
style.justifyContent = Justify.FlexStart;
|
||||||
|
style.paddingLeft = 10f;
|
||||||
|
style.paddingRight = 8f;
|
||||||
|
style.paddingTop = 6f;
|
||||||
|
style.paddingBottom = 6f;
|
||||||
|
_glyph = LuneUi.Glyph("", Color.white, 34f, this);
|
||||||
|
VisualElement column = LuneUi.Box(true, 0f, this);
|
||||||
|
column.style.marginLeft = 10f;
|
||||||
|
column.style.flexGrow = 1f;
|
||||||
|
column.style.flexShrink = 1f;
|
||||||
|
column.style.minWidth = 0f;
|
||||||
|
column.style.justifyContent = Justify.Center;
|
||||||
|
VisualElement head = LuneUi.Box(false, 6f, column);
|
||||||
|
_name = LuneUi.Clip(LuneUi.Label("", "HudValue", head));
|
||||||
|
_name.style.flexGrow = 1f;
|
||||||
|
LuneUi.Glyph("coin", LuneUiTheme.Gold, 14f, head);
|
||||||
|
_cost = LuneUi.Label("", "HudValue", head);
|
||||||
|
_effect = LuneUi.Clip(LuneUi.Label("", "HudMuted", column));
|
||||||
|
_badge = new LuneStatusBadge();
|
||||||
|
_badge.style.alignSelf = Align.FlexStart;
|
||||||
|
column.Add(_badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetItem(string id, State state)
|
||||||
|
{
|
||||||
|
ItemId = id ?? "";
|
||||||
|
Current = state;
|
||||||
|
Color tint = ColorOf(ItemId);
|
||||||
|
bool usable = state == State.Available || state == State.Equipped;
|
||||||
|
_glyph.Glyph = ItemId;
|
||||||
|
_glyph.Color = usable || Compact ? tint : LuneUiTheme.WithAlpha(tint, .45f);
|
||||||
|
if (Compact)
|
||||||
|
{
|
||||||
|
Disabled = ItemId.Length == 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LuneContent.Items.TryGetValue(ItemId, out ItemSpec item);
|
||||||
|
_name.text = item != null ? item.Name : "";
|
||||||
|
_cost.text = item != null ? item.Cost.ToString() : "";
|
||||||
|
LuneUi.SetColor(_cost, state == State.Unaffordable ? LuneUiTheme.Danger : LuneUiTheme.Bright);
|
||||||
|
_effect.text = item != null ? item.Text : "";
|
||||||
|
Color badge = state == State.Equipped ? LuneUiTheme.Good : state == State.Available ? LuneUiTheme.BronzeLight : LuneUiTheme.Muted;
|
||||||
|
if (state == State.Unaffordable)
|
||||||
|
badge = LuneUiTheme.Danger;
|
||||||
|
string glyph = state == State.Equipped || state == State.Unique ? "check" : state == State.Incompatible || state == State.Locked ? "lock" : "";
|
||||||
|
_badge.SetState(StateText[state], badge, glyph, state == State.Equipped);
|
||||||
|
Disabled = !usable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short event list (Godot LuneEventFeed): tower falls, champions, boss, critical Ancients,
|
||||||
|
/// round results. At most five entries, newest on top; each fades after a while. A click on an
|
||||||
|
/// entry with a place moves the camera there. It never lists every kill.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneEventFeed : VisualElement
|
||||||
|
{
|
||||||
|
public const int Limit = 5;
|
||||||
|
public const float Lifetime = 14f;
|
||||||
|
public event Action<LuneFocusTarget> FocusRequested;
|
||||||
|
|
||||||
|
sealed class Entry
|
||||||
|
{
|
||||||
|
public string Text;
|
||||||
|
public string Level;
|
||||||
|
public float Born;
|
||||||
|
public LuneButton Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly List<Entry> _entries = new List<Entry>();
|
||||||
|
float _clock;
|
||||||
|
|
||||||
|
public LuneEventFeed()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Texts
|
||||||
|
{
|
||||||
|
get { return _entries.ConvertAll(e => e.Text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color LevelColor(string level)
|
||||||
|
{
|
||||||
|
return level == "good" ? LuneUiTheme.Good : level == "warn" ? LuneUiTheme.Warn : level == "danger" ? LuneUiTheme.Danger : LuneUiTheme.Info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>level: info, good, warn, danger. target: null or a lane, point or camera target.</summary>
|
||||||
|
public void Push(string text, string level = "info", string glyph = "", LuneFocusTarget target = null)
|
||||||
|
{
|
||||||
|
foreach (Entry entry in _entries)
|
||||||
|
{
|
||||||
|
if (entry.Text == text && _clock - entry.Born < 4f)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var button = new LuneButton(null, LuneButtons.HudRow);
|
||||||
|
button.style.minHeight = 30f;
|
||||||
|
button.style.justifyContent = Justify.FlexStart;
|
||||||
|
button.style.paddingLeft = 8f;
|
||||||
|
button.style.paddingRight = 8f;
|
||||||
|
button.style.marginBottom = 3f;
|
||||||
|
Color tint = LevelColor(level);
|
||||||
|
LuneUi.Glyph(glyph.Length > 0 ? glyph : "star", tint, 15f, button).style.marginRight = 7f;
|
||||||
|
Label label = LuneUi.Clip(LuneUi.Label(text, "HudText", button));
|
||||||
|
label.style.flexGrow = 1f;
|
||||||
|
LuneUi.SetColor(label, Color.Lerp(tint, LuneUiTheme.Text, .45f));
|
||||||
|
bool hasTarget = target != null && !target.IsEmpty;
|
||||||
|
button.Clicked += () =>
|
||||||
|
{
|
||||||
|
if (hasTarget)
|
||||||
|
FocusRequested?.Invoke(target);
|
||||||
|
};
|
||||||
|
LuneTooltip.Attach(button, () => new LuneTip { Title = text, Note = hasTarget ? "Klick: Kamera dorthin" : "" });
|
||||||
|
Insert(0, button);
|
||||||
|
_entries.Insert(0, new Entry { Text = text, Level = level, Born = _clock, Node = button });
|
||||||
|
while (_entries.Count > Limit)
|
||||||
|
{
|
||||||
|
Entry old = _entries[_entries.Count - 1];
|
||||||
|
_entries.RemoveAt(_entries.Count - 1);
|
||||||
|
old.Node.RemoveFromHierarchy();
|
||||||
|
}
|
||||||
|
LuneUi.Show(this, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
foreach (Entry entry in _entries)
|
||||||
|
entry.Node.RemoveFromHierarchy();
|
||||||
|
_entries.Clear();
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(float delta)
|
||||||
|
{
|
||||||
|
_clock += delta;
|
||||||
|
for (int index = _entries.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
Entry entry = _entries[index];
|
||||||
|
float age = _clock - entry.Born;
|
||||||
|
if (age > Lifetime)
|
||||||
|
{
|
||||||
|
entry.Node.RemoveFromHierarchy();
|
||||||
|
_entries.RemoveAt(index);
|
||||||
|
}
|
||||||
|
else if (age > Lifetime - 2f)
|
||||||
|
{
|
||||||
|
entry.Node.style.opacity = Mathf.Clamp01((Lifetime - age) / 2f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LuneUi.Show(this, _entries.Count > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A window over the field for detail work (hero, recruiting; Godot LuneModalPanel). A light
|
||||||
|
/// backdrop keeps clicks away from the world and closes the window; Esc and the × close it too.
|
||||||
|
/// The window never covers the whole screen and only opens on request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneModalPanel : VisualElement
|
||||||
|
{
|
||||||
|
public event Action Closed;
|
||||||
|
public readonly LunePanel Panel;
|
||||||
|
public readonly VisualElement Body;
|
||||||
|
public readonly Label Title;
|
||||||
|
readonly Vector2 _box;
|
||||||
|
|
||||||
|
public LuneModalPanel(Vector2 size)
|
||||||
|
{
|
||||||
|
_box = size;
|
||||||
|
style.position = Position.Absolute;
|
||||||
|
style.left = 0f;
|
||||||
|
style.top = 0f;
|
||||||
|
style.right = 0f;
|
||||||
|
style.bottom = 0f;
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
var backdrop = new VisualElement();
|
||||||
|
backdrop.style.position = Position.Absolute;
|
||||||
|
backdrop.style.left = 0f;
|
||||||
|
backdrop.style.top = 0f;
|
||||||
|
backdrop.style.right = 0f;
|
||||||
|
backdrop.style.bottom = 0f;
|
||||||
|
backdrop.style.backgroundColor = new Color(.01f, .015f, .025f, .38f);
|
||||||
|
backdrop.RegisterCallback<PointerDownEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (evt.button == 0)
|
||||||
|
Close();
|
||||||
|
});
|
||||||
|
Add(backdrop);
|
||||||
|
Panel = new LunePanel(LunePanels.Modal);
|
||||||
|
Panel.style.position = Position.Absolute;
|
||||||
|
Add(Panel);
|
||||||
|
VisualElement column = LuneUi.Box(true, 12f, Panel);
|
||||||
|
column.style.flexGrow = 1f;
|
||||||
|
VisualElement head = LuneUi.Box(false, 0f, column);
|
||||||
|
Title = LuneUi.Label("", "HudTitle", head);
|
||||||
|
Title.style.flexGrow = 1f;
|
||||||
|
var close = new LuneButton(null, LuneButtons.HudIconButton, head);
|
||||||
|
close.style.width = 36f;
|
||||||
|
close.style.height = 34f;
|
||||||
|
LuneUi.Glyph("close", LuneUiTheme.Muted, 14f, close);
|
||||||
|
close.Clicked += Close;
|
||||||
|
LuneTooltip.Attach(close, () => new LuneTip { Title = "Schließen", Text = "Esc oder Klick neben das Fenster." });
|
||||||
|
Body = LuneUi.Box(true, 10f, column);
|
||||||
|
Body.style.flexGrow = 1f;
|
||||||
|
RegisterCallback<GeometryChangedEvent>(_ => Layout());
|
||||||
|
RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Open { get { return style.display != DisplayStyle.None; } }
|
||||||
|
|
||||||
|
public void OpenWith(string heading)
|
||||||
|
{
|
||||||
|
Title.text = heading;
|
||||||
|
LuneUi.Show(this, true);
|
||||||
|
BringToFront();
|
||||||
|
Layout();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!Open)
|
||||||
|
return;
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
Closed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Layout()
|
||||||
|
{
|
||||||
|
float width = layout.width;
|
||||||
|
float height = layout.height;
|
||||||
|
if (float.IsNaN(width) || width <= 0f)
|
||||||
|
return;
|
||||||
|
var edge = new Vector2(Mathf.Min(_box.x, width - 40f), Mathf.Min(_box.y, height - 40f));
|
||||||
|
Panel.style.width = edge.x;
|
||||||
|
Panel.style.height = edge.y;
|
||||||
|
Panel.style.left = Mathf.Floor((width - edge.x) * .5f);
|
||||||
|
Panel.style.top = Mathf.Floor((height - edge.y) * .5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneComponents.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 80ace1f0cf9a54de09fffe1461c041a1
|
||||||
682
Assets/Luneraria/UI/Hud/LuneElements.cs
Normal file
|
|
@ -0,0 +1,682 @@
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>The plate styles of the HUD panels (Godot "panel" styleboxes of lune_ui_theme.gd and the menu theme).</summary>
|
||||||
|
public static class LunePanels
|
||||||
|
{
|
||||||
|
public static readonly LunePlateStyle Hud = LunePlateStyle.Hud(LuneUiTheme.SlateTop, LuneUiTheme.SlateBottom, LuneUiTheme.Edge, 1f, 6f, 12f, 10f);
|
||||||
|
public static readonly LunePlateStyle HudTight = LunePlateStyle.Hud(LuneUiTheme.SlateTop, LuneUiTheme.SlateBottom, LuneUiTheme.Edge, 1f, 5f, 8f, 6f);
|
||||||
|
public static readonly LunePlateStyle Modal = LunePlateStyle.Hud(new Color(.07f, .09f, .125f, .98f), new Color(.045f, .06f, .085f, .98f), LuneUiTheme.Hex("5c4a33"), 1.5f, 10f, 22f, 18f)
|
||||||
|
.WithShadow(new Color(0f, 0f, 0f, .55f), 16f);
|
||||||
|
public static readonly LunePlateStyle BannerDanger = LunePlateStyle.Hud(new Color(.16f, .075f, .05f, .94f), new Color(.11f, .05f, .035f, .95f), LuneUiTheme.Hex("a0523a"), 1.5f, 6f, 16f, 8f);
|
||||||
|
public static readonly LunePlateStyle BannerOwn = LunePlateStyle.Hud(new Color(.07f, .1f, .16f, .94f), new Color(.05f, .07f, .11f, .95f), LuneUiTheme.Hex("5f86b8"), 1.5f, 6f, 16f, 8f);
|
||||||
|
public static readonly LunePlateStyle Tooltip = LunePlateStyle.Hud(new Color(.06f, .08f, .11f, .98f), new Color(.045f, .06f, .085f, .98f), LuneUiTheme.Hex("6b5a44"), 1f, 6f, 12f, 9f)
|
||||||
|
.WithShadow(new Color(0f, 0f, 0f, .5f), 8f);
|
||||||
|
public static readonly LunePlateStyle Menu = MenuPlate();
|
||||||
|
|
||||||
|
static LunePlateStyle MenuPlate()
|
||||||
|
{
|
||||||
|
LunePlateStyle style = LunePlateStyle.Menu(new Color(.078f, .094f, .114f, .95f), new Color(.055f, .066f, .08f, .96f), LuneUiTheme.Hex("5c4a33"), 1.5f, 12f, 30f, 26f)
|
||||||
|
.WithShadow(new Color(0f, 0f, 0f, .45f), 18f);
|
||||||
|
style.Ornaments = true;
|
||||||
|
style.OrnamentColor = LuneUiTheme.Hex("8a6a3c");
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A panel with a drawn plate background (Godot PanelContainer with a ForgedStyle).</summary>
|
||||||
|
public class LunePanel : VisualElement
|
||||||
|
{
|
||||||
|
LunePlateStyle _plate;
|
||||||
|
|
||||||
|
public LunePanel(LunePlateStyle plate, bool stopsMouse = true)
|
||||||
|
{
|
||||||
|
pickingMode = stopsMouse ? PickingMode.Position : PickingMode.Ignore;
|
||||||
|
Plate = plate;
|
||||||
|
generateVisualContent += context =>
|
||||||
|
{
|
||||||
|
if (_plate != null)
|
||||||
|
LunePlates.Draw(context, new Rect(0f, 0f, layout.width, layout.height), _plate);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public LunePlateStyle Plate
|
||||||
|
{
|
||||||
|
get { return _plate; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _plate)
|
||||||
|
return;
|
||||||
|
_plate = value;
|
||||||
|
value?.ApplyPadding(this);
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Look of a button variation: plates per state and the text colours (Godot Button theme items).</summary>
|
||||||
|
public sealed class LuneButtonLook
|
||||||
|
{
|
||||||
|
public LunePlateStyle Normal;
|
||||||
|
public LunePlateStyle Hover;
|
||||||
|
public LunePlateStyle Pressed;
|
||||||
|
public LunePlateStyle Disabled;
|
||||||
|
public LunePlateStyle HoverPressed;
|
||||||
|
/// <summary>Flat tab look: background and bottom line per state instead of plates.</summary>
|
||||||
|
public bool Tab;
|
||||||
|
public string TextVariation = "HudText";
|
||||||
|
public float TextSize = 15f;
|
||||||
|
public Color Text = LuneUiTheme.Text;
|
||||||
|
public Color TextHover = Color.white;
|
||||||
|
public Color? TextPressed;
|
||||||
|
public Color? TextHoverPressed;
|
||||||
|
public Color TextDisabled = LuneUiTheme.Faint;
|
||||||
|
|
||||||
|
public LunePlateStyle PlateFor(bool disabled, bool pressed, bool hover)
|
||||||
|
{
|
||||||
|
if (disabled)
|
||||||
|
return Disabled ?? Normal;
|
||||||
|
if (pressed && hover)
|
||||||
|
return HoverPressed ?? Pressed ?? Normal;
|
||||||
|
if (pressed)
|
||||||
|
return Pressed ?? Normal;
|
||||||
|
if (hover)
|
||||||
|
return Hover ?? Normal;
|
||||||
|
return Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color TextFor(bool disabled, bool pressed, bool hover)
|
||||||
|
{
|
||||||
|
if (disabled)
|
||||||
|
return TextDisabled;
|
||||||
|
if (pressed && hover)
|
||||||
|
return TextHoverPressed ?? TextPressed ?? TextHover;
|
||||||
|
if (pressed)
|
||||||
|
return TextPressed ?? Text;
|
||||||
|
return hover ? TextHover : Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The button variations of lune_ui_theme.gd and the menu theme.</summary>
|
||||||
|
public static class LuneButtons
|
||||||
|
{
|
||||||
|
static LunePlateStyle P(string top, string bottom, Color edge, float width, float cut, float x, float y)
|
||||||
|
{
|
||||||
|
return LunePlateStyle.Hud(LuneUiTheme.Hex(top), LuneUiTheme.Hex(bottom), edge, width, cut, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LunePlateStyle P(Color top, Color bottom, Color edge, float width, float cut, float x, float y)
|
||||||
|
{
|
||||||
|
return LunePlateStyle.Hud(top, bottom, edge, width, cut, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LunePlateStyle M(string top, string bottom, Color edge, float width, float cut, float x, float y)
|
||||||
|
{
|
||||||
|
return LunePlateStyle.Menu(LuneUiTheme.Hex(top), LuneUiTheme.Hex(bottom), edge, width, cut, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color H(string hex) { return LuneUiTheme.Hex(hex); }
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudButton = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("1b232d", "131920", H("4a5a6c"), 1f, 5f, 12f, 7f), Hover = P("26303c", "1a212a", LuneUiTheme.BronzeLight, 1f, 5f, 12f, 7f),
|
||||||
|
Pressed = P("10151b", "171d25", LuneUiTheme.Bronze, 1.5f, 5f, 12f, 7f), Disabled = P("141a20", "10141a", H("2c333c"), 1f, 5f, 12f, 7f),
|
||||||
|
TextSize = 15f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudIconButton = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("1b232d", "131920", H("3c4a5a"), 1f, 5f, 9f, 6f), Hover = P("26303c", "1a212a", LuneUiTheme.BronzeLight, 1f, 5f, 9f, 6f),
|
||||||
|
Pressed = P("10151b", "171d25", LuneUiTheme.Bronze, 1.5f, 5f, 9f, 6f), Disabled = P("141a20", "10141a", H("2c333c"), 1f, 5f, 9f, 6f),
|
||||||
|
TextSize = 15f,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudPrimary = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = M("e8c47e", "b98a4a", H("fff0cc"), 1.5f, 7f, 16f, 8f), Hover = M("f6d898", "cc9c56", Color.white, 1.5f, 7f, 16f, 8f),
|
||||||
|
Pressed = M("b98a4a", "d5aa66", H("fff0cc"), 2f, 7f, 16f, 8f), Disabled = M("4a4234", "3a3328", H("6a5c46"), 1f, 7f, 16f, 8f),
|
||||||
|
TextVariation = "HudValue", TextSize = 16f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("9c8f78"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Godot PrimaryButton of the menu theme (Bereit, Neue Partie).</summary>
|
||||||
|
public static readonly LuneButtonLook Primary = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = M("e8c47e", "b98a4a", H("fff0cc"), 1.5f, 9f, 30f, 15f), Hover = M("f6d898", "cc9c56", Color.white, 1.5f, 9f, 30f, 15f),
|
||||||
|
Pressed = M("b98a4a", "d5aa66", H("fff0cc"), 2f, 9f, 30f, 15f), Disabled = M("5a4c38", "463a2a", H("7d6a4c"), 1.5f, 9f, 30f, 15f),
|
||||||
|
TextVariation = "HudName", TextSize = 22f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("2a2218"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Godot Button of the menu theme (secondary).</summary>
|
||||||
|
public static readonly LuneButtonLook Secondary = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = M("1d232b", "141920", H("6b5a44"), 1.5f, 7f, 20f, 11f), Hover = M("27303a", "1a2028", LuneUiTheme.BronzeLight, 1.5f, 7f, 20f, 11f),
|
||||||
|
Pressed = M("10141a", "171c23", LuneUiTheme.Bronze, 2f, 7f, 20f, 11f), Disabled = M("15191e", "111418", H("3a352e"), 1.5f, 7f, 20f, 11f),
|
||||||
|
TextSize = 17f, Text = LuneUiTheme.Parchment,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudCard = Card(new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("19222c", "10161d", H("34424f"), 1f, 7f, 6f, 6f), Hover = P("212c38", "141b23", LuneUiTheme.Bronze, 1.5f, 7f, 6f, 6f),
|
||||||
|
Pressed = P("2a2820", "17171a", LuneUiTheme.BronzeLight, 2f, 7f, 6f, 6f), Disabled = P("12171d", "0d1116", H("252c34"), 1f, 7f, 6f, 6f),
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudChampionCard = Card(new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("261c14", "15100c", H("6e4a2c"), 1f, 7f, 6f, 6f), Hover = P("302216", "1b140e", LuneUiTheme.Champion, 1.5f, 7f, 6f, 6f),
|
||||||
|
Pressed = P("3a2818", "1e150e", H("ffc88c"), 2f, 7f, 6f, 6f), Disabled = P("16110d", "0f0c0a", H("3a2c20"), 1f, 7f, 6f, 6f),
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudChip = Card(new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("1a232d", "11171e", H("33404d"), 1f, 4f, 2f, 2f), Hover = P("222d39", "151c24", LuneUiTheme.Bronze, 1f, 4f, 2f, 2f),
|
||||||
|
Pressed = P("2c2a22", "17171a", LuneUiTheme.BronzeLight, 2f, 4f, 2f, 2f), Disabled = P("12171d", "0d1116", H("252c34"), 1f, 4f, 2f, 2f),
|
||||||
|
});
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudRow = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P(new Color(.09f, .12f, .16f, .55f), new Color(.06f, .08f, .11f, .55f), H("2a3645"), 1f, 5f, 10f, 7f),
|
||||||
|
Hover = P(new Color(.13f, .17f, .22f, .75f), new Color(.08f, .11f, .15f, .75f), LuneUiTheme.Bronze, 1f, 5f, 10f, 7f),
|
||||||
|
Pressed = P(new Color(.16f, .15f, .12f, .8f), new Color(.09f, .09f, .1f, .8f), LuneUiTheme.BronzeLight, 1.5f, 5f, 10f, 7f),
|
||||||
|
Disabled = P(new Color(.09f, .12f, .16f, .55f), new Color(.06f, .08f, .11f, .55f), H("2a3645"), 1f, 5f, 10f, 7f),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudToggle = Toggle();
|
||||||
|
|
||||||
|
static LuneButtonLook Toggle()
|
||||||
|
{
|
||||||
|
LunePlateStyle on = P("3a3222", "262117", LuneUiTheme.BronzeLight, 1.5f, 5f, 10f, 6f).WithInner(LuneUiTheme.WithAlpha(LuneUiTheme.BronzeLight, .35f));
|
||||||
|
return new LuneButtonLook
|
||||||
|
{
|
||||||
|
Normal = P("171e27", "11161d", H("3a4756"), 1f, 5f, 10f, 6f), Hover = P("212a35", "171d25", LuneUiTheme.Bronze, 1f, 5f, 10f, 6f),
|
||||||
|
Pressed = on, HoverPressed = on, Disabled = P("11151a", "0e1115", H("242a31"), 1f, 5f, 10f, 6f),
|
||||||
|
TextVariation = "HudBadge", TextSize = 14f, Text = H("cfd5dc"), TextHover = Color.white, TextPressed = H("fff4dc"), TextHoverPressed = H("fff4dc"), TextDisabled = H("59616b"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly LuneButtonLook HudTab = new LuneButtonLook
|
||||||
|
{
|
||||||
|
Tab = true, TextVariation = "HudCaps", TextSize = 14f, Text = LuneUiTheme.Muted, TextHover = LuneUiTheme.Text, TextPressed = LuneUiTheme.BronzeLight,
|
||||||
|
TextHoverPressed = LuneUiTheme.BronzeLight, TextDisabled = LuneUiTheme.Faint,
|
||||||
|
};
|
||||||
|
|
||||||
|
static LuneButtonLook Card(LuneButtonLook look)
|
||||||
|
{
|
||||||
|
look.HoverPressed = look.Pressed;
|
||||||
|
return look;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Button of the HUD (Godot Button with a theme variation). Draws the plate of its state;
|
||||||
|
/// `Disabled` greys it out and blocks clicks but keeps hover, so tooltips can say why (as
|
||||||
|
/// in G3). Toggle buttons keep their pressed state.
|
||||||
|
/// </summary>
|
||||||
|
public class LuneButton : VisualElement
|
||||||
|
{
|
||||||
|
public event Action Clicked;
|
||||||
|
public readonly Label TextLabel;
|
||||||
|
public bool ToggleMode;
|
||||||
|
LuneButtonLook _look;
|
||||||
|
bool _hover;
|
||||||
|
bool _down;
|
||||||
|
bool _on;
|
||||||
|
bool _disabled;
|
||||||
|
|
||||||
|
public LuneButton(string text, LuneButtonLook look, VisualElement parent = null)
|
||||||
|
{
|
||||||
|
_look = look;
|
||||||
|
focusable = false;
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
style.justifyContent = Justify.Center;
|
||||||
|
if (text != null)
|
||||||
|
{
|
||||||
|
TextLabel = LuneUi.Label(text, look.TextVariation, this, look.TextSize);
|
||||||
|
TextLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||||
|
}
|
||||||
|
ApplyPadding();
|
||||||
|
generateVisualContent += Draw;
|
||||||
|
RegisterCallback<PointerEnterEvent>(_ => SetHover(true));
|
||||||
|
RegisterCallback<PointerLeaveEvent>(_ => SetHover(false));
|
||||||
|
RegisterCallback<PointerDownEvent>(OnDown);
|
||||||
|
RegisterCallback<PointerUpEvent>(OnUp);
|
||||||
|
RegisterCallback<PointerCaptureOutEvent>(_ => { _down = false; Refresh(); });
|
||||||
|
Refresh();
|
||||||
|
parent?.Add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LuneButtonLook Look
|
||||||
|
{
|
||||||
|
get { return _look; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _look)
|
||||||
|
return;
|
||||||
|
_look = value;
|
||||||
|
if (TextLabel != null)
|
||||||
|
LuneUi.Apply(TextLabel, value.TextVariation, value.TextSize);
|
||||||
|
ApplyPadding();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Text
|
||||||
|
{
|
||||||
|
get { return TextLabel != null ? TextLabel.text : ""; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (TextLabel != null && TextLabel.text != value)
|
||||||
|
TextLabel.text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Disabled
|
||||||
|
{
|
||||||
|
get { return _disabled; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _disabled)
|
||||||
|
return;
|
||||||
|
_disabled = value;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pressed state of a toggle (Godot set_pressed_no_signal).</summary>
|
||||||
|
public bool On
|
||||||
|
{
|
||||||
|
get { return _on; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _on)
|
||||||
|
return;
|
||||||
|
_on = value;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Hovered { get { return _hover; } }
|
||||||
|
|
||||||
|
void ApplyPadding()
|
||||||
|
{
|
||||||
|
if (_look.Tab)
|
||||||
|
{
|
||||||
|
style.paddingLeft = 12f;
|
||||||
|
style.paddingRight = 12f;
|
||||||
|
style.paddingTop = 6f;
|
||||||
|
style.paddingBottom = 6f;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_look.Normal?.ApplyPadding(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetHover(bool value)
|
||||||
|
{
|
||||||
|
_hover = value;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnDown(PointerDownEvent evt)
|
||||||
|
{
|
||||||
|
if (evt.button != 0 || _disabled)
|
||||||
|
return;
|
||||||
|
_down = true;
|
||||||
|
this.CapturePointer(evt.pointerId);
|
||||||
|
Refresh();
|
||||||
|
evt.StopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnUp(PointerUpEvent evt)
|
||||||
|
{
|
||||||
|
if (evt.button != 0 || !_down)
|
||||||
|
return;
|
||||||
|
_down = false;
|
||||||
|
this.ReleasePointer(evt.pointerId);
|
||||||
|
bool inside = ContainsPoint(evt.localPosition);
|
||||||
|
Refresh();
|
||||||
|
evt.StopPropagation();
|
||||||
|
if (inside && !_disabled)
|
||||||
|
Click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Godot `pressed`: toggles first, then raises Clicked (also for tests and hotkeys).</summary>
|
||||||
|
public void Click()
|
||||||
|
{
|
||||||
|
if (_disabled)
|
||||||
|
return;
|
||||||
|
if (ToggleMode)
|
||||||
|
On = !_on;
|
||||||
|
Clicked?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Refresh()
|
||||||
|
{
|
||||||
|
bool pressed = _down || _on;
|
||||||
|
if (TextLabel != null)
|
||||||
|
LuneUi.SetColor(TextLabel, _look.TextFor(_disabled, pressed, _hover));
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
var rect = new Rect(0f, 0f, layout.width, layout.height);
|
||||||
|
bool pressed = _down || _on;
|
||||||
|
if (_look.Tab)
|
||||||
|
{
|
||||||
|
Painter2D painter = context.painter2D;
|
||||||
|
Color fill = pressed ? LuneUiTheme.WithAlpha(LuneUiTheme.BronzeLight, .08f) : _hover && !_disabled ? new Color(1f, 1f, 1f, .04f) : Color.clear;
|
||||||
|
Color line = pressed ? LuneUiTheme.BronzeLight : _hover && !_disabled ? LuneUiTheme.WithAlpha(LuneUiTheme.Bronze, .5f) : Color.clear;
|
||||||
|
if (fill.a > 0f)
|
||||||
|
LunePlates.Polygon(painter, new[] { rect.min, new Vector2(rect.xMax, rect.yMin), rect.max, new Vector2(rect.xMin, rect.yMax) }, fill);
|
||||||
|
if (line.a > 0f)
|
||||||
|
LunePlates.Line(painter, new Vector2(rect.xMin, rect.yMax - 1f), new Vector2(rect.xMax, rect.yMax - 1f), line, 2f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LunePlateStyle plate = _look.PlateFor(_disabled, pressed, _hover && !_disabled);
|
||||||
|
if (plate != null)
|
||||||
|
LunePlates.Draw(context, rect, plate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A LuneGlyphs symbol as an element (Godot LuneGlyph). Ignores the mouse.</summary>
|
||||||
|
public sealed class LuneGlyphElement : VisualElement
|
||||||
|
{
|
||||||
|
string _glyph;
|
||||||
|
Color _color;
|
||||||
|
public Color Accent;
|
||||||
|
|
||||||
|
public LuneGlyphElement(string glyph, Color color, float edge = 18f)
|
||||||
|
{
|
||||||
|
_glyph = glyph;
|
||||||
|
_color = color;
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.width = edge;
|
||||||
|
style.height = edge;
|
||||||
|
style.flexShrink = 0f;
|
||||||
|
style.alignSelf = Align.Center;
|
||||||
|
generateVisualContent += context =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_glyph))
|
||||||
|
LuneGlyphs.Draw(context.painter2D, _glyph, new Rect(0f, 0f, layout.width, layout.height), _color, Accent);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Glyph
|
||||||
|
{
|
||||||
|
get { return _glyph; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _glyph)
|
||||||
|
return;
|
||||||
|
_glyph = value;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color Color
|
||||||
|
{
|
||||||
|
get { return _color; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _color)
|
||||||
|
return;
|
||||||
|
_color = value;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Edge
|
||||||
|
{
|
||||||
|
set
|
||||||
|
{
|
||||||
|
style.width = value;
|
||||||
|
style.height = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thin value bar drawn directly: dark track, fill, a top highlight and optional ticks (Godot LuneHpBar).</summary>
|
||||||
|
public sealed class LuneHpBar : VisualElement
|
||||||
|
{
|
||||||
|
float _fraction = 1f;
|
||||||
|
Color _fill = LuneUiTheme.Good;
|
||||||
|
public int Ticks;
|
||||||
|
public Color Track = new Color(0f, 0f, 0f, .55f);
|
||||||
|
|
||||||
|
public LuneHpBar(float width = 60f, float height = 6f)
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.minWidth = width;
|
||||||
|
style.height = height;
|
||||||
|
style.flexShrink = 0f;
|
||||||
|
generateVisualContent += Draw;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float Fraction
|
||||||
|
{
|
||||||
|
get { return _fraction; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
value = Mathf.Clamp01(value);
|
||||||
|
if (Mathf.Abs(value - _fraction) <= .0005f)
|
||||||
|
return;
|
||||||
|
_fraction = value;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color Fill
|
||||||
|
{
|
||||||
|
get { return _fill; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _fill)
|
||||||
|
return;
|
||||||
|
_fill = value;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D painter = context.painter2D;
|
||||||
|
var rect = new Rect(0f, 0f, layout.width, layout.height);
|
||||||
|
LunePlates.Polygon(painter, Corners(rect), Track);
|
||||||
|
if (_fraction > 0f)
|
||||||
|
{
|
||||||
|
var filled = new Rect(0f, 0f, Mathf.Max(1f, rect.width * _fraction), rect.height);
|
||||||
|
LunePlates.Polygon(painter, Corners(filled), _fill);
|
||||||
|
LunePlates.Line(painter, new Vector2(0f, .5f), new Vector2(filled.width, .5f), new Color(1f, 1f, 1f, .28f), 1f);
|
||||||
|
}
|
||||||
|
for (int index = 1; index < Ticks; index++)
|
||||||
|
{
|
||||||
|
float x = Mathf.Round(rect.width * index / Ticks);
|
||||||
|
LunePlates.Line(painter, new Vector2(x, 0f), new Vector2(x, rect.height), new Color(0f, 0f, 0f, .45f), 1f);
|
||||||
|
}
|
||||||
|
LunePlates.Outline(painter, Corners(new Rect(.5f, .5f, rect.width - 1f, rect.height - 1f)), new Color(1f, 1f, 1f, .12f), 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector2[] Corners(Rect rect)
|
||||||
|
{
|
||||||
|
return new[] { rect.min, new Vector2(rect.xMax, rect.yMin), rect.max, new Vector2(rect.xMin, rect.yMax) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Green above 60 %, amber above 30 %, red below; the team colour when given.</summary>
|
||||||
|
public static Color HealthColor(float value, Color? baseColor = null)
|
||||||
|
{
|
||||||
|
if (value > .6f)
|
||||||
|
return baseColor ?? LuneUiTheme.Good;
|
||||||
|
if (value > .3f)
|
||||||
|
return LuneUiTheme.Warn;
|
||||||
|
return LuneUiTheme.Danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One building as a symbol in the team's shape language (round = Blau, angular = Rot) with its
|
||||||
|
/// HP as a bar underneath (Godot LuneTowerIndicator). Destroyed buildings remain as a hollow
|
||||||
|
/// outline with a cross.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneTowerIndicator : VisualElement
|
||||||
|
{
|
||||||
|
public int Team;
|
||||||
|
public string Tier = "outer";
|
||||||
|
public bool Alive = true;
|
||||||
|
public float Fraction = 1f;
|
||||||
|
public bool Attackable;
|
||||||
|
|
||||||
|
public LuneTowerIndicator(float width = 22f, float height = 27f)
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
style.width = width;
|
||||||
|
style.height = height;
|
||||||
|
style.flexShrink = 0f;
|
||||||
|
generateVisualContent += Draw;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SetState(bool alive, float fraction, bool attackable)
|
||||||
|
{
|
||||||
|
float value = Mathf.Round(Mathf.Clamp01(fraction) * 100f) / 100f;
|
||||||
|
if (alive == Alive && Mathf.Approximately(value, Fraction) && attackable == Attackable)
|
||||||
|
return false;
|
||||||
|
Alive = alive;
|
||||||
|
Fraction = value;
|
||||||
|
Attackable = attackable;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D painter = context.painter2D;
|
||||||
|
float edge = Mathf.Min(layout.width, layout.height - 5f);
|
||||||
|
var box = new Rect((layout.width - edge) * .5f, 0f, edge, edge);
|
||||||
|
Color tint = LuneUiTheme.TeamColor(Team);
|
||||||
|
string shape = "shape_" + Mathf.Clamp(Team, 0, 1);
|
||||||
|
if (Alive)
|
||||||
|
{
|
||||||
|
LuneGlyphs.Draw(painter, shape, box, LuneUiTheme.Lightened(LuneUiTheme.TeamDark[Mathf.Clamp(Team, 0, 1)], .1f));
|
||||||
|
LuneGlyphs.Draw(painter, shape, Grow(box, -edge * .08f), LuneUiTheme.WithAlpha(tint, .35f));
|
||||||
|
LuneGlyphs.Draw(painter, Tier == "core" ? "ancient" : "tower", Grow(box, -edge * .24f), LuneUiTheme.Hex("f4efe4"));
|
||||||
|
var bar = new Rect(box.x + 1f, layout.height - 3f, box.width - 2f, 3f);
|
||||||
|
LunePlates.Polygon(painter, LuneHpBar.Corners(bar), new Color(0f, 0f, 0f, .6f));
|
||||||
|
if (Fraction > 0f)
|
||||||
|
LunePlates.Polygon(painter, LuneHpBar.Corners(new Rect(bar.x, bar.y, bar.width * Fraction, bar.height)), LuneHpBar.HealthColor(Fraction, tint));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LuneGlyphs.Draw(painter, shape, box, new Color(0f, 0f, 0f, .35f));
|
||||||
|
LuneGlyphs.Draw(painter, "close", Grow(box, -edge * .18f), new Color(.5f, .52f, .55f, .8f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Rect Grow(Rect rect, float amount)
|
||||||
|
{
|
||||||
|
return new Rect(rect.x - amount, rect.y - amount, rect.width + amount * 2f, rect.height + amount * 2f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small labelled state (RUHIG, KRITISCH, BEREIT, BOT …; Godot LuneStatusBadge). Always colour
|
||||||
|
/// plus text and, where it helps, a symbol; never colour alone.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneStatusBadge : VisualElement
|
||||||
|
{
|
||||||
|
readonly LuneGlyphElement _glyph;
|
||||||
|
readonly Label _label;
|
||||||
|
public string Text { get; private set; } = "";
|
||||||
|
Color _color;
|
||||||
|
bool _filled;
|
||||||
|
|
||||||
|
public LuneStatusBadge()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
style.alignSelf = Align.Center;
|
||||||
|
style.flexShrink = 0f;
|
||||||
|
style.paddingLeft = 7f;
|
||||||
|
style.paddingRight = 7f;
|
||||||
|
style.paddingTop = 1f;
|
||||||
|
style.paddingBottom = 1f;
|
||||||
|
style.borderTopLeftRadius = 4f;
|
||||||
|
style.borderTopRightRadius = 4f;
|
||||||
|
style.borderBottomLeftRadius = 4f;
|
||||||
|
style.borderBottomRightRadius = 4f;
|
||||||
|
style.borderLeftWidth = 1f;
|
||||||
|
style.borderRightWidth = 1f;
|
||||||
|
style.borderTopWidth = 1f;
|
||||||
|
style.borderBottomWidth = 1f;
|
||||||
|
_glyph = LuneUi.Glyph("", Color.white, 12f, this);
|
||||||
|
_glyph.style.marginRight = 4f;
|
||||||
|
LuneUi.Show(_glyph, false);
|
||||||
|
_label = LuneUi.Label("", "HudBadge", this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetState(string text, Color color, string glyph = "", bool filled = false)
|
||||||
|
{
|
||||||
|
if (text == Text && color == _color && filled == _filled && glyph == _glyph.Glyph)
|
||||||
|
return;
|
||||||
|
Text = text;
|
||||||
|
_color = color;
|
||||||
|
_filled = filled;
|
||||||
|
_label.text = text;
|
||||||
|
Color ink = filled ? LuneUiTheme.Hex("15110b") : LuneUiTheme.Lightened(color, .15f);
|
||||||
|
LuneUi.SetColor(_label, ink);
|
||||||
|
LuneUi.Show(_glyph, !string.IsNullOrEmpty(glyph));
|
||||||
|
_glyph.Glyph = glyph;
|
||||||
|
_glyph.Color = ink;
|
||||||
|
Color background = filled ? LuneUiTheme.WithAlpha(color, .92f) : LuneUiTheme.WithAlpha(LuneUiTheme.Darkened(color, .75f), .85f);
|
||||||
|
Color border = filled ? LuneUiTheme.Lightened(color, .2f) : color;
|
||||||
|
style.backgroundColor = background;
|
||||||
|
style.borderLeftColor = border;
|
||||||
|
style.borderRightColor = border;
|
||||||
|
style.borderTopColor = border;
|
||||||
|
style.borderBottomColor = border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tabs with a bronze underline for the open one (Godot LuneTabBar). Select changes only the display; TabChanged fires on input.</summary>
|
||||||
|
public sealed class LuneTabBar : VisualElement
|
||||||
|
{
|
||||||
|
public event Action<int> TabChanged;
|
||||||
|
public int Current { get; private set; }
|
||||||
|
public readonly System.Collections.Generic.List<LuneButton> Tabs = new System.Collections.Generic.List<LuneButton>();
|
||||||
|
|
||||||
|
public LuneTabBar()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LuneButton AddTab(string title, string tipTitle = "", string tipText = "")
|
||||||
|
{
|
||||||
|
var tab = new LuneButton(title, LuneButtons.HudTab, this);
|
||||||
|
int index = Tabs.Count;
|
||||||
|
if (index > 0)
|
||||||
|
tab.style.marginLeft = 2f;
|
||||||
|
tab.Clicked += () =>
|
||||||
|
{
|
||||||
|
Select(index);
|
||||||
|
TabChanged?.Invoke(index);
|
||||||
|
};
|
||||||
|
if (tipTitle.Length > 0)
|
||||||
|
LuneTooltip.Attach(tab, () => new LuneTip { Title = tipTitle, Text = tipText });
|
||||||
|
Tabs.Add(tab);
|
||||||
|
tab.On = index == 0;
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Select(int index)
|
||||||
|
{
|
||||||
|
Current = Mathf.Clamp(index, 0, Tabs.Count - 1);
|
||||||
|
for (int i = 0; i < Tabs.Count; i++)
|
||||||
|
Tabs[i].On = i == Current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneElements.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 22ed841b521f545538f3d45ce0f7b730
|
||||||
609
Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
Normal file
|
|
@ -0,0 +1,609 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The hero in the bottom bar (Godot LuneHeroCard): portrait, name, level, experience
|
||||||
|
/// progress, HP, role, the three item slots and the talent state. A click opens the hero window
|
||||||
|
/// (details, items, talents) or, without a hero, the recruiting window. No MOBA action bar:
|
||||||
|
/// heroes act alone. The command slot is reserved for the ultimates of the planned heroes
|
||||||
|
/// (PlannedHeroes, trigger Command); it stays hidden while no such hero is playable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneHeroCard : LuneButton
|
||||||
|
{
|
||||||
|
public string HeroId = "";
|
||||||
|
public event Action CommandRequested;
|
||||||
|
readonly LunePortraitElement _portrait;
|
||||||
|
readonly Label _name;
|
||||||
|
readonly Label _level;
|
||||||
|
readonly LuneGlyphElement _roleGlyph;
|
||||||
|
readonly Label _role;
|
||||||
|
readonly LuneHpBar _xp;
|
||||||
|
readonly Label _xpText;
|
||||||
|
readonly Label _hp;
|
||||||
|
readonly LuneHpBar _hpBar;
|
||||||
|
readonly LuneGlyphElement[] _slots = new LuneGlyphElement[LuneContent.ItemSlots];
|
||||||
|
readonly LuneStatusBadge _talent;
|
||||||
|
readonly VisualElement _empty;
|
||||||
|
readonly VisualElement _filled;
|
||||||
|
readonly List<LunePortraitElement> _faces = new List<LunePortraitElement>();
|
||||||
|
readonly Label _emptyTitle;
|
||||||
|
readonly Label _emptyPrice;
|
||||||
|
readonly LuneGlyphElement _emptyCoin;
|
||||||
|
readonly VisualElement _facesRow;
|
||||||
|
/// <summary>Reserved: the ultimate of a planned hero (released through the command menu in combat).</summary>
|
||||||
|
public readonly LuneButton CommandSlot;
|
||||||
|
LuneHudModel _model;
|
||||||
|
HeroState _hero;
|
||||||
|
UnitDef _definition;
|
||||||
|
string _signature = "";
|
||||||
|
|
||||||
|
public LuneHeroCard() : base(null, LuneButtons.HudCard)
|
||||||
|
{
|
||||||
|
style.minWidth = 320f;
|
||||||
|
style.height = 132f;
|
||||||
|
style.justifyContent = Justify.FlexStart;
|
||||||
|
style.alignItems = Align.Stretch;
|
||||||
|
style.paddingLeft = 8f;
|
||||||
|
style.paddingRight = 8f;
|
||||||
|
style.paddingTop = 8f;
|
||||||
|
style.paddingBottom = 8f;
|
||||||
|
_portrait = new LunePortraitElement();
|
||||||
|
_portrait.style.width = 88f;
|
||||||
|
Add(_portrait);
|
||||||
|
_filled = LuneUi.Box(true, 2f, this);
|
||||||
|
_filled.style.marginLeft = 10f;
|
||||||
|
_filled.style.flexGrow = 1f;
|
||||||
|
_filled.style.flexShrink = 1f;
|
||||||
|
_filled.style.minWidth = 0f;
|
||||||
|
_name = LuneUi.Clip(LuneUi.Label("", "HudName", _filled));
|
||||||
|
VisualElement roleRow = LuneUi.Box(false, 5f, _filled);
|
||||||
|
LuneUi.Glyph("star", LuneUiTheme.Hero, 13f, roleRow);
|
||||||
|
_level = LuneUi.Label("", "HudValue", roleRow);
|
||||||
|
_roleGlyph = LuneUi.Glyph("tank", LuneUiTheme.Muted, 14f, roleRow);
|
||||||
|
_role = LuneUi.Label("", "HudMuted", roleRow);
|
||||||
|
VisualElement xpRow = LuneUi.Box(false, 6f, _filled);
|
||||||
|
_xp = new LuneHpBar(60f, 6f) { Fill = LuneUiTheme.BronzeLight };
|
||||||
|
_xp.style.flexGrow = 1f;
|
||||||
|
xpRow.Add(_xp);
|
||||||
|
_xpText = LuneUi.Label("", "HudMuted", xpRow);
|
||||||
|
VisualElement hpRow = LuneUi.Box(false, 5f, _filled);
|
||||||
|
LuneUi.Glyph("heart", LuneUiTheme.Lightened(LuneUiTheme.Danger, .2f), 13f, hpRow);
|
||||||
|
_hp = LuneUi.Label("", "HudText", hpRow);
|
||||||
|
_hpBar = new LuneHpBar(50f, 5f);
|
||||||
|
_hpBar.style.flexGrow = 1f;
|
||||||
|
hpRow.Add(_hpBar);
|
||||||
|
VisualElement itemRow = LuneUi.Box(false, 4f, _filled);
|
||||||
|
for (int index = 0; index < _slots.Length; index++)
|
||||||
|
{
|
||||||
|
var box = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
box.style.width = 26f;
|
||||||
|
box.style.height = 26f;
|
||||||
|
box.style.backgroundColor = new Color(0f, 0f, 0f, .35f);
|
||||||
|
box.style.borderTopLeftRadius = 3f;
|
||||||
|
box.style.borderTopRightRadius = 3f;
|
||||||
|
box.style.borderBottomLeftRadius = 3f;
|
||||||
|
box.style.borderBottomRightRadius = 3f;
|
||||||
|
box.style.borderLeftWidth = 1f;
|
||||||
|
box.style.borderRightWidth = 1f;
|
||||||
|
box.style.borderTopWidth = 1f;
|
||||||
|
box.style.borderBottomWidth = 1f;
|
||||||
|
Color edge = LuneUiTheme.Hex("33404d");
|
||||||
|
box.style.borderLeftColor = edge;
|
||||||
|
box.style.borderRightColor = edge;
|
||||||
|
box.style.borderTopColor = edge;
|
||||||
|
box.style.borderBottomColor = edge;
|
||||||
|
box.style.paddingLeft = 4f;
|
||||||
|
box.style.paddingTop = 4f;
|
||||||
|
itemRow.Add(box);
|
||||||
|
_slots[index] = LuneUi.Glyph("", Color.white, 18f, box);
|
||||||
|
}
|
||||||
|
_talent = new LuneStatusBadge();
|
||||||
|
itemRow.Add(_talent);
|
||||||
|
CommandSlot = new LuneButton("ULTIMATE", LuneButtons.HudPrimary, itemRow);
|
||||||
|
CommandSlot.Clicked += () => CommandRequested?.Invoke();
|
||||||
|
LuneUi.Show(CommandSlot, false);
|
||||||
|
_empty = LuneUi.Box(true, 3f, this);
|
||||||
|
_empty.style.marginLeft = 10f;
|
||||||
|
_empty.style.flexGrow = 1f;
|
||||||
|
_empty.style.justifyContent = Justify.Center;
|
||||||
|
_emptyTitle = LuneUi.Label("HELD ANWERBEN", "HudName", _empty);
|
||||||
|
_facesRow = LuneUi.Box(false, 4f, _empty);
|
||||||
|
foreach (string id in LuneContent.Heroes.Keys)
|
||||||
|
{
|
||||||
|
var face = new LunePortraitElement();
|
||||||
|
face.style.width = 38f;
|
||||||
|
face.style.height = 38f;
|
||||||
|
_facesRow.Add(face);
|
||||||
|
_faces.Add(face);
|
||||||
|
}
|
||||||
|
VisualElement price = LuneUi.Box(false, 4f, _empty);
|
||||||
|
_emptyCoin = LuneUi.Glyph("coin", LuneUiTheme.Gold, 14f, price);
|
||||||
|
_emptyPrice = LuneUi.Label("", "HudMuted", price);
|
||||||
|
LuneTooltip.Attach(this, Tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>hero: the own hero (draft in planning) or null; live: the combat unit or null.</summary>
|
||||||
|
public void Apply(HeroState hero, LuneHudModel model, LuneUnit live)
|
||||||
|
{
|
||||||
|
_model = model;
|
||||||
|
bool hasHero = hero != null;
|
||||||
|
if (live != null && hasHero)
|
||||||
|
{
|
||||||
|
_hp.text = live.Alive ? LuneUiTheme.Number(live.Hp) + " / " + LuneUiTheme.Number(live.MaxHp) : "gefallen";
|
||||||
|
_hpBar.Fraction = live.Alive ? (float)(live.Hp / Math.Max(1.0, live.MaxHp)) : 0f;
|
||||||
|
_hpBar.Fill = LuneHpBar.HealthColor(_hpBar.Fraction, LuneUiTheme.Good);
|
||||||
|
_portrait.Crossed = !live.Alive;
|
||||||
|
}
|
||||||
|
string signature = (hasHero ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) : "-") + "|"
|
||||||
|
+ (live != null) + "|" + model.OwnGold + "|" + model.Planning;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
LuneUi.Show(_filled, hasHero);
|
||||||
|
LuneUi.Show(_empty, !hasHero);
|
||||||
|
_hero = hero;
|
||||||
|
if (!hasHero)
|
||||||
|
{
|
||||||
|
HeroId = "";
|
||||||
|
_definition = null;
|
||||||
|
_portrait.ShowModel("w04");
|
||||||
|
_portrait.Dimmed = true;
|
||||||
|
_portrait.Crossed = false;
|
||||||
|
int index = 0;
|
||||||
|
foreach (HeroSpec spec in LuneContent.Heroes.Values)
|
||||||
|
_faces[index++].ShowModel(spec.Model);
|
||||||
|
// In combat nobody can recruit: say so instead of offering it.
|
||||||
|
bool planning = model.Planning;
|
||||||
|
_emptyTitle.text = planning ? "HELD ANWERBEN" : "KEIN HELD";
|
||||||
|
_emptyPrice.text = planning ? LuneContent.Heroes.Values[0].Cost + " · ein Held pro Spieler" : "Anwerben in der Planung";
|
||||||
|
LuneUi.Show(_emptyCoin, planning);
|
||||||
|
LuneUi.Show(_facesRow, planning);
|
||||||
|
LuneUi.Show(CommandSlot, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HeroId = hero.HeroId;
|
||||||
|
HeroSpec heroSpec = LuneContent.Heroes[HeroId];
|
||||||
|
_definition = model.HeroDefinition(hero);
|
||||||
|
_portrait.Dimmed = false;
|
||||||
|
_portrait.Crossed = live != null && !live.Alive;
|
||||||
|
_portrait.ShowModel(heroSpec.Model);
|
||||||
|
_name.text = heroSpec.Name;
|
||||||
|
int level = LuneContent.LevelFor(hero.Xp);
|
||||||
|
_level.text = "STUFE " + level;
|
||||||
|
_roleGlyph.Glyph = LuneStats.RoleGlyph(_definition.Role);
|
||||||
|
_role.text = LuneStats.RoleName(_definition.Role);
|
||||||
|
int[] levels = LuneContent.LevelXp;
|
||||||
|
int low = levels[level - 1];
|
||||||
|
bool top = level >= levels.Length;
|
||||||
|
int high = levels[Math.Min(level, levels.Length - 1)];
|
||||||
|
_xp.Fraction = top ? 1f : (float)(hero.Xp - low) / Math.Max(1, high - low);
|
||||||
|
_xpText.text = top ? "EP " + hero.Xp + " · max" : "EP " + hero.Xp + " / " + high;
|
||||||
|
LuneUi.Show(_hpBar, live != null);
|
||||||
|
if (live == null)
|
||||||
|
_hp.text = LuneUiTheme.Number(_definition.MaxHp) + " Leben";
|
||||||
|
for (int index = 0; index < _slots.Length; index++)
|
||||||
|
{
|
||||||
|
string itemId = index < hero.Items.Count ? hero.Items[index] : "";
|
||||||
|
_slots[index].Glyph = itemId;
|
||||||
|
_slots[index].Color = LuneItemSlot.ColorOf(itemId);
|
||||||
|
}
|
||||||
|
int openTier = hero.Talents.Count;
|
||||||
|
int[] talentLevels = LuneContent.TalentLevels;
|
||||||
|
if (openTier < talentLevels.Length && level >= talentLevels[openTier])
|
||||||
|
{
|
||||||
|
_talent.SetState("TALENT!", LuneUiTheme.Hero, "star", true);
|
||||||
|
LuneUi.Show(_talent, model.Planning);
|
||||||
|
}
|
||||||
|
else if (openTier < talentLevels.Length)
|
||||||
|
{
|
||||||
|
_talent.SetState("TALENT AB " + talentLevels[openTier], LuneUiTheme.Muted);
|
||||||
|
LuneUi.Show(_talent, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LuneUi.Show(_talent, false);
|
||||||
|
}
|
||||||
|
// The three reference heroes have no command ultimate; the planned heroes will.
|
||||||
|
PlannedHero planned = PlannedHeroes.All.FirstOrDefault(p => p.Active && p.Id == HeroId);
|
||||||
|
bool command = planned != null && planned.Ultimate.Trigger == PowerTrigger.Command;
|
||||||
|
LuneUi.Show(CommandSlot, command);
|
||||||
|
if (command)
|
||||||
|
CommandSlot.Text = planned.Ultimate.Name.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip Tip()
|
||||||
|
{
|
||||||
|
if (_hero == null || _definition == null)
|
||||||
|
return new LuneTip
|
||||||
|
{
|
||||||
|
Title = "Held anwerben", Glyph = "crown", GlyphColor = LuneUiTheme.Hero,
|
||||||
|
Text = "Drei Helden stehen zur Wahl; jeder Spieler führt einen. Helden sammeln Erfahrung, steigen bis Stufe 10 auf, tragen drei Items und wählen zwei Talente.",
|
||||||
|
Note = "Klick: Heldenauswahl öffnen",
|
||||||
|
};
|
||||||
|
HeroSpec spec = LuneContent.Heroes[_hero.HeroId];
|
||||||
|
List<string> names = _hero.Items.Select(id => LuneContent.Items[id].Name).ToList();
|
||||||
|
var tip = new LuneTip
|
||||||
|
{
|
||||||
|
Title = spec.Name + " · Stufe " + LuneContent.LevelFor(_hero.Xp), Glyph = "crown", GlyphColor = LuneUiTheme.Hero, Subtitle = spec.Title,
|
||||||
|
Text = "Items: " + (names.Count > 0 ? string.Join(", ", names) : "keine"), Note = "Klick: Werte, Items und Talente · H: Kamera zum Helden",
|
||||||
|
};
|
||||||
|
tip.Stats.AddRange(LuneStats.Rows(_definition));
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hero window content (Godot LuneHeroDetail). Without a hero: the three heroes with portrait,
|
||||||
|
/// role, real stats and price to recruit one. With a hero: portrait, level and experience, lane
|
||||||
|
/// order, and tabs for values and ability (from the rules' definitions, including level, items
|
||||||
|
/// and talents), the three item slots with the item shop, and the two talent tiers. Only shows
|
||||||
|
/// data; every change is a command through the game.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneHeroDetail : VisualElement
|
||||||
|
{
|
||||||
|
public const int TabValues = 0;
|
||||||
|
public const int TabItems = 1;
|
||||||
|
public const int TabTalents = 2;
|
||||||
|
|
||||||
|
public event Action<Command> CommandRequested;
|
||||||
|
public int Tab = TabValues;
|
||||||
|
public readonly Dictionary<string, LuneItemSlot> ItemCards = new Dictionary<string, LuneItemSlot>();
|
||||||
|
public readonly Dictionary<string, LuneButton> RecruitButtons = new Dictionary<string, LuneButton>();
|
||||||
|
public readonly Dictionary<string, LuneButton> LaneButtons = new Dictionary<string, LuneButton>();
|
||||||
|
public readonly List<LuneButton> TalentCards = new List<LuneButton>();
|
||||||
|
public LuneTabBar Tabs;
|
||||||
|
LuneHudModel _model;
|
||||||
|
string _signature = "";
|
||||||
|
|
||||||
|
public LuneHeroDetail()
|
||||||
|
{
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.flexGrow = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowTab(int index)
|
||||||
|
{
|
||||||
|
Tab = index;
|
||||||
|
_signature = "";
|
||||||
|
if (_model != null)
|
||||||
|
Apply(_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TitleText(LuneHudModel model)
|
||||||
|
{
|
||||||
|
HeroState hero = model.OwnHero;
|
||||||
|
return hero == null ? "HELD ANWERBEN" : "HELD · " + LuneContent.Heroes[hero.HeroId].Name.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(LuneHudModel model)
|
||||||
|
{
|
||||||
|
_model = model;
|
||||||
|
HeroState hero = model.OwnHero;
|
||||||
|
string signature = (hero != null ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + hero.Fresh + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) + ":" + string.Join(",", hero.FreshItems) : "-")
|
||||||
|
+ "|" + model.OwnGold + "|" + model.Planning + "|" + model.Locked + "|" + Tab + "|" + model.State.GrottoAvailable() + "|" + model.State.RoundNumber;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
Clear();
|
||||||
|
ItemCards.Clear();
|
||||||
|
RecruitButtons.Clear();
|
||||||
|
LaneButtons.Clear();
|
||||||
|
TalentCards.Clear();
|
||||||
|
Tabs = null;
|
||||||
|
if (hero == null)
|
||||||
|
Recruit(model);
|
||||||
|
else
|
||||||
|
ShowHero(model, hero);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool CanAct(LuneHudModel model) { return model.Planning && !model.Locked; }
|
||||||
|
|
||||||
|
static string WhyNot(LuneHudModel model)
|
||||||
|
{
|
||||||
|
if (!model.Planning)
|
||||||
|
return "Nur in der Planung.";
|
||||||
|
if (model.Locked)
|
||||||
|
return "Bereit gemeldet – die Planung ist verbindlich.";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void Recruit(LuneHudModel model)
|
||||||
|
{
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Jeder Spieler führt einen Helden. Er kämpft auf seiner Lane mit, sammelt Erfahrung aus Gefechten in seiner Nähe und aus jeder Runde, trägt drei Items und wählt ab Stufe 3 und 6 je ein Talent.", "HudMuted", this));
|
||||||
|
VisualElement row = LuneUi.Box(false, 12f, this);
|
||||||
|
row.style.alignItems = Align.Stretch;
|
||||||
|
row.style.flexGrow = 1f;
|
||||||
|
row.style.marginTop = 10f;
|
||||||
|
int gold = model.OwnGold;
|
||||||
|
foreach (KeyValuePair<string, HeroSpec> pair in LuneContent.Heroes)
|
||||||
|
{
|
||||||
|
string heroId = pair.Key;
|
||||||
|
HeroSpec spec = pair.Value;
|
||||||
|
var panel = new LunePanel(LunePanels.HudTight);
|
||||||
|
panel.style.flexGrow = 1f;
|
||||||
|
panel.style.flexBasis = 0f;
|
||||||
|
row.Add(panel);
|
||||||
|
VisualElement column = LuneUi.Box(true, 5f, panel);
|
||||||
|
column.style.flexGrow = 1f;
|
||||||
|
var portrait = new LunePortraitElement();
|
||||||
|
portrait.style.height = 170f;
|
||||||
|
column.Add(portrait);
|
||||||
|
portrait.ShowModel(spec.Model, true);
|
||||||
|
LuneUi.Label(spec.Name, "HudTitle", column);
|
||||||
|
LuneUi.Label(spec.Title, "HudMuted", column);
|
||||||
|
UnitDef definition = model.HeroDefinition(new HeroState { HeroId = heroId });
|
||||||
|
VisualElement role = LuneUi.Box(false, 5f, column);
|
||||||
|
LuneUi.Glyph(LuneStats.RoleGlyph(definition.Role), LuneUiTheme.BronzeLight, 15f, role);
|
||||||
|
LuneUi.Label(spec.RoleText, "HudText", role);
|
||||||
|
foreach (string[] entry in LuneStats.Rows(definition).Take(5))
|
||||||
|
{
|
||||||
|
VisualElement line = LuneUi.Box(false, 6f, column);
|
||||||
|
LuneUi.Glyph(entry[0], LuneUiTheme.Muted, 13f, line);
|
||||||
|
LuneUi.Label(entry[1], "HudMuted", line).style.minWidth = 104f;
|
||||||
|
LuneUi.Clip(LuneUi.Label(entry[2], "HudText", line)).style.flexGrow = 1f;
|
||||||
|
}
|
||||||
|
Label summary = LuneUi.Wrap(LuneUi.Label(spec.Summary, "HudText", column));
|
||||||
|
summary.style.flexGrow = 1f;
|
||||||
|
int cost = spec.Cost;
|
||||||
|
var buy = new LuneButton("ANWERBEN · " + cost + " G", LuneButtons.HudPrimary, column);
|
||||||
|
string why = WhyNot(model);
|
||||||
|
if (why.Length == 0 && gold < cost)
|
||||||
|
why = "Es fehlen " + (cost - gold) + " Gold.";
|
||||||
|
buy.Disabled = why.Length > 0;
|
||||||
|
buy.Clicked += () => CommandRequested?.Invoke(Command.HeroBuy(heroId));
|
||||||
|
RecruitButtons[heroId] = buy;
|
||||||
|
if (why.Length > 0)
|
||||||
|
LuneUi.SetColor(LuneUi.Wrap(LuneUi.Label(why, "HudMuted", column)), gold < cost ? LuneUiTheme.Danger : LuneUiTheme.Muted);
|
||||||
|
LuneUi.Label("Vorläufige Rolle des vorhandenen Modells.", "HudMuted", column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShowHero(LuneHudModel model, HeroState hero)
|
||||||
|
{
|
||||||
|
HeroSpec spec = LuneContent.Heroes[hero.HeroId];
|
||||||
|
UnitDef definition = model.HeroDefinition(hero);
|
||||||
|
bool canAct = CanAct(model);
|
||||||
|
VisualElement body = LuneUi.Box(false, 18f, this);
|
||||||
|
body.style.alignItems = Align.Stretch;
|
||||||
|
body.style.flexGrow = 1f;
|
||||||
|
VisualElement left = LuneUi.Box(true, 6f, body);
|
||||||
|
left.style.width = 290f;
|
||||||
|
var portrait = new LunePortraitElement();
|
||||||
|
portrait.style.width = 290f;
|
||||||
|
portrait.style.height = 230f;
|
||||||
|
left.Add(portrait);
|
||||||
|
portrait.ShowModel(spec.Model, true);
|
||||||
|
LuneUi.Label(spec.Title, "HudMuted", left);
|
||||||
|
VisualElement role = LuneUi.Box(false, 6f, left);
|
||||||
|
LuneUi.Glyph(LuneStats.RoleGlyph(definition.Role), LuneUiTheme.BronzeLight, 16f, role);
|
||||||
|
LuneUi.Label(spec.RoleText, "HudText", role);
|
||||||
|
int level = LuneContent.LevelFor(hero.Xp);
|
||||||
|
int[] levels = LuneContent.LevelXp;
|
||||||
|
VisualElement levelRow = LuneUi.Box(false, 6f, left);
|
||||||
|
LuneUi.Glyph("star", LuneUiTheme.Hero, 16f, levelRow);
|
||||||
|
LuneUi.Label("STUFE " + level + " / " + levels.Length, "HudValue", levelRow);
|
||||||
|
var bar = new LuneHpBar(0f, 8f) { Fill = LuneUiTheme.BronzeLight };
|
||||||
|
left.Add(bar);
|
||||||
|
bool top = level >= levels.Length;
|
||||||
|
int low = levels[level - 1];
|
||||||
|
int high = levels[Math.Min(level, levels.Length - 1)];
|
||||||
|
bar.Fraction = top ? 1f : (float)(hero.Xp - low) / Math.Max(1, high - low);
|
||||||
|
LuneUi.Label(top ? hero.Xp + " EP · Höchststufe" : hero.Xp + " / " + high + " EP · noch " + (high - hero.Xp) + " bis Stufe " + (level + 1), "HudMuted", left);
|
||||||
|
LuneUi.Label("EINSATZ", "HudCaps", left);
|
||||||
|
VisualElement lanes = LuneUi.Box(false, 4f, left);
|
||||||
|
foreach (string lane in LuneMap.Lanes.Concat(new[] { LuneMatch.Grotto }))
|
||||||
|
{
|
||||||
|
string title = lane == LuneMatch.Grotto ? "GROTTE" : LuneUi.Upper(LuneMap.LaneNames[lane]);
|
||||||
|
var button = new LuneButton(title, LuneButtons.HudToggle, lanes);
|
||||||
|
button.style.flexGrow = 1f;
|
||||||
|
button.On = hero.Lane == lane;
|
||||||
|
button.Disabled = !canAct || (lane == LuneMatch.Grotto && !model.State.GrottoAvailable());
|
||||||
|
string uid = hero.Uid;
|
||||||
|
string captured = lane;
|
||||||
|
button.Clicked += () => CommandRequested?.Invoke(Command.Assign(uid, captured));
|
||||||
|
LaneButtons[lane] = button;
|
||||||
|
}
|
||||||
|
if (hero.Lane == LuneMatch.Grotto)
|
||||||
|
LuneUi.SetColor(LuneUi.Wrap(LuneUi.Label("Der Held fehlt seiner Lane in der nächsten Kampfphase.", "HudText", left)), LuneUiTheme.Warn);
|
||||||
|
if (hero.Fresh && model.Planning)
|
||||||
|
{
|
||||||
|
int refund = hero.Invested + hero.Items.Sum(id => LuneContent.Items[id].Cost);
|
||||||
|
var cancel = new LuneButton("Held zurückgeben +" + refund, LuneButtons.HudButton, left);
|
||||||
|
cancel.style.alignSelf = Align.FlexStart;
|
||||||
|
cancel.Disabled = !canAct;
|
||||||
|
cancel.Clicked += () => CommandRequested?.Invoke(Command.HeroCancel());
|
||||||
|
}
|
||||||
|
VisualElement right = LuneUi.Box(true, 8f, body);
|
||||||
|
right.style.flexGrow = 1f;
|
||||||
|
right.style.flexShrink = 1f;
|
||||||
|
right.style.minWidth = 0f;
|
||||||
|
Tabs = new LuneTabBar();
|
||||||
|
right.Add(Tabs);
|
||||||
|
Tabs.AddTab("WERTE & FÄHIGKEIT");
|
||||||
|
Tabs.AddTab("ITEMS " + hero.Items.Count + "/" + LuneContent.ItemSlots);
|
||||||
|
int openTier = hero.Talents.Count;
|
||||||
|
int[] talentLevels = LuneContent.TalentLevels;
|
||||||
|
bool talentReady = openTier < talentLevels.Length && level >= talentLevels[openTier];
|
||||||
|
LuneButton talents = Tabs.AddTab("TALENTE");
|
||||||
|
if (talentReady)
|
||||||
|
LuneUi.Glyph("star", LuneUiTheme.Hero, 13f, talents).style.marginLeft = 5f;
|
||||||
|
Tabs.Select(Tab);
|
||||||
|
Tabs.TabChanged += ShowTab;
|
||||||
|
VisualElement page = LuneUi.Box(true, 8f, right);
|
||||||
|
page.style.flexGrow = 1f;
|
||||||
|
if (Tab == TabItems)
|
||||||
|
Items(model, hero, level, page);
|
||||||
|
else if (Tab == TabTalents)
|
||||||
|
Talents(model, hero, level, page);
|
||||||
|
else
|
||||||
|
Values(hero, definition, spec, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Values(HeroState hero, UnitDef definition, HeroSpec spec, VisualElement page)
|
||||||
|
{
|
||||||
|
List<string[]> rows = LuneStats.Rows(definition, true);
|
||||||
|
rows.Add(new[] { "sword", "Schaden/Sekunde", "≈ " + LuneUiTheme.Decimal(LuneStats.Dps(definition)) + " (ohne Fähigkeit)" });
|
||||||
|
VisualElement grid = LuneUi.Box(true, 3f, page);
|
||||||
|
foreach (string[] entry in rows)
|
||||||
|
{
|
||||||
|
VisualElement line = LuneUi.Box(false, 10f, grid);
|
||||||
|
LuneUi.Glyph(entry[0], LuneUiTheme.Muted, 15f, line);
|
||||||
|
LuneUi.Label(entry[1], "HudMuted", line).style.minWidth = 130f;
|
||||||
|
LuneUi.Wrap(LuneUi.Label(entry[2], "HudText", line)).style.flexGrow = 1f;
|
||||||
|
}
|
||||||
|
UnitDef baseDef = spec.Base;
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Grundwerte " + LuneUiTheme.Number(baseDef.MaxHp) + " Leben, " + LuneUiTheme.Decimal(baseDef.Damage) + " Schaden; je Stufe +" + LuneUiTheme.Round(LuneContent.HpPerLevel * 100.0)
|
||||||
|
+ " % Leben und +" + LuneUiTheme.Round(LuneContent.DamagePerLevel * 100.0) + " % Schaden (jetzt Stufe " + definition.Level + ").", "HudMuted", page));
|
||||||
|
LuneUi.Label("FÄHIGKEIT", "HudCaps", page);
|
||||||
|
LuneUi.Wrap(LuneUi.Label(DataCatalog.GetUnit(spec.Model).AbilityDescription ?? "", "HudText", page));
|
||||||
|
var chosen = new List<string>();
|
||||||
|
TalentOption[][] options = LuneContent.TalentsOf(hero.HeroId);
|
||||||
|
for (int tier = 0; tier < hero.Talents.Count; tier++)
|
||||||
|
{
|
||||||
|
foreach (TalentOption option in options[tier])
|
||||||
|
{
|
||||||
|
if (option.Id == hero.Talents[tier])
|
||||||
|
chosen.Add(option.Name + ": " + option.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen.Count > 0)
|
||||||
|
{
|
||||||
|
LuneUi.Label("TALENTE", "HudCaps", page);
|
||||||
|
LuneUi.Wrap(LuneUi.Label(string.Join("\n", chosen), "HudText", page));
|
||||||
|
}
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Erfahrung: Tode von Gegnern in " + (int)LuneContent.XpRadius + " m (Monster 12 + Welle, Champion 30 + 2 × Welle, Gebäude " + LuneContent.BuildingXp + ", Boss " + LuneContent.BossXp
|
||||||
|
+ ") und je Kampf " + LuneContent.XpParticipation + " + " + LuneContent.XpPerRound + " × Welle.", "HudMuted", page));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Items(LuneHudModel model, HeroState hero, int level, VisualElement page)
|
||||||
|
{
|
||||||
|
bool canAct = CanAct(model);
|
||||||
|
List<string> items = hero.Items;
|
||||||
|
VisualElement slots = LuneUi.Box(false, 10f, page);
|
||||||
|
slots.style.alignItems = Align.FlexStart;
|
||||||
|
for (int index = 0; index < LuneContent.ItemSlots; index++)
|
||||||
|
{
|
||||||
|
VisualElement box = LuneUi.Box(true, 3f, slots);
|
||||||
|
box.style.flexGrow = 1f;
|
||||||
|
box.style.flexBasis = 0f;
|
||||||
|
var slot = new LuneItemSlot(true);
|
||||||
|
slot.style.width = 46f;
|
||||||
|
slot.style.height = 46f;
|
||||||
|
box.Add(slot);
|
||||||
|
if (index < items.Count)
|
||||||
|
{
|
||||||
|
string itemId = items[index];
|
||||||
|
ItemSpec item = LuneContent.Items[itemId];
|
||||||
|
slot.SetItem(itemId, LuneItemSlot.State.Equipped);
|
||||||
|
LuneTooltip.Attach(slot, () => new LuneTip { Title = item.Name, Glyph = itemId, GlyphColor = LuneItemSlot.ColorOf(itemId), Text = item.Text });
|
||||||
|
LuneUi.Label(item.Name, "HudText", box);
|
||||||
|
bool fresh = hero.FreshItems.Contains(itemId);
|
||||||
|
int refund = fresh ? item.Cost : (int)Math.Floor(item.Cost * LuneContent.ItemRefund);
|
||||||
|
var sell = new LuneButton("Verkaufen +" + refund, LuneButtons.HudButton, box);
|
||||||
|
sell.style.alignSelf = Align.FlexStart;
|
||||||
|
sell.Disabled = !canAct;
|
||||||
|
sell.Clicked += () => CommandRequested?.Invoke(Command.ItemSell(itemId));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
slot.SetItem("", LuneItemSlot.State.Empty);
|
||||||
|
LuneUi.Label("Platz " + (index + 1) + " frei", "HudMuted", box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LuneUi.Label("ITEMS · einzigartig je Held, bleiben dauerhaft, Rückgabe " + LuneUiTheme.Round(LuneContent.ItemRefund * 100.0) + " %", "HudCaps", page);
|
||||||
|
VisualElement grid = LuneUi.Box(true, 8f, page);
|
||||||
|
int gold = model.OwnGold;
|
||||||
|
VisualElement line = null;
|
||||||
|
int column = 0;
|
||||||
|
foreach (KeyValuePair<string, ItemSpec> pair in LuneContent.Items)
|
||||||
|
{
|
||||||
|
string itemId = pair.Key;
|
||||||
|
ItemSpec item = pair.Value;
|
||||||
|
if (column % 2 == 0)
|
||||||
|
line = LuneUi.Box(false, 8f, grid);
|
||||||
|
column++;
|
||||||
|
var card = new LuneItemSlot(false);
|
||||||
|
card.style.flexGrow = 1f;
|
||||||
|
card.style.flexBasis = 0f;
|
||||||
|
line.Add(card);
|
||||||
|
LuneItemSlot.State state = LuneItemSlot.State.Available;
|
||||||
|
if (items.Contains(itemId))
|
||||||
|
state = LuneItemSlot.State.Unique;
|
||||||
|
else if (!canAct)
|
||||||
|
state = LuneItemSlot.State.Locked;
|
||||||
|
else if (level < item.MinLevel)
|
||||||
|
state = LuneItemSlot.State.Incompatible;
|
||||||
|
else if (items.Count >= LuneContent.ItemSlots)
|
||||||
|
state = LuneItemSlot.State.Full;
|
||||||
|
else if (gold < item.Cost)
|
||||||
|
state = LuneItemSlot.State.Unaffordable;
|
||||||
|
card.SetItem(itemId, state);
|
||||||
|
ItemCards[itemId] = card;
|
||||||
|
string uid = hero.Uid;
|
||||||
|
card.Clicked += () =>
|
||||||
|
{
|
||||||
|
if (card.Current == LuneItemSlot.State.Available)
|
||||||
|
CommandRequested?.Invoke(Command.ItemBuy(itemId, uid));
|
||||||
|
};
|
||||||
|
string why = state == LuneItemSlot.State.Unique ? "Dein Held trägt es bereits; Items sind einzigartig."
|
||||||
|
: state == LuneItemSlot.State.Locked ? WhyNot(model)
|
||||||
|
: state == LuneItemSlot.State.Incompatible ? "Erfordert Heldenstufe " + item.MinLevel + "."
|
||||||
|
: state == LuneItemSlot.State.Full ? "Alle " + LuneContent.ItemSlots + " Plätze belegt; verkaufe zuerst ein Item."
|
||||||
|
: state == LuneItemSlot.State.Unaffordable ? "Es fehlen " + (item.Cost - gold) + " Gold."
|
||||||
|
: "Klick: kaufen und ausrüsten.";
|
||||||
|
LuneTooltip.Attach(card, () => new LuneTip { Title = item.Name + " · " + item.Cost + " Gold", Glyph = itemId, GlyphColor = LuneItemSlot.ColorOf(itemId), Text = item.Text, Note = why });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Talents(LuneHudModel model, HeroState hero, int level, VisualElement page)
|
||||||
|
{
|
||||||
|
bool canAct = CanAct(model);
|
||||||
|
List<string> talents = hero.Talents;
|
||||||
|
int[] talentLevels = LuneContent.TalentLevels;
|
||||||
|
TalentOption[][] options = LuneContent.TalentsOf(hero.HeroId);
|
||||||
|
for (int tier = 0; tier < talentLevels.Length; tier++)
|
||||||
|
{
|
||||||
|
int required = talentLevels[tier];
|
||||||
|
string state = tier < talents.Count ? "gewählt" : level >= required && tier == talents.Count ? "jetzt wählbar" : "ab Stufe " + required;
|
||||||
|
LuneUi.Label("TALENT " + (tier + 1) + " · STUFE " + required + " · " + state.ToUpperInvariant(), "HudCaps", page);
|
||||||
|
VisualElement row = LuneUi.Box(false, 8f, page);
|
||||||
|
row.style.alignItems = Align.Stretch;
|
||||||
|
for (int choice = 0; choice < options[tier].Length; choice++)
|
||||||
|
{
|
||||||
|
TalentOption option = options[tier][choice];
|
||||||
|
var card = new LuneButton(null, LuneButtons.HudCard, row);
|
||||||
|
card.style.flexGrow = 1f;
|
||||||
|
card.style.flexBasis = 0f;
|
||||||
|
card.style.minHeight = 96f;
|
||||||
|
card.style.flexDirection = FlexDirection.Column;
|
||||||
|
card.style.alignItems = Align.Stretch;
|
||||||
|
card.style.justifyContent = Justify.FlexStart;
|
||||||
|
card.style.paddingLeft = 10f;
|
||||||
|
card.style.paddingRight = 10f;
|
||||||
|
card.style.paddingTop = 8f;
|
||||||
|
card.style.paddingBottom = 8f;
|
||||||
|
LuneUi.Label(option.Name, "HudValue", card);
|
||||||
|
LuneUi.Wrap(LuneUi.Label(option.Text, "HudMuted", card));
|
||||||
|
bool chosen = tier < talents.Count && talents[tier] == option.Id;
|
||||||
|
card.On = chosen;
|
||||||
|
card.Disabled = !(canAct && tier == talents.Count && level >= required) && !chosen;
|
||||||
|
int capturedTier = tier;
|
||||||
|
int capturedChoice = choice;
|
||||||
|
card.Clicked += () =>
|
||||||
|
{
|
||||||
|
if (!chosen)
|
||||||
|
CommandRequested?.Invoke(Command.Talent(capturedTier, capturedChoice));
|
||||||
|
};
|
||||||
|
TalentCards.Add(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Die Wahl ist endgültig; jede Stufe bietet zwei Richtungen (Einzelziel oder Fläche, Schutz oder Druck).", "HudMuted", page));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneHeroPanels.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7062140f99f7847c69df47a76d57ffaa
|
||||||
1455
Assets/Luneraria/UI/Hud/LuneHud.cs
Normal file
2
Assets/Luneraria/UI/Hud/LuneHud.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ace221db2f2334b4a8e6f015cc4661e1
|
||||||
65
Assets/Luneraria/UI/Hud/LuneHudCapture.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System.IO;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Screenshots of the scene with the HUD on top, also in batch mode: the camera renders into
|
||||||
|
/// one texture, the HUD panel into its own (premultiplied alpha), and both are composed on the
|
||||||
|
/// CPU. Used by the U3 tests and captures; the player can also capture the real screen.
|
||||||
|
/// </summary>
|
||||||
|
public static class LuneHudCapture
|
||||||
|
{
|
||||||
|
public static Texture2D Read(RenderTexture texture)
|
||||||
|
{
|
||||||
|
RenderTexture previous = RenderTexture.active;
|
||||||
|
RenderTexture.active = texture;
|
||||||
|
var image = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
|
||||||
|
image.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
|
||||||
|
image.Apply();
|
||||||
|
RenderTexture.active = previous;
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Texture2D RenderScene(Camera camera, int width, int height)
|
||||||
|
{
|
||||||
|
var target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 };
|
||||||
|
RenderTexture previous = camera.targetTexture;
|
||||||
|
float aspect = camera.aspect;
|
||||||
|
camera.aspect = (float)width / height;
|
||||||
|
camera.targetTexture = target;
|
||||||
|
camera.Render();
|
||||||
|
camera.targetTexture = previous;
|
||||||
|
camera.aspect = aspect;
|
||||||
|
Texture2D image = Read(target);
|
||||||
|
target.Release();
|
||||||
|
Object.Destroy(target);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HUD (premultiplied alpha) over the scene: out = hud + scene · (1 − hud alpha).</summary>
|
||||||
|
public static Texture2D Compose(Texture2D scene, Texture2D hud)
|
||||||
|
{
|
||||||
|
Color32[] under = scene.GetPixels32();
|
||||||
|
Color32[] over = hud.GetPixels32();
|
||||||
|
var result = new Color32[under.Length];
|
||||||
|
for (int index = 0; index < under.Length; index++)
|
||||||
|
{
|
||||||
|
Color32 a = over[index];
|
||||||
|
Color32 b = under[index];
|
||||||
|
int keep = 255 - a.a;
|
||||||
|
result[index] = new Color32((byte)Mathf.Min(255, a.r + b.r * keep / 255), (byte)Mathf.Min(255, a.g + b.g * keep / 255), (byte)Mathf.Min(255, a.b + b.b * keep / 255), 255);
|
||||||
|
}
|
||||||
|
var image = new Texture2D(scene.width, scene.height, TextureFormat.RGBA32, false);
|
||||||
|
image.SetPixels32(result);
|
||||||
|
image.Apply();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(Texture2D image, string file)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(file));
|
||||||
|
File.WriteAllBytes(file, image.EncodeToPNG());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneHudCapture.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b98091c453cdb4a6c9348ac3d4c56283
|
||||||
530
Assets/Luneraria/UI/Hud/LuneLanePanels.cs
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One lane in the lane overview (Godot LuneLaneIndicator): name and hotkey, the threat level
|
||||||
|
/// (text, symbol and colour), the tower chain from the own base to the enemy base, the two lane
|
||||||
|
/// players with their heroes, announced champions or the boss, and in combat the unit count of
|
||||||
|
/// both sides. A click moves the camera. The threat rises at once but only falls after a short
|
||||||
|
/// hold, so it does not flicker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneLaneIndicator : LuneButton
|
||||||
|
{
|
||||||
|
public const float Hold = 2.5f;
|
||||||
|
public readonly string Lane;
|
||||||
|
public readonly string Hotkey;
|
||||||
|
public int Level { get; private set; }
|
||||||
|
/// <summary>The threat badge pulses (a lane just turned critical; off with reduced motion).</summary>
|
||||||
|
public bool Pulsing { get { return _pulse > 0f; } }
|
||||||
|
public LuneHudModel.LaneInfo Data { get; private set; }
|
||||||
|
public readonly LuneTowerIndicator[] Towers = new LuneTowerIndicator[4];
|
||||||
|
readonly LuneStatusBadge _badge;
|
||||||
|
readonly Label _own;
|
||||||
|
readonly Label _enemy;
|
||||||
|
readonly LunePortraitElement _ownHero;
|
||||||
|
readonly LunePortraitElement _enemyHero;
|
||||||
|
readonly Label _army;
|
||||||
|
readonly VisualElement _dangers;
|
||||||
|
readonly LuneGlyphElement _mine;
|
||||||
|
readonly LuneGlyphElement _ownShape;
|
||||||
|
readonly LuneGlyphElement _enemyShape;
|
||||||
|
float _lowerHold;
|
||||||
|
float _pulse;
|
||||||
|
string _signature = "";
|
||||||
|
|
||||||
|
public LuneLaneIndicator(string lane, string hotkey) : base(null, LuneButtons.HudRow)
|
||||||
|
{
|
||||||
|
Lane = lane;
|
||||||
|
Hotkey = hotkey;
|
||||||
|
style.minHeight = 92f;
|
||||||
|
style.flexDirection = FlexDirection.Column;
|
||||||
|
style.alignItems = Align.Stretch;
|
||||||
|
style.justifyContent = Justify.FlexStart;
|
||||||
|
style.paddingLeft = 10f;
|
||||||
|
style.paddingRight = 9f;
|
||||||
|
style.paddingTop = 6f;
|
||||||
|
style.paddingBottom = 6f;
|
||||||
|
var column = (LuneBox)LuneUi.Box(true, 3f, this);
|
||||||
|
VisualElement head = LuneUi.Box(false, 6f, column);
|
||||||
|
LuneUi.Glyph("lane_" + lane, LuneUiTheme.BronzeLight, 17f, head);
|
||||||
|
LuneUi.Label(LuneUi.Upper(LuneMap.LaneNames[lane]), "HudLane", head);
|
||||||
|
_mine = LuneUi.Glyph("star", LuneUiTheme.BronzeLight, 12f, head);
|
||||||
|
LuneUi.Show(_mine, false);
|
||||||
|
LuneUi.Spacer(head);
|
||||||
|
_badge = new LuneStatusBadge();
|
||||||
|
head.Add(_badge);
|
||||||
|
VisualElement chain = LuneUi.Box(false, 3f, column);
|
||||||
|
_ownShape = LuneUi.Glyph("emblem_0", LuneUiTheme.Team[0], 13f, chain);
|
||||||
|
for (int index = 0; index < 4; index++)
|
||||||
|
{
|
||||||
|
var tower = new LuneTowerIndicator();
|
||||||
|
chain.Add(tower);
|
||||||
|
Towers[index] = tower;
|
||||||
|
int captured = index;
|
||||||
|
LuneTooltip.Attach(tower, () => TowerTip(captured));
|
||||||
|
if (index == 1)
|
||||||
|
{
|
||||||
|
VisualElement middle = LuneUi.Box(false, 4f, chain);
|
||||||
|
middle.style.flexGrow = 1f;
|
||||||
|
middle.style.justifyContent = Justify.Center;
|
||||||
|
_army = LuneUi.Label("", "HudValue", middle);
|
||||||
|
_dangers = LuneUi.Box(false, 2f, middle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_enemyShape = LuneUi.Glyph("emblem_1", LuneUiTheme.Team[1], 13f, chain);
|
||||||
|
VisualElement names = LuneUi.Box(false, 5f, column);
|
||||||
|
// Heroes of both lane players (Dota-style "who stands where"), committed heroes only.
|
||||||
|
_ownHero = new LunePortraitElement();
|
||||||
|
_ownHero.style.width = 22f;
|
||||||
|
_ownHero.style.height = 22f;
|
||||||
|
names.Add(_ownHero);
|
||||||
|
_own = LuneUi.Clip(LuneUi.Label("", "HudMuted", names));
|
||||||
|
_own.style.flexGrow = 1f;
|
||||||
|
_own.style.flexBasis = 0f;
|
||||||
|
_enemy = LuneUi.Clip(LuneUi.Label("", "HudMuted", names));
|
||||||
|
_enemy.style.flexGrow = 1f;
|
||||||
|
_enemy.style.flexBasis = 0f;
|
||||||
|
_enemy.style.unityTextAlign = TextAnchor.MiddleRight;
|
||||||
|
_enemyHero = new LunePortraitElement();
|
||||||
|
_enemyHero.style.width = 22f;
|
||||||
|
_enemyHero.style.height = 22f;
|
||||||
|
names.Add(_enemyHero);
|
||||||
|
LuneTooltip.Attach(this, Tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies a lane from LuneHudModel.Lane.</summary>
|
||||||
|
public void Apply(LuneHudModel.LaneInfo entry, int ownTeam, float delta, bool reducedMotion)
|
||||||
|
{
|
||||||
|
Data = entry;
|
||||||
|
int threat = entry.Threat.Level;
|
||||||
|
if (threat >= Level)
|
||||||
|
{
|
||||||
|
if (threat == LuneLaneThreat.Critical && Level != threat && !reducedMotion)
|
||||||
|
_pulse = 1.6f;
|
||||||
|
Level = threat;
|
||||||
|
_lowerHold = Hold;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lowerHold -= delta;
|
||||||
|
if (_lowerHold <= 0f)
|
||||||
|
{
|
||||||
|
Level = threat;
|
||||||
|
_lowerHold = Hold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color tint = LuneUiTheme.Threat[Level];
|
||||||
|
_badge.SetState(LuneLaneThreat.Names[Level], tint, Level >= 2 ? LuneLaneThreat.Glyphs[Level] : "", Level == LuneLaneThreat.Critical);
|
||||||
|
_ownShape.Glyph = "emblem_" + ownTeam;
|
||||||
|
_ownShape.Color = LuneUiTheme.TeamColor(ownTeam);
|
||||||
|
_enemyShape.Glyph = "emblem_" + (1 - ownTeam);
|
||||||
|
_enemyShape.Color = LuneUiTheme.TeamColor(1 - ownTeam);
|
||||||
|
for (int index = 0; index < 4; index++)
|
||||||
|
{
|
||||||
|
LuneHudModel.BuildingInfo tower = entry.Towers[index];
|
||||||
|
Towers[index].Team = tower.Team;
|
||||||
|
Towers[index].Tier = tower.Tier;
|
||||||
|
Towers[index].SetState(tower.Alive, (float)tower.Fraction, tower.Attackable);
|
||||||
|
}
|
||||||
|
_own.text = entry.OwnPlayer;
|
||||||
|
LuneUi.SetColor(_own, entry.OwnIsMe ? LuneUiTheme.BronzeLight : LuneUiTheme.Muted);
|
||||||
|
_enemy.text = entry.EnemyPlayer;
|
||||||
|
ShowHero(_ownHero, entry.OwnHero);
|
||||||
|
ShowHero(_enemyHero, entry.EnemyHero);
|
||||||
|
LuneUi.Show(_mine, entry.OwnIsMe);
|
||||||
|
_army.text = entry.HasCounts ? entry.OwnCount + " : " + entry.EnemyCount : "";
|
||||||
|
string signature = string.Join(",", entry.Dangers.Select(d => d.Kind + ":" + d.Team + ":" + d.ChampionId));
|
||||||
|
if (signature != _signature)
|
||||||
|
{
|
||||||
|
_signature = signature;
|
||||||
|
_dangers.Clear();
|
||||||
|
foreach (Announcement danger in entry.Dangers)
|
||||||
|
{
|
||||||
|
bool hostile = danger.Team != ownTeam;
|
||||||
|
LuneUi.Glyph(danger.Kind == "boss" ? "crown" : "champion", hostile ? LuneUiTheme.Danger : LuneUiTheme.TeamColor(danger.Team), 16f, _dangers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_pulse > 0f)
|
||||||
|
{
|
||||||
|
_pulse = Mathf.Max(0f, _pulse - delta);
|
||||||
|
_badge.style.opacity = _pulse > 0f ? .55f + .45f * Mathf.Abs(Mathf.Cos(_pulse * Mathf.PI * 2f)) : 1f;
|
||||||
|
}
|
||||||
|
else if (_badge.style.opacity != 1f)
|
||||||
|
{
|
||||||
|
_badge.style.opacity = 1f;
|
||||||
|
}
|
||||||
|
LuneTooltip.Current?.Refresh(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ShowHero(LunePortraitElement portrait, LuneHudModel.HeroBrief hero)
|
||||||
|
{
|
||||||
|
LuneUi.Show(portrait, hero != null);
|
||||||
|
if (hero != null)
|
||||||
|
portrait.ShowModel(hero.Model);
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip Tip()
|
||||||
|
{
|
||||||
|
if (Data == null)
|
||||||
|
return null;
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (string reason in Data.Threat.Reasons)
|
||||||
|
lines.Add("• " + reason);
|
||||||
|
if (lines.Count == 0)
|
||||||
|
lines.Add("Keine Bedrohung aus Türmen, Kampflage oder Ankündigungen.");
|
||||||
|
var tip = new LuneTip
|
||||||
|
{
|
||||||
|
Title = Data.Title + "-LANE · " + LuneLaneThreat.Names[Level], Glyph = "lane_" + Lane,
|
||||||
|
Subtitle = PlayerText(Data.OwnPlayer, Data.OwnHero) + " gegen " + PlayerText(Data.EnemyPlayer, Data.EnemyHero),
|
||||||
|
Text = string.Join("\n", lines),
|
||||||
|
};
|
||||||
|
for (int index = 0; index < 2; index++)
|
||||||
|
tip.Stat("tower", "Eigene " + LuneHudModel.BuildingTitle(Data.Towers[index].Id), TowerValue(Data.Towers[index]));
|
||||||
|
for (int index = 2; index < 4; index++)
|
||||||
|
tip.Stat("tower", "Gegner " + LuneHudModel.BuildingTitle(Data.Towers[index].Id), TowerValue(Data.Towers[index]));
|
||||||
|
if (Data.HasCounts)
|
||||||
|
tip.Stat("melee", "Einheiten", Data.OwnCount + " eigene · " + Data.EnemyCount + " gegnerische");
|
||||||
|
var notes = new List<string>();
|
||||||
|
foreach (Announcement danger in Data.Dangers)
|
||||||
|
{
|
||||||
|
string side = LuneUiTheme.TeamName(danger.Team);
|
||||||
|
notes.Add(danger.Kind == "boss" ? "Schlundkrone (" + side + ") greift an" : LuneContent.Champions[danger.ChampionId].Name + " (" + side + ") angekündigt");
|
||||||
|
}
|
||||||
|
tip.Note = (notes.Count > 0 ? string.Join("; ", notes) + "\n" : "") + "Klick: Kamera auf die Lane" + (Hotkey.Length > 0 ? " (" + Hotkey + ")" : "");
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string PlayerText(string player, LuneHudModel.HeroBrief hero)
|
||||||
|
{
|
||||||
|
return hero == null ? player : player + " (" + hero.Name + ", Stufe " + hero.Level + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
LuneTip TowerTip(int index)
|
||||||
|
{
|
||||||
|
if (Data == null)
|
||||||
|
return null;
|
||||||
|
LuneHudModel.BuildingInfo tower = Data.Towers[index];
|
||||||
|
string text = !tower.Alive ? "Zerstört – die Ruine blockiert nicht." : tower.Attackable ? "Angreifbar." : "Noch geschützt: erst fällt der Turm davor.";
|
||||||
|
return new LuneTip { Title = LuneHudModel.BuildingTitle(tower.Id) + " · " + LuneUiTheme.TeamName(tower.Team), Glyph = "tower", GlyphColor = LuneUiTheme.TeamColor(tower.Team), Text = text }
|
||||||
|
.Stat("heart", "Leben", TowerValue(tower));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string TowerValue(LuneHudModel.BuildingInfo tower)
|
||||||
|
{
|
||||||
|
if (!tower.Alive)
|
||||||
|
return "zerstört";
|
||||||
|
return LuneUiTheme.Number(tower.Hp) + " / " + LuneUiTheme.Number(tower.MaxHp) + " (" + LuneUiTheme.Round(tower.Fraction * 100.0) + " %)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The boss grotto and the Schlundkrone at a glance (Godot LuneBossIndicator): BOSS VERFÜGBAR,
|
||||||
|
/// BOSS UMKÄMPFT, BOSS EROBERT, EINSATZ NÄCHSTE WELLE, BOSS GREIFT AN, COOLDOWN – X RUNDEN. When
|
||||||
|
/// a team owns the boss the card names target lane, deployment wave and the bounty the defenders
|
||||||
|
/// earn; the player who steers the boss chooses its lane here. A click moves the camera.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneBossIndicator : LunePanel
|
||||||
|
{
|
||||||
|
public event Action FocusRequested;
|
||||||
|
public event Action<string> LaneChosen;
|
||||||
|
public LuneHudModel.BossInfo Data { get; private set; }
|
||||||
|
public readonly LunePortraitElement Portrait;
|
||||||
|
public bool Compact { get; private set; }
|
||||||
|
readonly LuneStatusBadge _badge;
|
||||||
|
readonly Label _line;
|
||||||
|
readonly Label _risk;
|
||||||
|
readonly LuneHpBar _bar;
|
||||||
|
readonly VisualElement _choice;
|
||||||
|
public readonly Dictionary<string, LuneButton> LaneButtons = new Dictionary<string, LuneButton>();
|
||||||
|
readonly LuneGlyphElement _owner;
|
||||||
|
string _signature = "";
|
||||||
|
int _ownTeam;
|
||||||
|
int _round;
|
||||||
|
bool _planning = true;
|
||||||
|
|
||||||
|
public LuneBossIndicator() : base(LunePanels.HudTight)
|
||||||
|
{
|
||||||
|
VisualElement row = LuneUi.Box(false, 10f, this);
|
||||||
|
row.style.alignItems = Align.FlexStart;
|
||||||
|
Portrait = new LunePortraitElement();
|
||||||
|
Portrait.style.width = 64f;
|
||||||
|
Portrait.style.height = 64f;
|
||||||
|
row.Add(Portrait);
|
||||||
|
_owner = LuneUi.Glyph("emblem_2", LuneUiTheme.Team[2], 18f, Portrait);
|
||||||
|
_owner.style.position = Position.Absolute;
|
||||||
|
_owner.style.left = 2f;
|
||||||
|
_owner.style.bottom = 2f;
|
||||||
|
VisualElement column = LuneUi.Box(true, 3f, row);
|
||||||
|
column.style.flexGrow = 1f;
|
||||||
|
column.style.flexShrink = 1f;
|
||||||
|
column.style.minWidth = 0f;
|
||||||
|
VisualElement head = LuneUi.Box(false, 6f, column);
|
||||||
|
LuneUi.Glyph("grotto", LuneUiTheme.Team[2], 15f, head);
|
||||||
|
LuneUi.Label("BOSSGROTTE · G", "HudCaps", head);
|
||||||
|
_badge = new LuneStatusBadge();
|
||||||
|
_badge.style.alignSelf = Align.FlexStart;
|
||||||
|
column.Add(_badge);
|
||||||
|
_line = LuneUi.Wrap(LuneUi.Label("", "HudText", column));
|
||||||
|
_risk = LuneUi.Wrap(LuneUi.Label("", "HudMuted", column));
|
||||||
|
_bar = new LuneHpBar(0f, 5f);
|
||||||
|
LuneUi.Show(_bar, false);
|
||||||
|
column.Add(_bar);
|
||||||
|
_choice = LuneUi.Box(false, 4f, column);
|
||||||
|
LuneUi.Show(_choice, false);
|
||||||
|
foreach (string lane in LuneMap.Lanes)
|
||||||
|
{
|
||||||
|
var button = new LuneButton(LuneUi.Upper(LuneMap.LaneNames[lane]), LuneButtons.HudToggle, _choice);
|
||||||
|
button.ToggleMode = true;
|
||||||
|
button.style.flexGrow = 1f;
|
||||||
|
string captured = lane;
|
||||||
|
button.Clicked += () => LaneChosen?.Invoke(captured);
|
||||||
|
LaneButtons[lane] = button;
|
||||||
|
}
|
||||||
|
RegisterCallback<PointerDownEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (evt.button == 0)
|
||||||
|
FocusRequested?.Invoke();
|
||||||
|
});
|
||||||
|
LuneTooltip.Attach(this, Tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Short form while the right column shows a selected unit: badge only.</summary>
|
||||||
|
public void SetCompact(bool value)
|
||||||
|
{
|
||||||
|
if (value == Compact)
|
||||||
|
return;
|
||||||
|
Compact = value;
|
||||||
|
Portrait.style.width = value ? 40f : 64f;
|
||||||
|
Portrait.style.height = value ? 40f : 64f;
|
||||||
|
_signature = "";
|
||||||
|
if (Data != null)
|
||||||
|
Apply(Data, _ownTeam, _round, _planning);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(LuneHudModel.BossInfo boss, int ownTeam, int round, bool planning)
|
||||||
|
{
|
||||||
|
string signature = boss + "|" + ownTeam + "|" + round + "|" + planning;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
Data = boss;
|
||||||
|
_ownTeam = ownTeam;
|
||||||
|
_round = round;
|
||||||
|
_planning = planning;
|
||||||
|
Portrait.ShowModel("schlundkrone");
|
||||||
|
int owner = boss.Team;
|
||||||
|
bool own = owner == ownTeam;
|
||||||
|
string lane = LuneUi.Upper(LuneMap.LaneNames.TryGetValue(boss.Lane, out string name) ? name : "?");
|
||||||
|
string side = LuneUiTheme.TeamName(owner);
|
||||||
|
bool showOwner = owner >= 0;
|
||||||
|
_owner.Glyph = "emblem_" + Mathf.Clamp(owner, 0, 2);
|
||||||
|
_owner.Color = LuneUiTheme.TeamColor(owner);
|
||||||
|
LuneUi.Show(_bar, false);
|
||||||
|
LuneUi.Show(_choice, false);
|
||||||
|
_risk.text = "";
|
||||||
|
switch (boss.State)
|
||||||
|
{
|
||||||
|
case "available":
|
||||||
|
_badge.SetState("BOSS VERFÜGBAR", LuneUiTheme.Team[2], "crown");
|
||||||
|
_line.text = "Einheiten mit „Grotte“ entsenden. Wer den Boss besiegt, schickt ihn in der Folgewelle gegen den Gegner.";
|
||||||
|
break;
|
||||||
|
case "contested":
|
||||||
|
_badge.SetState("BOSS UMKÄMPFT", LuneUiTheme.Warn, "melee");
|
||||||
|
_line.text = "Expeditionen kämpfen in der Grotte; sie fehlen ihren Lanes bis Kampfende.";
|
||||||
|
break;
|
||||||
|
case "conquered":
|
||||||
|
_badge.SetState("BOSS EROBERT · " + LuneUi.Upper(side), LuneUiTheme.TeamColor(owner), "crown", true);
|
||||||
|
_line.text = "Einsatz in Welle " + boss.DeployRound + " für " + (own ? "dein Team" : side) + ".";
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
_badge.SetState("EXPEDITION GESCHEITERT", LuneUiTheme.Muted, "close");
|
||||||
|
_line.text = "Der Boss hat überlebt; niemand erhält ihn.";
|
||||||
|
break;
|
||||||
|
case "deploy_next":
|
||||||
|
_badge.SetState("EINSATZ NÄCHSTE WELLE", own ? LuneUiTheme.TeamColor(owner) : LuneUiTheme.Danger, "crown", true);
|
||||||
|
if (own)
|
||||||
|
{
|
||||||
|
_line.text = "Deine Schlundkrone greift in Welle " + boss.DeployRound + " auf " + lane + " an.";
|
||||||
|
_risk.text = "Fällt sie, erhält " + LuneUiTheme.TeamName(1 - ownTeam) + " " + LuneUiTheme.Number(boss.Bounty) + " Gold Kopfgeld.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_line.text = side + ": Schlundkrone greift in Welle " + boss.DeployRound + " auf " + lane + " an.";
|
||||||
|
_risk.text = "Besiegt ihr sie, erhält dein Team " + LuneUiTheme.Number(boss.Bounty) + " Gold Kopfgeld.";
|
||||||
|
}
|
||||||
|
if (boss.Chooser && planning)
|
||||||
|
{
|
||||||
|
LuneUi.Show(_choice, true);
|
||||||
|
foreach (KeyValuePair<string, LuneButton> pair in LaneButtons)
|
||||||
|
pair.Value.On = pair.Key == boss.Lane;
|
||||||
|
_risk.text += " Du wählst die Lane:";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "deploying":
|
||||||
|
_badge.SetState("BOSS GREIFT AN · " + lane, own ? LuneUiTheme.TeamColor(owner) : LuneUiTheme.Danger, "warning", true);
|
||||||
|
_line.text = own ? "Deine Schlundkrone greift " + lane + " an." : "Gegnerische Schlundkrone auf " + lane + "!";
|
||||||
|
_risk.text = "Kopfgeld für die Verteidiger: " + LuneUiTheme.Number(boss.Bounty) + " Gold.";
|
||||||
|
if (boss.HasHp)
|
||||||
|
{
|
||||||
|
LuneUi.Show(_bar, true);
|
||||||
|
_bar.Fraction = (float)(boss.Hp / Math.Max(1.0, boss.MaxHp));
|
||||||
|
_bar.Fill = LuneUiTheme.TeamColor(owner);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "defeated":
|
||||||
|
_badge.SetState("SCHLUNDKRONE BESIEGT", !own ? LuneUiTheme.Good : LuneUiTheme.Muted, "check");
|
||||||
|
_line.text = "Das Kopfgeld von " + LuneUiTheme.Number(boss.Bounty) + " Gold geht an " + (own ? "den Gegner" : "dein Team") + ".";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
int left = boss.RoundsLeft;
|
||||||
|
_badge.SetState("COOLDOWN – " + left + (left == 1 ? " RUNDE" : " RUNDEN"), LuneUiTheme.Muted, "clock");
|
||||||
|
_line.text = "Grotte wieder offen ab Welle " + boss.NextRound + ".";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
LuneUi.Show(_risk, _risk.text.Length > 0 && !Compact);
|
||||||
|
LuneUi.Show(_line, !Compact);
|
||||||
|
LuneUi.Show(_owner, showOwner && !Compact);
|
||||||
|
if (Compact)
|
||||||
|
LuneUi.Show(_choice, false);
|
||||||
|
LuneTooltip.Current?.Refresh(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Live boss HP during its attack, without rebuilding the texts.</summary>
|
||||||
|
public void SetBossHp(float fraction)
|
||||||
|
{
|
||||||
|
if (LuneUi.Visible(_bar))
|
||||||
|
_bar.Fraction = fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BadgeText { get { return _badge.Text; } }
|
||||||
|
public string LineText { get { return _line.text; } }
|
||||||
|
|
||||||
|
static LuneTip Tip()
|
||||||
|
{
|
||||||
|
return new LuneTip
|
||||||
|
{
|
||||||
|
Title = "Bossgrotte · Schlundkrone", Glyph = "crown", GlyphColor = LuneUiTheme.Team[2],
|
||||||
|
Text = "Expeditionen kämpfen zuerst gegeneinander; der Boss erwacht, wenn höchstens ein Team übrig ist. Sieg: Der Boss greift in der Folgewelle für die Sieger an, die Verteidiger erhalten bei seinem Fall ein hohes Kopfgeld. Danach ist die Grotte "
|
||||||
|
+ LuneContent.BossLockRounds + " Runden gesperrt.",
|
||||||
|
Note = "Klick oder G: Kamera zur Grotte",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the coming wave brings (Godot LuneNextWave): monster types with counts (identical for
|
||||||
|
/// both sides and every lane), new types, the armour most of the wave wears and the damage type
|
||||||
|
/// that hits it for 130 %, announced attack champions and an announced boss. Dangers for the
|
||||||
|
/// own team are highlighted. In combat the panel shrinks to one line about the following wave.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneNextWave : LunePanel
|
||||||
|
{
|
||||||
|
readonly Label _title;
|
||||||
|
readonly Label _round;
|
||||||
|
readonly VisualElement _rows;
|
||||||
|
readonly VisualElement _extra;
|
||||||
|
readonly Label _note;
|
||||||
|
string _signature = "";
|
||||||
|
public bool Compact { get; private set; }
|
||||||
|
|
||||||
|
public LuneNextWave() : base(LunePanels.HudTight)
|
||||||
|
{
|
||||||
|
VisualElement column = LuneUi.Box(true, 4f, this);
|
||||||
|
VisualElement head = LuneUi.Box(false, 0f, column);
|
||||||
|
_title = LuneUi.Label("NÄCHSTE WELLE", "HudCaps", head);
|
||||||
|
_title.style.flexGrow = 1f;
|
||||||
|
_round = LuneUi.Label("", "HudValue", head);
|
||||||
|
_rows = LuneUi.Box(true, 2f, column);
|
||||||
|
_note = LuneUi.Wrap(LuneUi.Label("", "HudMuted", column));
|
||||||
|
_extra = LuneUi.Box(true, 3f, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string NoteText { get { return _note.text; } }
|
||||||
|
|
||||||
|
public void Apply(LuneHudModel.WaveInfo wave, int ownTeam, bool fighting)
|
||||||
|
{
|
||||||
|
string signature = wave + "|" + ownTeam + "|" + fighting;
|
||||||
|
if (signature == _signature)
|
||||||
|
return;
|
||||||
|
_signature = signature;
|
||||||
|
Compact = fighting;
|
||||||
|
_title.text = fighting ? "DANACH" : "NÄCHSTE WELLE";
|
||||||
|
_round.text = "WELLE " + wave.Round;
|
||||||
|
_rows.Clear();
|
||||||
|
_extra.Clear();
|
||||||
|
if (fighting)
|
||||||
|
{
|
||||||
|
_note.text = string.Join(", ", wave.Monsters.Select(m => m.Count + "× " + m.Name + (m.New ? " (neu)" : ""))) + " je Lane";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (LuneHudModel.MonsterGroup monster in wave.Monsters)
|
||||||
|
{
|
||||||
|
VisualElement row = LuneUi.Box(false, 7f, _rows);
|
||||||
|
row.pickingMode = PickingMode.Position;
|
||||||
|
var portrait = new LunePortraitElement();
|
||||||
|
portrait.style.width = 30f;
|
||||||
|
portrait.style.height = 30f;
|
||||||
|
row.Add(portrait);
|
||||||
|
portrait.ShowModel(monster.Id);
|
||||||
|
LuneUi.Label(monster.Count + "×", "HudValue", row).style.minWidth = 30f;
|
||||||
|
Label title = LuneUi.Clip(LuneUi.Label(monster.Name, "HudText", row));
|
||||||
|
title.style.flexGrow = 1f;
|
||||||
|
LuneUi.Glyph("armor", LuneTypeColors.Armor(monster.Armor), 13f, row);
|
||||||
|
LuneUi.SetColor(LuneUi.Label(LuneStats.ArmorName(monster.Armor), "HudMuted", row), LuneTypeColors.Armor(monster.Armor));
|
||||||
|
if (monster.New)
|
||||||
|
{
|
||||||
|
var badge = new LuneStatusBadge();
|
||||||
|
badge.SetState("NEU", LuneUiTheme.Warn);
|
||||||
|
row.Add(badge);
|
||||||
|
}
|
||||||
|
LuneHudModel.MonsterGroup data = monster;
|
||||||
|
int round = wave.Round;
|
||||||
|
LuneTooltip.Attach(row, () => new LuneTip { Title = data.Name, Subtitle = "Monster der Welle " + round, Note = "Beide Teams erhalten auf jeder Lane dieselbe Welle." }
|
||||||
|
.Stat("heart", "Leben", LuneUiTheme.Number(data.Hp)).Stat("sword", "Schaden/Treffer", LuneUiTheme.Decimal(data.Damage))
|
||||||
|
.Stat("range", "Reichweite", LuneUiTheme.Decimal(data.Range) + " m").Stat("armor", "Rüstung", LuneStats.ArmorName(data.Armor)));
|
||||||
|
}
|
||||||
|
// Counter hint as in Legion TD 2: the armour most of the wave wears and the damage type
|
||||||
|
// that hits it for 130 % (DamageMatrix).
|
||||||
|
int[] armour = new int[3];
|
||||||
|
foreach (LuneHudModel.MonsterGroup monster in wave.Monsters)
|
||||||
|
armour[Mathf.Clamp(monster.Armor, 0, 2)] += monster.Count;
|
||||||
|
int main = Array.IndexOf(armour, armour.Max());
|
||||||
|
int counter = 0;
|
||||||
|
for (int attack = 0; attack < 3; attack++)
|
||||||
|
{
|
||||||
|
if (Math.Abs(LuneUnit.DamageMatrix[attack, main] - 1.30) < 1e-9)
|
||||||
|
counter = attack;
|
||||||
|
}
|
||||||
|
_note.text = wave.Count + " Monster je Lane · beide Seiten gleich\nMeist " + LuneStats.ArmorName(main) + ": " + LuneStats.DamageName(counter) + " trifft stark";
|
||||||
|
if (wave.Champions.Count == 0 && wave.Boss == null)
|
||||||
|
return;
|
||||||
|
LuneUi.Label("ANGEKÜNDIGT", "HudCaps", _extra);
|
||||||
|
foreach (LuneHudModel.ChampionNotice entry in wave.Champions)
|
||||||
|
{
|
||||||
|
bool hostile = !entry.Own;
|
||||||
|
VisualElement row = LuneUi.Box(false, 6f, _extra);
|
||||||
|
LuneUi.Glyph("champion", hostile ? LuneUiTheme.Danger : LuneUiTheme.Champion, 15f, row);
|
||||||
|
LuneUi.Clip(LuneUi.Label(entry.Name + " → " + LuneUi.Upper(LuneMap.LaneNames[entry.Lane]), "HudText", row)).style.flexGrow = 1f;
|
||||||
|
var badge = new LuneStatusBadge();
|
||||||
|
badge.SetState(hostile ? "GEGEN EUCH" : "FÜR EUCH", hostile ? LuneUiTheme.Danger : LuneUiTheme.Good);
|
||||||
|
row.Add(badge);
|
||||||
|
}
|
||||||
|
if (wave.Boss != null)
|
||||||
|
{
|
||||||
|
bool hostile = !wave.Boss.Own;
|
||||||
|
VisualElement row = LuneUi.Box(false, 6f, _extra);
|
||||||
|
LuneUi.Glyph("crown", hostile ? LuneUiTheme.Danger : LuneUiTheme.TeamColor(ownTeam), 16f, row);
|
||||||
|
LuneUi.Clip(LuneUi.Label("Schlundkrone → " + LuneUi.Upper(LuneMap.LaneNames[wave.Boss.Lane]), "HudText", row)).style.flexGrow = 1f;
|
||||||
|
var badge = new LuneStatusBadge();
|
||||||
|
badge.SetState(hostile ? "GEGEN EUCH" : "FÜR EUCH", hostile ? LuneUiTheme.Danger : LuneUiTheme.Good, "crown");
|
||||||
|
row.Add(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneLanePanels.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 363dc4367ba484d61b1dc04102c80f37
|
||||||
441
Assets/Luneraria/UI/Hud/LuneMinimap.cs
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Luneraria.Core;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Minimap from the public match state (port of scripts/luneraria/ui/lune_minimap.gd). Lanes,
|
||||||
|
/// river, grotto and both bases as the map; every building in its team's shape language (round
|
||||||
|
/// = Blau, angular = Rot; Ancients as crescent and sun disc with an HP ring); heroes as small
|
||||||
|
/// portraits (G3 open point, Dota principle), attack champions and the boss as their own
|
||||||
|
/// markers; troops and monsters merged per map cell into one marker whose size grows with the
|
||||||
|
/// count, so there is no cloud of pixels. Click or drag moves the camera; the minimap never
|
||||||
|
/// buys, orders or changes anything. It redraws only when the HUD refreshes it (about ten times
|
||||||
|
/// a second in combat); the camera frame lives on a separate overlay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneMinimap : VisualElement
|
||||||
|
{
|
||||||
|
public const float Cell = 6f;
|
||||||
|
/// <summary>Radius of the grotto arena (lune_terrain.gd ARENA_RADIUS).</summary>
|
||||||
|
public const float ArenaRadius = 7.8f;
|
||||||
|
const float Extent = (float)LuneMap.HalfExtent + 4f;
|
||||||
|
const float HeroMarker = 18f;
|
||||||
|
|
||||||
|
public event Action<Vec2> FocusRequested;
|
||||||
|
public LuneMatch Match;
|
||||||
|
public int ViewerTeam;
|
||||||
|
public int HumanSlot;
|
||||||
|
/// <summary>Counts for tests and the performance log.</summary>
|
||||||
|
public int Clusters { get; private set; }
|
||||||
|
public int Heroes { get; private set; }
|
||||||
|
public int Champions { get; private set; }
|
||||||
|
public int Bosses { get; private set; }
|
||||||
|
public int Units { get; private set; }
|
||||||
|
public int Redraws { get; private set; }
|
||||||
|
|
||||||
|
readonly List<(int team, Vector2 at, int count)> _clusters = new List<(int, Vector2, int)>();
|
||||||
|
readonly List<LuneUnit> _specials = new List<LuneUnit>();
|
||||||
|
readonly List<Announcement> _dangers = new List<Announcement>();
|
||||||
|
readonly VisualElement _heroLayer;
|
||||||
|
readonly List<VisualElement> _heroMarkers = new List<VisualElement>();
|
||||||
|
readonly VisualElement _overlay;
|
||||||
|
Vec2[] _footprint = new Vec2[0];
|
||||||
|
Vec2 _focus;
|
||||||
|
float _span = 34f;
|
||||||
|
bool _dragging;
|
||||||
|
|
||||||
|
public LuneMinimap()
|
||||||
|
{
|
||||||
|
style.width = 224f;
|
||||||
|
style.height = 224f;
|
||||||
|
style.overflow = Overflow.Hidden;
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
generateVisualContent += Draw;
|
||||||
|
_heroLayer = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
_heroLayer.style.position = Position.Absolute;
|
||||||
|
_heroLayer.style.left = 0f;
|
||||||
|
_heroLayer.style.top = 0f;
|
||||||
|
_heroLayer.style.right = 0f;
|
||||||
|
_heroLayer.style.bottom = 0f;
|
||||||
|
Add(_heroLayer);
|
||||||
|
_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 += DrawFrame;
|
||||||
|
Add(_overlay);
|
||||||
|
RegisterCallback<PointerDownEvent>(OnDown);
|
||||||
|
RegisterCallback<PointerMoveEvent>(OnMove);
|
||||||
|
RegisterCallback<PointerUpEvent>(OnUp);
|
||||||
|
RegisterCallback<GeometryChangedEvent>(_ => { MarkDirtyRepaint(); _overlay.MarkDirtyRepaint(); PlaceHeroes(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2 Size { get { return new Vector2(layout.width, layout.height); } }
|
||||||
|
|
||||||
|
public Vector2 ToMap(Vec2 v)
|
||||||
|
{
|
||||||
|
Vector2 size = Size;
|
||||||
|
return new Vector2(((float)v.X + Extent) / (Extent * 2f) * size.x, ((float)v.Y + Extent) / (Extent * 2f) * size.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec2 ToWorld(Vector2 p)
|
||||||
|
{
|
||||||
|
Vector2 size = Size;
|
||||||
|
return new Vec2(p.x / Mathf.Max(1f, size.x) * Extent * 2f - Extent, p.y / Mathf.Max(1f, size.y) * Extent * 2f - Extent);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnDown(PointerDownEvent evt)
|
||||||
|
{
|
||||||
|
if (evt.button != 0)
|
||||||
|
return;
|
||||||
|
_dragging = true;
|
||||||
|
this.CapturePointer(evt.pointerId);
|
||||||
|
FocusRequested?.Invoke(ToWorld(evt.localPosition));
|
||||||
|
evt.StopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnMove(PointerMoveEvent evt)
|
||||||
|
{
|
||||||
|
if (!_dragging)
|
||||||
|
return;
|
||||||
|
FocusRequested?.Invoke(ToWorld(evt.localPosition));
|
||||||
|
evt.StopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnUp(PointerUpEvent evt)
|
||||||
|
{
|
||||||
|
if (!_dragging)
|
||||||
|
return;
|
||||||
|
_dragging = false;
|
||||||
|
this.ReleasePointer(evt.pointerId);
|
||||||
|
evt.StopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Camera focus, view span and the ground outline of the view (empty = rectangle around the focus).</summary>
|
||||||
|
public void SetCamera(Vec2 focus, float span, Vec2[] footprint)
|
||||||
|
{
|
||||||
|
bool moved = footprint == null || footprint.Length != _footprint.Length;
|
||||||
|
if (!moved)
|
||||||
|
{
|
||||||
|
for (int index = 0; index < footprint.Length; index++)
|
||||||
|
{
|
||||||
|
if (footprint[index].DistanceSquaredTo(_footprint[index]) > .04)
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved |= focus.DistanceSquaredTo(_focus) > .04 || Mathf.Abs(span - _span) > .1f;
|
||||||
|
if (!moved)
|
||||||
|
return;
|
||||||
|
_footprint = footprint ?? new Vec2[0];
|
||||||
|
_focus = focus;
|
||||||
|
_span = span;
|
||||||
|
_overlay.MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recomputes markers from the match (called by the HUD at its update rate).</summary>
|
||||||
|
public void Refresh(List<Announcement> announcements)
|
||||||
|
{
|
||||||
|
_clusters.Clear();
|
||||||
|
_specials.Clear();
|
||||||
|
_dangers.Clear();
|
||||||
|
if (announcements != null)
|
||||||
|
_dangers.AddRange(announcements);
|
||||||
|
Clusters = Heroes = Champions = Bosses = Units = 0;
|
||||||
|
if (Match != null && Match.Phase == Phase.Combat && Match.Combat != null)
|
||||||
|
{
|
||||||
|
var cells = new Dictionary<(int, int, int), (Vector2 sum, int count)>();
|
||||||
|
var order = new List<(int, int, int)>();
|
||||||
|
foreach (LuneUnit unit in Match.Combat.Units)
|
||||||
|
{
|
||||||
|
if (!unit.Alive || !unit.IsMobile())
|
||||||
|
continue;
|
||||||
|
Units++;
|
||||||
|
if (unit.Kind == "hero" || unit.Kind == "champion" || unit.Kind == "boss")
|
||||||
|
{
|
||||||
|
_specials.Add(unit);
|
||||||
|
if (unit.Kind == "hero")
|
||||||
|
Heroes++;
|
||||||
|
else if (unit.Kind == "champion")
|
||||||
|
Champions++;
|
||||||
|
else
|
||||||
|
Bosses++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var key = (unit.Team, (int)Math.Floor(unit.Position.X / Cell), (int)Math.Floor(unit.Position.Y / Cell));
|
||||||
|
if (!cells.TryGetValue(key, out var entry))
|
||||||
|
{
|
||||||
|
entry = (Vector2.zero, 0);
|
||||||
|
order.Add(key);
|
||||||
|
}
|
||||||
|
cells[key] = (entry.sum + new Vector2((float)unit.Position.X, (float)unit.Position.Y), entry.count + 1);
|
||||||
|
}
|
||||||
|
foreach (var key in order)
|
||||||
|
{
|
||||||
|
var entry = cells[key];
|
||||||
|
_clusters.Add((key.Item1, entry.sum / entry.count, entry.count));
|
||||||
|
}
|
||||||
|
Clusters = _clusters.Count;
|
||||||
|
}
|
||||||
|
Redraws++;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
PlaceHeroes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hero markers are portraits in rings: child elements placed over the map.</summary>
|
||||||
|
void PlaceHeroes()
|
||||||
|
{
|
||||||
|
int used = 0;
|
||||||
|
float scale = Size.x / 224f;
|
||||||
|
if (float.IsNaN(scale) || scale <= 0f)
|
||||||
|
return;
|
||||||
|
foreach (LuneUnit unit in _specials)
|
||||||
|
{
|
||||||
|
if (unit.Kind != "hero")
|
||||||
|
continue;
|
||||||
|
if (used >= _heroMarkers.Count)
|
||||||
|
{
|
||||||
|
var marker = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
marker.style.position = Position.Absolute;
|
||||||
|
marker.style.overflow = Overflow.Hidden;
|
||||||
|
var portrait = new LunePortraitElement();
|
||||||
|
portrait.style.flexGrow = 1f;
|
||||||
|
marker.Add(portrait);
|
||||||
|
_heroLayer.Add(marker);
|
||||||
|
_heroMarkers.Add(marker);
|
||||||
|
}
|
||||||
|
VisualElement element = _heroMarkers[used++];
|
||||||
|
float edge = HeroMarker * scale;
|
||||||
|
Vector2 at = ToMap(unit.Position);
|
||||||
|
element.style.left = at.x - edge * .5f;
|
||||||
|
element.style.top = at.y - edge * .5f;
|
||||||
|
element.style.width = edge;
|
||||||
|
element.style.height = edge;
|
||||||
|
float radius = edge * .5f;
|
||||||
|
element.style.borderTopLeftRadius = radius;
|
||||||
|
element.style.borderTopRightRadius = radius;
|
||||||
|
element.style.borderBottomLeftRadius = radius;
|
||||||
|
element.style.borderBottomRightRadius = radius;
|
||||||
|
Color ring = unit.OwnerSlot == HumanSlot ? Color.white : LuneUiTheme.Hero;
|
||||||
|
float width = 1.6f * scale;
|
||||||
|
element.style.borderLeftWidth = width;
|
||||||
|
element.style.borderRightWidth = width;
|
||||||
|
element.style.borderTopWidth = width;
|
||||||
|
element.style.borderBottomWidth = width;
|
||||||
|
element.style.borderLeftColor = ring;
|
||||||
|
element.style.borderRightColor = ring;
|
||||||
|
element.style.borderTopColor = ring;
|
||||||
|
element.style.borderBottomColor = ring;
|
||||||
|
element.style.backgroundColor = LuneUiTheme.TeamColor(unit.Team);
|
||||||
|
((LunePortraitElement)element[0]).ShowModel(unit.Def.Id);
|
||||||
|
LuneUi.Show(element, true);
|
||||||
|
}
|
||||||
|
for (int index = used; index < _heroMarkers.Count; index++)
|
||||||
|
LuneUi.Show(_heroMarkers[index], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D p = context.painter2D;
|
||||||
|
Vector2 size = Size;
|
||||||
|
if (size.x <= 0f)
|
||||||
|
return;
|
||||||
|
LunePlates.Polygon(p, LuneHpBar.Corners(new Rect(0f, 0f, size.x, size.y)), new Color(.035f, .05f, .06f, .95f));
|
||||||
|
if (Match == null)
|
||||||
|
return;
|
||||||
|
float s = size.x / 224f;
|
||||||
|
// Ground: the Kluft from the grotto spring into the south-eastern delta, lanes, plazas.
|
||||||
|
Vec2 spring = LuneMap.Grotto + LuneMap.Axis * ArenaRadius;
|
||||||
|
Polyline(p, new[] { ToMap(spring), ToMap(new Vec2(40, 40)) }, new Color(.1f, .3f, .34f, .9f), 6f * s);
|
||||||
|
Disc(p, ToMap(new Vec2(33, 33)), 10f * s, new Color(.1f, .3f, .34f, .9f));
|
||||||
|
for (int team = 0; team < 2; team++)
|
||||||
|
Disc(p, ToMap(Match.Map.CorePosition(team)), 17f * s, LuneUiTheme.WithAlpha(LuneUiTheme.TeamDark[team], .55f));
|
||||||
|
foreach (string lane in LuneMap.Lanes)
|
||||||
|
{
|
||||||
|
Vec2[] points = Match.Map.LanePaths[lane].Points;
|
||||||
|
var mapped = new Vector2[points.Length];
|
||||||
|
for (int index = 0; index < points.Length; index++)
|
||||||
|
mapped[index] = ToMap(points[index]);
|
||||||
|
Polyline(p, mapped, new Color(.2f, .19f, .16f), 7f * s);
|
||||||
|
Polyline(p, mapped, new Color(.55f, .5f, .4f), 3.2f * s);
|
||||||
|
}
|
||||||
|
DrawGrotto(p, s);
|
||||||
|
foreach (BuildingDef definition in Match.Map.Buildings)
|
||||||
|
DrawBuilding(p, definition, s);
|
||||||
|
// Announced dangers: a warning mark in the middle of the lane.
|
||||||
|
foreach (Announcement entry in _dangers)
|
||||||
|
{
|
||||||
|
if (entry.Team == ViewerTeam)
|
||||||
|
continue;
|
||||||
|
Vector2 at = ToMap(Match.Map.LanePoint(ViewerTeam, entry.Lane, Match.Map.LanePaths[entry.Lane].Length * .5));
|
||||||
|
float edge = 13f * s;
|
||||||
|
Disc(p, at, edge * .62f, new Color(0f, 0f, 0f, .6f));
|
||||||
|
LuneGlyphs.Draw(p, entry.Kind == "boss" ? "crown" : "warning", new Rect(at - new Vector2(edge, edge) * .5f, new Vector2(edge, edge)), LuneUiTheme.Danger);
|
||||||
|
}
|
||||||
|
// Armies merged per cell.
|
||||||
|
foreach (var cluster in _clusters)
|
||||||
|
{
|
||||||
|
Vector2 at = ToMap(new Vec2(cluster.at.x, cluster.at.y));
|
||||||
|
float radius = (2f + 1.15f * Mathf.Sqrt(cluster.count)) * s;
|
||||||
|
Color tint = cluster.team <= 1 ? LuneUiTheme.TeamColor(cluster.team) : LuneUiTheme.Team[2];
|
||||||
|
Shape(p, cluster.team, at, radius + 1.2f * s, new Color(0f, 0f, 0f, .7f));
|
||||||
|
Shape(p, cluster.team, at, radius, LuneUiTheme.WithAlpha(tint, .9f));
|
||||||
|
}
|
||||||
|
// Champions and the boss on top (heroes are portrait elements over the map).
|
||||||
|
foreach (LuneUnit unit in _specials)
|
||||||
|
{
|
||||||
|
Vector2 at = ToMap(unit.Position);
|
||||||
|
Color tint = LuneUiTheme.TeamColor(unit.Team);
|
||||||
|
if (unit.Kind == "hero")
|
||||||
|
{
|
||||||
|
Disc(p, at, (HeroMarker * .5f + 1.5f) * s, new Color(0f, 0f, 0f, .75f));
|
||||||
|
}
|
||||||
|
else if (unit.Kind == "champion")
|
||||||
|
{
|
||||||
|
float edge = 12f * s;
|
||||||
|
Disc(p, at, edge * .55f, new Color(0f, 0f, 0f, .75f));
|
||||||
|
LuneGlyphs.Draw(p, "champion", new Rect(at - new Vector2(edge, edge) * .5f, new Vector2(edge, edge)), Color.Lerp(tint, LuneUiTheme.Champion, .35f));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float edge = 17f * s;
|
||||||
|
Disc(p, at, edge * .6f, new Color(0f, 0f, 0f, .8f));
|
||||||
|
Ring(p, at, edge * .6f, 0f, Mathf.PI * 2f, tint, 1.6f * s);
|
||||||
|
LuneGlyphs.Draw(p, "crown", new Rect(at - new Vector2(edge, edge) * .45f, new Vector2(edge, edge) * .9f), LuneUiTheme.Lightened(tint, .2f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LunePlates.Outline(p, LuneHpBar.Corners(new Rect(.5f, .5f, size.x - 1f, size.y - 1f)), LuneUiTheme.Hex("3a4656"), 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawFrame(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D p = context.painter2D;
|
||||||
|
Vector2 size = Size;
|
||||||
|
if (size.x <= 0f)
|
||||||
|
return;
|
||||||
|
if (_footprint.Length == 4)
|
||||||
|
{
|
||||||
|
var outline = new Vector2[4];
|
||||||
|
for (int index = 0; index < 4; index++)
|
||||||
|
{
|
||||||
|
Vector2 corner = ToMap(_footprint[index]);
|
||||||
|
outline[index] = new Vector2(Mathf.Clamp(corner.x, 0f, size.x), Mathf.Clamp(corner.y, 0f, size.y));
|
||||||
|
}
|
||||||
|
LunePlates.Polygon(p, outline, new Color(1f, 1f, 1f, .05f));
|
||||||
|
LunePlates.Outline(p, outline, new Color(1f, 1f, 1f, .75f), 1.3f);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Vector2 center = ToMap(_focus);
|
||||||
|
float span = _span / (Extent * 2f) * size.x;
|
||||||
|
LunePlates.Outline(p, LuneHpBar.Corners(new Rect(center.x - span * .9f, center.y - span * .55f, span * 1.8f, span * 1.1f)), new Color(1f, 1f, 1f, .6f), 1.2f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Shape(Painter2D p, int team, Vector2 at, float radius, Color color)
|
||||||
|
{
|
||||||
|
if (team == 1)
|
||||||
|
LunePlates.Polygon(p, new[] { at + new Vector2(0f, -radius * 1.2f), at + new Vector2(radius * 1.2f, 0f), at + new Vector2(0f, radius * 1.2f), at + new Vector2(-radius * 1.2f, 0f) }, color);
|
||||||
|
else
|
||||||
|
Disc(p, at, radius, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawBuilding(Painter2D p, BuildingDef definition, float s)
|
||||||
|
{
|
||||||
|
string id = definition.Id;
|
||||||
|
int team = definition.Team;
|
||||||
|
bool alive = Match.BuildingAlive(id);
|
||||||
|
double hp = Match.BuildingHp.GetValueOrDefault(id, 0.0);
|
||||||
|
if (Match.Phase == Phase.Combat && Match.Combat != null && Match.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
|
||||||
|
{
|
||||||
|
alive = unit.Alive;
|
||||||
|
hp = unit.Hp;
|
||||||
|
}
|
||||||
|
float fraction = alive ? Mathf.Clamp01((float)(hp / Math.Max(1.0, definition.MaxHp))) : 0f;
|
||||||
|
Vector2 at = ToMap(definition.Position);
|
||||||
|
Color tint = LuneUiTheme.TeamColor(team);
|
||||||
|
if (definition.Tier == "core")
|
||||||
|
{
|
||||||
|
float edge = 18f * s;
|
||||||
|
float radius = 10.5f * s;
|
||||||
|
Disc(p, at, radius, new Color(0f, 0f, 0f, .7f));
|
||||||
|
Ring(p, at, radius, 0f, Mathf.PI * 2f, LuneUiTheme.WithAlpha(tint, .25f), 2.4f * s);
|
||||||
|
if (fraction > 0f)
|
||||||
|
Ring(p, at, radius, -Mathf.PI * .5f, -Mathf.PI * .5f + Mathf.PI * 2f * fraction, LuneHpBar.HealthColor(fraction, tint), 2.4f * s);
|
||||||
|
LuneGlyphs.Draw(p, "emblem_" + team, new Rect(at - new Vector2(edge, edge) * .38f, new Vector2(edge, edge) * .76f), alive ? tint : new Color(.4f, .4f, .4f));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float r = (definition.Tier == "base" ? 4.4f : 3.8f) * s;
|
||||||
|
if (alive)
|
||||||
|
{
|
||||||
|
Shape(p, team, at, r + 1.3f * s, new Color(0f, 0f, 0f, .75f));
|
||||||
|
Shape(p, team, at, r, tint);
|
||||||
|
if (fraction < .99f)
|
||||||
|
{
|
||||||
|
var bar = new Rect(at.x - r - 1f, at.y + r + 2.5f * s, r * 2f + 2f, 2f * s);
|
||||||
|
LunePlates.Polygon(p, LuneHpBar.Corners(bar), new Color(0f, 0f, 0f, .8f));
|
||||||
|
LunePlates.Polygon(p, LuneHpBar.Corners(new Rect(bar.x, bar.y, bar.width * fraction, bar.height)), LuneHpBar.HealthColor(fraction, tint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Shape(p, team, at, r, new Color(0f, 0f, 0f, .55f));
|
||||||
|
float cross = r * .8f;
|
||||||
|
var gray = new Color(.55f, .55f, .58f);
|
||||||
|
LunePlates.Line(p, at - new Vector2(cross, cross), at + new Vector2(cross, cross), gray, 1.4f * s);
|
||||||
|
LunePlates.Line(p, at + new Vector2(-cross, cross), at + new Vector2(cross, -cross), gray, 1.4f * s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawGrotto(Painter2D p, float s)
|
||||||
|
{
|
||||||
|
Vector2 at = ToMap(LuneMap.Grotto);
|
||||||
|
BossClaim claim = Match.Boss.Claim;
|
||||||
|
Color tint = LuneUiTheme.Team[2];
|
||||||
|
if (claim != null)
|
||||||
|
tint = LuneUiTheme.TeamColor(claim.Team);
|
||||||
|
else if (!Match.GrottoAvailable())
|
||||||
|
tint = new Color(.42f, .4f, .46f);
|
||||||
|
float edge = 16f * s;
|
||||||
|
Disc(p, at, edge * .72f, new Color(0f, 0f, 0f, .65f));
|
||||||
|
Ring(p, at, edge * .72f, 0f, Mathf.PI * 2f, LuneUiTheme.WithAlpha(tint, .9f), 1.4f * s);
|
||||||
|
LuneGlyphs.Draw(p, "grotto", new Rect(at - new Vector2(edge, edge) * .42f, new Vector2(edge, edge) * .84f), tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Disc(Painter2D p, Vector2 center, float radius, Color color)
|
||||||
|
{
|
||||||
|
p.fillColor = color;
|
||||||
|
p.BeginPath();
|
||||||
|
p.Arc(center, radius, Angle.Degrees(0f), Angle.Degrees(360f));
|
||||||
|
p.ClosePath();
|
||||||
|
p.Fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Ring(Painter2D p, Vector2 center, float radius, float start, float end, Color color, float width)
|
||||||
|
{
|
||||||
|
p.strokeColor = color;
|
||||||
|
p.lineWidth = width;
|
||||||
|
p.lineCap = LineCap.Butt;
|
||||||
|
p.BeginPath();
|
||||||
|
p.Arc(center, radius, Angle.Radians(start), Angle.Radians(end));
|
||||||
|
p.Stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Polyline(Painter2D p, Vector2[] points, Color color, float width)
|
||||||
|
{
|
||||||
|
p.strokeColor = color;
|
||||||
|
p.lineWidth = width;
|
||||||
|
p.lineJoin = LineJoin.Round;
|
||||||
|
p.lineCap = LineCap.Round;
|
||||||
|
p.BeginPath();
|
||||||
|
p.MoveTo(points[0]);
|
||||||
|
for (int index = 1; index < points.Length; index++)
|
||||||
|
p.LineTo(points[index]);
|
||||||
|
p.Stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneMinimap.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3e19158da54244294a97035484e94693
|
||||||
431
Assets/Luneraria/UI/Hud/LunePortraits.cs
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LunePortraits.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 48624641cbea941c2bc39a78849afbd1
|
||||||
108
Assets/Luneraria/UI/Hud/LunePreferences.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Player preferences of the HUD and options (port of scripts/autoload/preferences.gd as far as
|
||||||
|
/// the HUD uses it): UI size, reduced motion, volume, mute, full screen. Stored in PlayerPrefs.
|
||||||
|
/// Tests can set values without touching the stored ones (Persist = false).
|
||||||
|
/// </summary>
|
||||||
|
public static class LunePreferences
|
||||||
|
{
|
||||||
|
public static event Action Changed;
|
||||||
|
/// <summary>False in tests: values change for the session only.</summary>
|
||||||
|
public static bool Persist = true;
|
||||||
|
|
||||||
|
static float _uiScale = -1f;
|
||||||
|
static int _reducedMotion = -1;
|
||||||
|
static float _volume = -1f;
|
||||||
|
static int _muted = -1;
|
||||||
|
static int _fullscreen = -1;
|
||||||
|
|
||||||
|
public static float UiScale
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_uiScale < 0f)
|
||||||
|
_uiScale = PlayerPrefs.GetFloat("lune.ui_scale", 1f);
|
||||||
|
return _uiScale;
|
||||||
|
}
|
||||||
|
set { _uiScale = value; Save("lune.ui_scale", value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ReducedMotion
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_reducedMotion < 0)
|
||||||
|
_reducedMotion = PlayerPrefs.GetInt("lune.reduced_motion", 0);
|
||||||
|
return _reducedMotion == 1;
|
||||||
|
}
|
||||||
|
set { _reducedMotion = value ? 1 : 0; Save("lune.reduced_motion", _reducedMotion); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Volume
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_volume < 0f)
|
||||||
|
_volume = PlayerPrefs.GetFloat("lune.volume", .8f);
|
||||||
|
return _volume;
|
||||||
|
}
|
||||||
|
set { _volume = Mathf.Clamp01(value); Save("lune.volume", _volume); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Muted
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_muted < 0)
|
||||||
|
_muted = PlayerPrefs.GetInt("lune.muted", 0);
|
||||||
|
return _muted == 1;
|
||||||
|
}
|
||||||
|
set { _muted = value ? 1 : 0; Save("lune.muted", _muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Fullscreen
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_fullscreen < 0)
|
||||||
|
_fullscreen = PlayerPrefs.GetInt("lune.fullscreen", Screen.fullScreen ? 1 : 0);
|
||||||
|
return _fullscreen == 1;
|
||||||
|
}
|
||||||
|
set { _fullscreen = value ? 1 : 0; Save("lune.fullscreen", _fullscreen); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Save(string key, float value)
|
||||||
|
{
|
||||||
|
if (Persist)
|
||||||
|
PlayerPrefs.SetFloat(key, value);
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Save(string key, int value)
|
||||||
|
{
|
||||||
|
if (Persist)
|
||||||
|
PlayerPrefs.SetInt(key, value);
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes pending values (Godot Preferences.commit).</summary>
|
||||||
|
public static void Commit()
|
||||||
|
{
|
||||||
|
if (Persist)
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Session values back to defaults (tests).</summary>
|
||||||
|
public static void ResetSession(float uiScale = 1f, bool reducedMotion = false)
|
||||||
|
{
|
||||||
|
Persist = false;
|
||||||
|
_uiScale = uiScale;
|
||||||
|
_reducedMotion = reducedMotion ? 1 : 0;
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LunePreferences.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ae0ab59288f8a4fb5bbe5a3cc3237a2c
|
||||||
301
Assets/Luneraria/UI/Hud/LuneSettingsPanel.cs
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options and controls (port of scripts/luneraria/ui/lune_settings.gd): volume, mute, full
|
||||||
|
/// screen, reduced motion and the HUD size; the key list is the one place where every shortcut
|
||||||
|
/// is shown. A running combat pauses offline while this window is open (the game controller
|
||||||
|
/// does that on Opened/Closed).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneSettingsPanel : VisualElement
|
||||||
|
{
|
||||||
|
public static readonly float[] Scales = { .9f, 1f, 1.15f, 1.3f };
|
||||||
|
public static readonly (string group, (string key, string action)[] pairs)[] Keys =
|
||||||
|
{
|
||||||
|
("Planung", new[] { ("1–5", "Truppe kaufen"), ("Leertaste", "Bereit"), ("Klick auf Einheit", "Details, Aufwerten, Einsatz"), ("Esc", "Auswahl oder Fenster schließen") }),
|
||||||
|
("Kampf", new[] { ("Leertaste / P", "Pause (offline)"), ("Tempo-Knopf", "1× · 2× · 4×") }),
|
||||||
|
("Kamera", new[] { ("F2 · F3 · F4", "Top · Mid · Bottom"), ("G", "Bossgrotte"), ("B", "eigener Ancient"), ("H", "eigener Held"), ("Tab", "nächste Lane"),
|
||||||
|
("O", "Übersicht"), ("R", "zurücksetzen"), ("Q / E", "drehen"), ("WASD · Pfeile · mittlere Maustaste", "verschieben"), ("Mausrad", "Zoom"), ("Minimap", "klicken oder ziehen") }),
|
||||||
|
("Allgemein", new[] { ("F1", "Optionen"), ("M", "Ton an/aus"), ("F11", "Vollbild") }),
|
||||||
|
};
|
||||||
|
|
||||||
|
public event Action Opened;
|
||||||
|
public event Action Closed;
|
||||||
|
readonly LuneSlider _volume;
|
||||||
|
readonly Dictionary<string, LuneSwitch> _checks = new Dictionary<string, LuneSwitch>();
|
||||||
|
public readonly List<LuneButton> ScaleButtons = new List<LuneButton>();
|
||||||
|
|
||||||
|
public LuneSettingsPanel()
|
||||||
|
{
|
||||||
|
style.position = Position.Absolute;
|
||||||
|
style.left = 0f;
|
||||||
|
style.top = 0f;
|
||||||
|
style.right = 0f;
|
||||||
|
style.bottom = 0f;
|
||||||
|
style.backgroundColor = new Color(.02f, .03f, .04f, .9f);
|
||||||
|
style.justifyContent = Justify.Center;
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
RegisterCallback<WheelEvent>(evt => evt.StopPropagation());
|
||||||
|
var panel = new LunePanel(LunePanels.Menu);
|
||||||
|
panel.style.width = 900f;
|
||||||
|
Add(panel);
|
||||||
|
VisualElement column = LuneUi.Box(true, 10f, panel);
|
||||||
|
Label heading = LuneUi.Label("Optionen & Steuerung", "HeadingLabel", column);
|
||||||
|
heading.style.alignSelf = Align.Center;
|
||||||
|
LuneUi.Label("Ein laufender Kampf pausiert offline, solange dieses Fenster geöffnet ist.", "MutedLabel", column).style.alignSelf = Align.Center;
|
||||||
|
VisualElement top = LuneUi.Box(false, 30f, column);
|
||||||
|
top.style.alignItems = Align.FlexStart;
|
||||||
|
VisualElement left = LuneUi.Box(true, 8f, top);
|
||||||
|
left.style.flexGrow = 1f;
|
||||||
|
left.style.flexBasis = 0f;
|
||||||
|
LuneUi.Label("LAUTSTÄRKE", "SectionLabel", left);
|
||||||
|
_volume = new LuneSlider();
|
||||||
|
left.Add(_volume);
|
||||||
|
_volume.Changed += value =>
|
||||||
|
{
|
||||||
|
LunePreferences.Volume = value;
|
||||||
|
LunePreferences.Commit();
|
||||||
|
Apply();
|
||||||
|
};
|
||||||
|
foreach ((string key, string text) entry in new[] { ("muted", "Ton stumm [M]"), ("fullscreen", "Vollbild [F11]"), ("reduced_motion", "Bewegung reduzieren") })
|
||||||
|
{
|
||||||
|
var check = new LuneSwitch(entry.text);
|
||||||
|
left.Add(check);
|
||||||
|
string key = entry.key;
|
||||||
|
check.Changed += value =>
|
||||||
|
{
|
||||||
|
if (key == "muted")
|
||||||
|
LunePreferences.Muted = value;
|
||||||
|
else if (key == "fullscreen")
|
||||||
|
LunePreferences.Fullscreen = value;
|
||||||
|
else
|
||||||
|
LunePreferences.ReducedMotion = value;
|
||||||
|
LunePreferences.Commit();
|
||||||
|
Apply();
|
||||||
|
};
|
||||||
|
_checks[key] = check;
|
||||||
|
}
|
||||||
|
VisualElement right = LuneUi.Box(true, 8f, top);
|
||||||
|
right.style.flexGrow = 1f;
|
||||||
|
right.style.flexBasis = 0f;
|
||||||
|
LuneUi.Label("UI-GRÖSSE IM SPIEL", "SectionLabel", right);
|
||||||
|
VisualElement scales = LuneUi.Box(false, 6f, right);
|
||||||
|
foreach (float value in Scales)
|
||||||
|
{
|
||||||
|
var button = new LuneButton(Mathf.RoundToInt(value * 100f) + " %", LuneButtons.HudToggle, scales);
|
||||||
|
button.style.flexGrow = 1f;
|
||||||
|
float captured = value;
|
||||||
|
button.Clicked += () =>
|
||||||
|
{
|
||||||
|
LunePreferences.UiScale = captured;
|
||||||
|
LunePreferences.Commit();
|
||||||
|
Sync();
|
||||||
|
};
|
||||||
|
ScaleButtons.Add(button);
|
||||||
|
}
|
||||||
|
LuneUi.Wrap(LuneUi.Label("Größer für hochauflösende oder weit entfernte Bildschirme. Das HUD begrenzt die Größe selbst, sodass nichts aus dem Fenster ragt.", "MutedLabel", right));
|
||||||
|
var line = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
line.style.height = 1f;
|
||||||
|
line.style.marginTop = 7f;
|
||||||
|
line.style.marginBottom = 7f;
|
||||||
|
line.style.backgroundColor = LuneUiTheme.WithAlpha(LuneUiTheme.Bronze, .35f);
|
||||||
|
column.Add(line);
|
||||||
|
LuneUi.Label("STEUERUNG", "SectionLabel", column);
|
||||||
|
VisualElement keys = LuneUi.Box(false, 26f, column);
|
||||||
|
keys.style.alignItems = Align.FlexStart;
|
||||||
|
for (int index = 0; index < 2; index++)
|
||||||
|
{
|
||||||
|
VisualElement list = LuneUi.Box(true, 2f, keys);
|
||||||
|
list.style.flexGrow = 1f;
|
||||||
|
list.style.flexBasis = 0f;
|
||||||
|
var groups = index == 0 ? new[] { Keys[0], Keys[1], Keys[3] } : new[] { Keys[2] };
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
LuneUi.Label(LuneUi.Upper(group.group), "EyebrowLabel", list, 14f).style.marginTop = 6f;
|
||||||
|
foreach (var pair in group.pairs)
|
||||||
|
{
|
||||||
|
VisualElement row = LuneUi.Box(false, 14f, list);
|
||||||
|
Label key = LuneUi.Label(pair.key, "CardFactLabel", row, 15f);
|
||||||
|
key.style.minWidth = 160f;
|
||||||
|
LuneUi.SetColor(key, LuneUiTheme.BronzeLight);
|
||||||
|
LuneUi.Wrap(LuneUi.Label(pair.action, "MutedLabel", row, 15f)).style.flexGrow = 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var back = new LuneButton("ZURÜCK [Esc]", LuneButtons.Primary, column);
|
||||||
|
back.style.alignSelf = Align.Center;
|
||||||
|
back.Clicked += Close;
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Open { get { return style.display != DisplayStyle.None; } }
|
||||||
|
|
||||||
|
public void Show()
|
||||||
|
{
|
||||||
|
Sync();
|
||||||
|
LuneUi.Show(this, true);
|
||||||
|
BringToFront();
|
||||||
|
Opened?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!Open)
|
||||||
|
return;
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
Closed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the preferences into the controls (Godot sync()).</summary>
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
_volume.SetValueWithoutNotify(LunePreferences.Volume);
|
||||||
|
_checks["muted"].SetValueWithoutNotify(LunePreferences.Muted);
|
||||||
|
_checks["fullscreen"].SetValueWithoutNotify(LunePreferences.Fullscreen);
|
||||||
|
_checks["reduced_motion"].SetValueWithoutNotify(LunePreferences.ReducedMotion);
|
||||||
|
int best = 1;
|
||||||
|
for (int index = 0; index < Scales.Length; index++)
|
||||||
|
{
|
||||||
|
if (Mathf.Abs(Scales[index] - LunePreferences.UiScale) < Mathf.Abs(Scales[best] - LunePreferences.UiScale))
|
||||||
|
best = index;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < ScaleButtons.Count; index++)
|
||||||
|
ScaleButtons[index].On = index == best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Volume, mute and full screen take effect at once.</summary>
|
||||||
|
public static void Apply()
|
||||||
|
{
|
||||||
|
AudioListener.volume = LunePreferences.Muted ? 0f : LunePreferences.Volume;
|
||||||
|
if (Application.isBatchMode)
|
||||||
|
return;
|
||||||
|
if (Screen.fullScreen != LunePreferences.Fullscreen)
|
||||||
|
Screen.fullScreen = LunePreferences.Fullscreen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Horizontal slider in the HUD look (track, bronze fill, handle); value 0–1.</summary>
|
||||||
|
public sealed class LuneSlider : VisualElement
|
||||||
|
{
|
||||||
|
public event Action<float> Changed;
|
||||||
|
public float Value { get; private set; }
|
||||||
|
bool _drag;
|
||||||
|
|
||||||
|
public LuneSlider()
|
||||||
|
{
|
||||||
|
style.height = 28f;
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
generateVisualContent += Draw;
|
||||||
|
RegisterCallback<PointerDownEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (evt.button != 0)
|
||||||
|
return;
|
||||||
|
_drag = true;
|
||||||
|
this.CapturePointer(evt.pointerId);
|
||||||
|
Set(evt.localPosition.x);
|
||||||
|
});
|
||||||
|
RegisterCallback<PointerMoveEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (_drag)
|
||||||
|
Set(evt.localPosition.x);
|
||||||
|
});
|
||||||
|
RegisterCallback<PointerUpEvent>(evt =>
|
||||||
|
{
|
||||||
|
_drag = false;
|
||||||
|
this.ReleasePointer(evt.pointerId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Set(float x)
|
||||||
|
{
|
||||||
|
float value = Mathf.Clamp01((x - 9f) / Mathf.Max(1f, layout.width - 18f));
|
||||||
|
if (Mathf.Approximately(value, Value))
|
||||||
|
return;
|
||||||
|
Value = value;
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
Changed?.Invoke(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetValueWithoutNotify(float value)
|
||||||
|
{
|
||||||
|
Value = Mathf.Clamp01(value);
|
||||||
|
MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D p = context.painter2D;
|
||||||
|
float width = layout.width;
|
||||||
|
float y = layout.height * .5f;
|
||||||
|
LunePlates.Line(p, new Vector2(9f, y), new Vector2(width - 9f, y), new Color(0f, 0f, 0f, .55f), 6f);
|
||||||
|
float x = 9f + (width - 18f) * Value;
|
||||||
|
LunePlates.Line(p, new Vector2(9f, y), new Vector2(x, y), LuneUiTheme.Bronze, 6f);
|
||||||
|
p.fillColor = LuneUiTheme.BronzeLight;
|
||||||
|
p.BeginPath();
|
||||||
|
p.Arc(new Vector2(x, y), 8f, Angle.Degrees(0f), Angle.Degrees(360f));
|
||||||
|
p.ClosePath();
|
||||||
|
p.Fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On/off switch with a label (Godot CheckButton with the toggle icons of the menu theme).</summary>
|
||||||
|
public sealed class LuneSwitch : VisualElement
|
||||||
|
{
|
||||||
|
public event Action<bool> Changed;
|
||||||
|
public bool Value { get; private set; }
|
||||||
|
readonly VisualElement _knob;
|
||||||
|
|
||||||
|
public LuneSwitch(string text)
|
||||||
|
{
|
||||||
|
style.flexDirection = FlexDirection.Row;
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
style.height = 34f;
|
||||||
|
pickingMode = PickingMode.Position;
|
||||||
|
Label label = LuneUi.Label(text, "CardFactLabel", this);
|
||||||
|
label.style.flexGrow = 1f;
|
||||||
|
_knob = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
_knob.style.width = 44f;
|
||||||
|
_knob.style.height = 22f;
|
||||||
|
_knob.generateVisualContent += Draw;
|
||||||
|
Add(_knob);
|
||||||
|
RegisterCallback<PointerDownEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (evt.button != 0)
|
||||||
|
return;
|
||||||
|
Value = !Value;
|
||||||
|
_knob.MarkDirtyRepaint();
|
||||||
|
Changed?.Invoke(Value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetValueWithoutNotify(bool value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
_knob.MarkDirtyRepaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Draw(MeshGenerationContext context)
|
||||||
|
{
|
||||||
|
Painter2D p = context.painter2D;
|
||||||
|
float w = _knob.layout.width;
|
||||||
|
float h = _knob.layout.height;
|
||||||
|
float r = h * .5f;
|
||||||
|
p.fillColor = Value ? LuneUiTheme.WithAlpha(LuneUiTheme.Bronze, .85f) : new Color(0f, 0f, 0f, .5f);
|
||||||
|
p.BeginPath();
|
||||||
|
p.Arc(new Vector2(r, r), r, Angle.Degrees(90f), Angle.Degrees(270f));
|
||||||
|
p.LineTo(new Vector2(w - r, 0f));
|
||||||
|
p.Arc(new Vector2(w - r, r), r, Angle.Degrees(270f), Angle.Degrees(450f));
|
||||||
|
p.ClosePath();
|
||||||
|
p.Fill();
|
||||||
|
p.fillColor = Value ? LuneUiTheme.Parchment : LuneUiTheme.Muted;
|
||||||
|
p.BeginPath();
|
||||||
|
p.Arc(new Vector2(Value ? w - r : r, r), r - 3f, Angle.Degrees(0f), Angle.Degrees(360f));
|
||||||
|
p.ClosePath();
|
||||||
|
p.Fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneSettingsPanel.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e2a2d47991e354a9cb5d4ed085a1d2ee
|
||||||
209
Assets/Luneraria/UI/Hud/LuneTooltip.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The one tooltip of the HUD (port of components/lune_tooltip.gd). A single panel is reused
|
||||||
|
/// for every hover: it appears after a short delay next to the hovered element, stays inside
|
||||||
|
/// the window, never takes mouse input and hides as soon as the element is left, hidden,
|
||||||
|
/// detached or clicked. Content is a LuneTip (title, subtitle, stat rows, text, note, warn).
|
||||||
|
/// Elements register with LuneTooltip.Attach(element, builder); the HUD calls Tick each frame.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneTooltip : LunePanel
|
||||||
|
{
|
||||||
|
public const float Delay = .28f;
|
||||||
|
public const float Quick = .05f;
|
||||||
|
public const float Width = 300f;
|
||||||
|
|
||||||
|
public static LuneTooltip Current;
|
||||||
|
|
||||||
|
VisualElement _owner;
|
||||||
|
Func<LuneTip> _builder;
|
||||||
|
float _wait;
|
||||||
|
float _lastHide = -10f;
|
||||||
|
float _clock;
|
||||||
|
bool _placed;
|
||||||
|
Vector2 _placedSize;
|
||||||
|
readonly VisualElement _column;
|
||||||
|
/// <summary>Last pointer position in panel coordinates (real and synthetic events).</summary>
|
||||||
|
public Vector2 Pointer = new Vector2(-1f, -1f);
|
||||||
|
/// <summary>Text of the visible tooltip (tests).</summary>
|
||||||
|
public string Shown { get; private set; } = "";
|
||||||
|
|
||||||
|
public LuneTooltip() : base(LunePanels.Tooltip, false)
|
||||||
|
{
|
||||||
|
style.position = Position.Absolute;
|
||||||
|
style.maxWidth = Width;
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
_column = LuneUi.Box(true, 4f, this);
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
RegisterCallback<GeometryChangedEvent>(_ => Place());
|
||||||
|
Current = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Open { get { return style.display != DisplayStyle.None; } }
|
||||||
|
public VisualElement Owner { get { return _owner; } }
|
||||||
|
|
||||||
|
public static void Attach(VisualElement element, Func<LuneTip> builder)
|
||||||
|
{
|
||||||
|
if (element.pickingMode == PickingMode.Ignore)
|
||||||
|
element.pickingMode = PickingMode.Position;
|
||||||
|
element.RegisterCallback<PointerEnterEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (Current != null)
|
||||||
|
Current.Hover(element, builder, evt.position);
|
||||||
|
});
|
||||||
|
element.RegisterCallback<PointerLeaveEvent>(_ => Current?.Leave(element));
|
||||||
|
element.RegisterCallback<PointerDownEvent>(_ => Current?.Leave(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hover(VisualElement element, Func<LuneTip> builder, Vector2 at)
|
||||||
|
{
|
||||||
|
if (Pointer.x < 0f)
|
||||||
|
Pointer = at;
|
||||||
|
_owner = element;
|
||||||
|
_builder = builder;
|
||||||
|
_wait = _clock - _lastHide < .6f ? Quick : Delay;
|
||||||
|
Hide(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Leave(VisualElement element)
|
||||||
|
{
|
||||||
|
if (element == _owner)
|
||||||
|
{
|
||||||
|
Hide(true);
|
||||||
|
_owner = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hide(bool remember)
|
||||||
|
{
|
||||||
|
if (Open && remember)
|
||||||
|
_lastHide = _clock;
|
||||||
|
LuneUi.Show(this, false);
|
||||||
|
Shown = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes an open tooltip (values changed while hovering).</summary>
|
||||||
|
public void Refresh(VisualElement element)
|
||||||
|
{
|
||||||
|
if (element == _owner && Open)
|
||||||
|
ShowNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(float delta)
|
||||||
|
{
|
||||||
|
_clock += delta;
|
||||||
|
if (_owner == null)
|
||||||
|
return;
|
||||||
|
if (_owner.panel == null || !Showing(_owner) || !_owner.worldBound.Contains(Pointer))
|
||||||
|
{
|
||||||
|
Hide(true);
|
||||||
|
_owner = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Open)
|
||||||
|
{
|
||||||
|
_wait -= delta;
|
||||||
|
if (_wait <= 0f)
|
||||||
|
ShowNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Visible in the hierarchy (Godot is_visible_in_tree).</summary>
|
||||||
|
static bool Showing(VisualElement element)
|
||||||
|
{
|
||||||
|
for (VisualElement node = element; node != null; node = node.parent)
|
||||||
|
{
|
||||||
|
if (node.style.display == DisplayStyle.None || node.resolvedStyle.display == DisplayStyle.None)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShowNow()
|
||||||
|
{
|
||||||
|
LuneTip tip = _builder != null ? _builder() : null;
|
||||||
|
if (tip == null || tip.IsEmpty)
|
||||||
|
{
|
||||||
|
Hide(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_column.Clear();
|
||||||
|
if (tip.Title.Length > 0)
|
||||||
|
{
|
||||||
|
VisualElement head = LuneUi.Box(false, 6f, _column);
|
||||||
|
if (tip.Glyph.Length > 0)
|
||||||
|
LuneUi.Glyph(tip.Glyph, tip.GlyphColor ?? LuneUiTheme.BronzeLight, 18f, head);
|
||||||
|
LuneUi.Wrap(LuneUi.Label(tip.Title, "HudValue", head));
|
||||||
|
}
|
||||||
|
if (tip.Subtitle.Length > 0)
|
||||||
|
LuneUi.Wrap(LuneUi.Label(tip.Subtitle, "HudMuted", _column));
|
||||||
|
if (tip.Stats.Count > 0)
|
||||||
|
{
|
||||||
|
VisualElement grid = LuneUi.Box(true, 2f, _column);
|
||||||
|
foreach (string[] row in tip.Stats)
|
||||||
|
{
|
||||||
|
VisualElement line = LuneUi.Box(false, 8f, grid);
|
||||||
|
LuneUi.Glyph(row[0], LuneUiTheme.Muted, 14f, line);
|
||||||
|
Label name = LuneUi.Label(row[1], "HudMuted", line);
|
||||||
|
name.style.minWidth = 104f;
|
||||||
|
LuneUi.Wrap(LuneUi.Label(row[2], "HudText", line)).style.flexGrow = 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddLine(tip.Text, "HudText", null);
|
||||||
|
AddLine(tip.Note, "HudMuted", null);
|
||||||
|
AddLine(tip.Warn, "HudMuted", LuneUiTheme.Danger);
|
||||||
|
Shown = tip.ToString();
|
||||||
|
// Measure at the top left with the full width available, then place at that size.
|
||||||
|
_placed = false;
|
||||||
|
style.left = 0f;
|
||||||
|
style.top = 0f;
|
||||||
|
style.width = StyleKeyword.Auto;
|
||||||
|
LuneUi.Show(this, true);
|
||||||
|
BringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddLine(string text, string variation, Color? color)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return;
|
||||||
|
Label line = LuneUi.Wrap(LuneUi.Label(text, variation, _column));
|
||||||
|
line.style.width = Width - 24f;
|
||||||
|
if (color.HasValue)
|
||||||
|
LuneUi.SetColor(line, color.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Above the owner, centred; below when there is no room; always inside the HUD. Placed once
|
||||||
|
/// per content: measured at the top left with the full width, then the measured width is
|
||||||
|
/// fixed and the tooltip moves to whole pixels. Later geometry changes are ignored; a
|
||||||
|
/// re-placement could change the pixel snapping, the wrapping and the size again (a layout
|
||||||
|
/// loop found in the U3 tests).
|
||||||
|
/// </summary>
|
||||||
|
void Place()
|
||||||
|
{
|
||||||
|
if (_placed || !Open || _owner == null || parent == null)
|
||||||
|
return;
|
||||||
|
float width = layout.width;
|
||||||
|
float height = layout.height;
|
||||||
|
if (float.IsNaN(width) || width <= 0f || float.IsNaN(height))
|
||||||
|
return;
|
||||||
|
Rect area = parent.contentRect;
|
||||||
|
Rect anchor = parent.WorldToLocal(_owner.worldBound);
|
||||||
|
float x = anchor.center.x - width * .5f;
|
||||||
|
float y = anchor.y - height - 8f;
|
||||||
|
if (y < area.y + 6f)
|
||||||
|
y = anchor.yMax + 8f;
|
||||||
|
x = Mathf.Clamp(x, area.x + 6f, Mathf.Max(area.x + 6f, area.xMax - width - 6f));
|
||||||
|
y = Mathf.Clamp(y, area.y + 6f, Mathf.Max(area.y + 6f, area.yMax - height - 6f));
|
||||||
|
_placed = true;
|
||||||
|
_placedSize = new Vector2(width, height);
|
||||||
|
style.width = width;
|
||||||
|
style.left = Mathf.Round(x);
|
||||||
|
style.top = Mathf.Round(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneTooltip.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f0090d324fbbc44869a77c3f1acad83a
|
||||||
255
Assets/Luneraria/UI/Hud/LuneUi.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TextCore.Text;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using TextElement = UnityEngine.UIElements.TextElement;
|
||||||
|
|
||||||
|
namespace Luneraria.UI
|
||||||
|
{
|
||||||
|
/// <summary>Weights of the HUD fonts (Godot FontVariation weights of lune_ui_theme.gd).</summary>
|
||||||
|
public enum LuneFace
|
||||||
|
{
|
||||||
|
/// <summary>Cantarell 560: Regular with a thin outline in the text colour.</summary>
|
||||||
|
Text,
|
||||||
|
/// <summary>Cantarell 700–760.</summary>
|
||||||
|
Bold,
|
||||||
|
/// <summary>Cinzel 700.</summary>
|
||||||
|
Display,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A Godot label variation: face, size, colour, glyph spacing, optional outline.</summary>
|
||||||
|
public sealed class LuneTextStyle
|
||||||
|
{
|
||||||
|
public LuneFace Face;
|
||||||
|
public float Size;
|
||||||
|
public Color Color;
|
||||||
|
public float Spacing;
|
||||||
|
/// <summary>Godot outline_size (black, alpha .55) for titles, the wave and gold; 0 = none.</summary>
|
||||||
|
public float Outline;
|
||||||
|
|
||||||
|
public LuneTextStyle(LuneFace face, float size, Color color, float spacing = 0f, float outline = 0f)
|
||||||
|
{
|
||||||
|
Face = face;
|
||||||
|
Size = Mathf.Max(LuneUiTheme.MinFont, size);
|
||||||
|
Color = color;
|
||||||
|
Spacing = spacing;
|
||||||
|
Outline = outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory and text styles of the HUD (Godot LuneUiTheme.label/button and the label variations
|
||||||
|
/// of lune_ui_theme.gd). Components build their elements here, so sizes, faces and colours
|
||||||
|
/// stay consistent. Fonts come from Settings/LuneUi.asset (LuneUi.Assets, set by the HUD).
|
||||||
|
/// </summary>
|
||||||
|
public static class LuneUi
|
||||||
|
{
|
||||||
|
public static LuneUiAssets Assets;
|
||||||
|
|
||||||
|
static readonly Color Title = LuneUiTheme.Hex("f4e6c4");
|
||||||
|
public static readonly Dictionary<string, LuneTextStyle> Styles = new Dictionary<string, LuneTextStyle>
|
||||||
|
{
|
||||||
|
{ "HudText", new LuneTextStyle(LuneFace.Text, 15, LuneUiTheme.Text) },
|
||||||
|
{ "HudMuted", new LuneTextStyle(LuneFace.Text, 14, LuneUiTheme.Muted) },
|
||||||
|
{ "HudCaps", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.BronzeLight, 2f) },
|
||||||
|
{ "HudBadge", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.Text, 1f) },
|
||||||
|
{ "HudLane", new LuneTextStyle(LuneFace.Display, 16, Title, 1f) },
|
||||||
|
{ "HudValue", new LuneTextStyle(LuneFace.Bold, 17, LuneUiTheme.Bright) },
|
||||||
|
{ "HudGold", new LuneTextStyle(LuneFace.Bold, 24, LuneUiTheme.Gold, 0f, 4f) },
|
||||||
|
{ "HudName", new LuneTextStyle(LuneFace.Display, 18, Title, 1f) },
|
||||||
|
{ "HudTitle", new LuneTextStyle(LuneFace.Display, 24, Title, 1f, 4f) },
|
||||||
|
{ "HudWave", new LuneTextStyle(LuneFace.Display, 30, Title, 3f, 4f) },
|
||||||
|
// Menu theme (game-over panel, options).
|
||||||
|
{ "TitleLabel", new LuneTextStyle(LuneFace.Display, 78, Title, 2f, 6f) },
|
||||||
|
{ "HeadingLabel", new LuneTextStyle(LuneFace.Display, 34, Title, 1f) },
|
||||||
|
{ "SectionLabel", new LuneTextStyle(LuneFace.Display, 16, LuneUiTheme.BronzeLight, 3f) },
|
||||||
|
{ "BodyLabel", new LuneTextStyle(LuneFace.Text, 20, LuneUiTheme.Parchment) },
|
||||||
|
{ "MutedLabel", new LuneTextStyle(LuneFace.Text, 16, LuneUiTheme.Hex("a9a293")) },
|
||||||
|
{ "EyebrowLabel", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.BronzeLight, 2f) },
|
||||||
|
{ "CardFactLabel", new LuneTextStyle(LuneFace.Text, 17, LuneUiTheme.Hex("d8cfbc")) },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Outline that thickens Cantarell Regular to the G3 text weight 560.</summary>
|
||||||
|
public const float MediumOutline = .25f;
|
||||||
|
|
||||||
|
public static FontAsset Font(LuneFace face)
|
||||||
|
{
|
||||||
|
if (Assets == null)
|
||||||
|
return null;
|
||||||
|
return face == LuneFace.Display ? Assets.Display : face == LuneFace.Bold ? Assets.TextBold : Assets.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies a text style; size 0 keeps the style's size.</summary>
|
||||||
|
public static void Apply(TextElement element, string variation, float size = 0f)
|
||||||
|
{
|
||||||
|
if (!Styles.TryGetValue(variation, out LuneTextStyle style))
|
||||||
|
style = Styles["HudText"];
|
||||||
|
FontAsset font = Font(style.Face);
|
||||||
|
if (font != null)
|
||||||
|
element.style.unityFontDefinition = FontDefinition.FromSDFFont(font);
|
||||||
|
element.style.fontSize = size > 0f ? Mathf.Max(LuneUiTheme.MinFont, size) : style.Size;
|
||||||
|
element.style.letterSpacing = style.Spacing;
|
||||||
|
element.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f, color = new Color(0f, 0f, 0f, .55f) };
|
||||||
|
SetColor(element, style.Color, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Text colour; keeps the weight outline (560) or the dark title outline in step.</summary>
|
||||||
|
public static void SetColor(TextElement element, Color color, LuneTextStyle style = null)
|
||||||
|
{
|
||||||
|
element.style.color = color;
|
||||||
|
if (style == null)
|
||||||
|
style = element.userData as LuneTextStyle;
|
||||||
|
if (style == null)
|
||||||
|
return;
|
||||||
|
element.userData = style;
|
||||||
|
if (style.Outline > 0f)
|
||||||
|
{
|
||||||
|
element.style.unityTextOutlineWidth = style.Outline * .25f;
|
||||||
|
element.style.unityTextOutlineColor = new Color(0f, 0f, 0f, .55f);
|
||||||
|
}
|
||||||
|
else if (style.Face == LuneFace.Text)
|
||||||
|
{
|
||||||
|
element.style.unityTextOutlineWidth = MediumOutline;
|
||||||
|
element.style.unityTextOutlineColor = color;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
element.style.unityTextOutlineWidth = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Label Label(string text, string variation = "HudText", VisualElement parent = null, float size = 0f)
|
||||||
|
{
|
||||||
|
var label = new Label(text ?? "");
|
||||||
|
label.pickingMode = PickingMode.Ignore;
|
||||||
|
Zero(label);
|
||||||
|
label.style.whiteSpace = WhiteSpace.NoWrap;
|
||||||
|
Apply(label, variation, size);
|
||||||
|
parent?.Add(label);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Godot clip_text with OVERRUN_TRIM_ELLIPSIS: one line, cut with "…".</summary>
|
||||||
|
public static Label Clip(Label label)
|
||||||
|
{
|
||||||
|
label.style.overflow = Overflow.Hidden;
|
||||||
|
label.style.textOverflow = TextOverflow.Ellipsis;
|
||||||
|
label.style.whiteSpace = WhiteSpace.NoWrap;
|
||||||
|
label.style.flexShrink = 1f;
|
||||||
|
label.style.minWidth = 0f;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Godot AUTOWRAP_WORD_SMART.</summary>
|
||||||
|
public static Label Wrap(Label label)
|
||||||
|
{
|
||||||
|
label.style.whiteSpace = WhiteSpace.Normal;
|
||||||
|
label.style.flexShrink = 1f;
|
||||||
|
label.style.minWidth = 0f;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Zero(VisualElement element)
|
||||||
|
{
|
||||||
|
element.style.marginLeft = 0f;
|
||||||
|
element.style.marginRight = 0f;
|
||||||
|
element.style.marginTop = 0f;
|
||||||
|
element.style.marginBottom = 0f;
|
||||||
|
element.style.paddingLeft = 0f;
|
||||||
|
element.style.paddingRight = 0f;
|
||||||
|
element.style.paddingTop = 0f;
|
||||||
|
element.style.paddingBottom = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Godot HBox/VBoxContainer with a separation; ignores the mouse like Godot's boxes in the HUD.</summary>
|
||||||
|
public static VisualElement Box(bool vertical, float separation, VisualElement parent = null)
|
||||||
|
{
|
||||||
|
var box = new LuneBox(vertical, separation);
|
||||||
|
parent?.Add(box);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VisualElement Spacer(VisualElement parent)
|
||||||
|
{
|
||||||
|
var spacer = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
|
spacer.style.flexGrow = 1f;
|
||||||
|
parent.Add(spacer);
|
||||||
|
return spacer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LuneGlyphElement Glyph(string glyph, Color color, float edge = 18f, VisualElement parent = null)
|
||||||
|
{
|
||||||
|
var element = new LuneGlyphElement(glyph, color, edge);
|
||||||
|
parent?.Add(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Godot `visible`: hidden elements take no space; the parent box closes the gap.</summary>
|
||||||
|
public static void Show(VisualElement element, bool visible)
|
||||||
|
{
|
||||||
|
DisplayStyle display = visible ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
if (element.style.display == display)
|
||||||
|
return;
|
||||||
|
element.style.display = display;
|
||||||
|
(element.parent as LuneBox)?.Space();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Visible(VisualElement element)
|
||||||
|
{
|
||||||
|
return element.resolvedStyle.display != DisplayStyle.None && element.style.display != DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Upper(string text)
|
||||||
|
{
|
||||||
|
return (text ?? "").ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Box container with Godot's separation: the gap goes between children, not around them.
|
||||||
|
/// Children added later get the gap too; hidden children (display none) leave no gap.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LuneBox : VisualElement
|
||||||
|
{
|
||||||
|
public readonly bool Vertical;
|
||||||
|
public float Separation;
|
||||||
|
|
||||||
|
public LuneBox(bool vertical, float separation)
|
||||||
|
{
|
||||||
|
Vertical = vertical;
|
||||||
|
Separation = separation;
|
||||||
|
pickingMode = PickingMode.Ignore;
|
||||||
|
style.flexDirection = vertical ? FlexDirection.Column : FlexDirection.Row;
|
||||||
|
if (!vertical)
|
||||||
|
style.alignItems = Align.Center;
|
||||||
|
RegisterCallback<GeometryChangedEvent>(_ => Space());
|
||||||
|
RegisterCallback<AttachToPanelEvent>(_ => Space());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies the gap before every visible child except the first.</summary>
|
||||||
|
public void Space()
|
||||||
|
{
|
||||||
|
bool first = true;
|
||||||
|
foreach (VisualElement child in Children())
|
||||||
|
{
|
||||||
|
float gap = first ? 0f : Separation;
|
||||||
|
if (child.style.display == DisplayStyle.None)
|
||||||
|
continue;
|
||||||
|
// Only real changes: setting a margin dirties the layout even with the same value.
|
||||||
|
if (Vertical)
|
||||||
|
{
|
||||||
|
StyleLength current = child.style.marginTop;
|
||||||
|
if (current.keyword != StyleKeyword.Undefined || Mathf.Abs(current.value.value - gap) > .01f)
|
||||||
|
child.style.marginTop = gap;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StyleLength current = child.style.marginLeft;
|
||||||
|
if (current.keyword != StyleKeyword.Undefined || Mathf.Abs(current.value.value - gap) > .01f)
|
||||||
|
child.style.marginLeft = gap;
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Hud/LuneUi.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a0b7d42f0faa4353875e7799526547d
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
"rootNamespace": "Luneraria.UI",
|
"rootNamespace": "Luneraria.UI",
|
||||||
"references": [
|
"references": [
|
||||||
"Luneraria.Core",
|
"Luneraria.Core",
|
||||||
"Luneraria.View"
|
"Luneraria.View",
|
||||||
|
"Unity.RenderPipelines.Core.Runtime",
|
||||||
|
"Unity.RenderPipelines.Universal.Runtime"
|
||||||
],
|
],
|
||||||
"includePlatforms": [],
|
"includePlatforms": [],
|
||||||
"excludePlatforms": [],
|
"excludePlatforms": [],
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ namespace Luneraria.UI
|
||||||
/// <summary>Grows the drawing outside the element (focus rings).</summary>
|
/// <summary>Grows the drawing outside the element (focus rings).</summary>
|
||||||
public float Grow;
|
public float Grow;
|
||||||
public bool DrawFill = true;
|
public bool DrawFill = true;
|
||||||
|
/// <summary>Small rivet diamonds in the middle of the long edges (menu plates).</summary>
|
||||||
|
public bool Ornaments;
|
||||||
|
public Color OrnamentColor = LuneUiTheme.Hex("c99a58");
|
||||||
/// <summary>Content margins (Godot content_margin_*), applied as padding.</summary>
|
/// <summary>Content margins (Godot content_margin_*), applied as padding.</summary>
|
||||||
public float PadX = 12f;
|
public float PadX = 12f;
|
||||||
public float PadY = 12f;
|
public float PadY = 12f;
|
||||||
|
|
@ -132,6 +135,16 @@ namespace Luneraria.UI
|
||||||
Outline(painter, PlatePoints(Grow(r, -style.InnerInset), Mathf.Max(1f, style.Chamfer - style.InnerInset * .45f), Points), style.Inner, 1f);
|
Outline(painter, PlatePoints(Grow(r, -style.InnerInset), Mathf.Max(1f, style.Chamfer - style.InnerInset * .45f), Points), style.Inner, 1f);
|
||||||
if (style.Edge.a > 0f && style.EdgeWidth > 0f)
|
if (style.Edge.a > 0f && style.EdgeWidth > 0f)
|
||||||
Outline(painter, PlatePoints(Grow(r, -style.EdgeWidth * .5f), style.Chamfer, Points), style.Edge, style.EdgeWidth);
|
Outline(painter, PlatePoints(Grow(r, -style.EdgeWidth * .5f), style.Chamfer, Points), style.Edge, style.EdgeWidth);
|
||||||
|
if (style.Ornaments)
|
||||||
|
{
|
||||||
|
// Small rivet diamonds on the long edges mark forged panels without adding frames.
|
||||||
|
float x = r.x + r.width * .5f;
|
||||||
|
foreach (float y in new[] { r.y, r.yMax })
|
||||||
|
{
|
||||||
|
const float size = 3.5f;
|
||||||
|
Polygon(painter, new[] { new Vector2(x, y - size), new Vector2(x + size, y), new Vector2(x, y + size), new Vector2(x - size, y) }, style.OrnamentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Convex polygon with a vertical colour gradient (Godot: polygon with per-vertex colours).</summary>
|
/// <summary>Convex polygon with a vertical colour gradient (Godot: polygon with per-vertex colours).</summary>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ namespace Luneraria.View
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_out = args[at + 1];
|
_out = args[at + 1];
|
||||||
|
Running = true;
|
||||||
// The benchmark runs unattended; it must not pause when its window loses focus.
|
// The benchmark runs unattended; it must not pause when its window loses focus.
|
||||||
Application.runInBackground = true;
|
Application.runInBackground = true;
|
||||||
QualitySettings.vSyncCount = 0;
|
QualitySettings.vSyncCount = 0;
|
||||||
|
|
@ -66,8 +67,12 @@ namespace Luneraria.View
|
||||||
_windows.Add(new Window { Label = "mid lane close (zoom 22)", Seconds = 10f, Zoom = 22f, Focus = new Vec2(0, 0) });
|
_windows.Add(new Window { Label = "mid lane close (zoom 22)", Seconds = 10f, Zoom = 22f, Focus = new Vec2(0, 0) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True in a benchmark run: the game controller keeps the bot match (no human seat).</summary>
|
||||||
|
public static bool Running { get; private set; }
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
|
Driver.HumanSlot = -1;
|
||||||
Driver.Seed = 9;
|
Driver.Seed = 9;
|
||||||
Driver.StartRound = 21;
|
Driver.StartRound = 21;
|
||||||
Driver.ExtraGold = 4000;
|
Driver.ExtraGold = 4000;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ namespace Luneraria.View
|
||||||
{
|
{
|
||||||
public LuneGround Ground;
|
public LuneGround Ground;
|
||||||
public bool Interactive = true;
|
public bool Interactive = true;
|
||||||
|
/// <summary>True while the pointer is over the HUD: no wheel zoom, no middle-mouse drag (U3).</summary>
|
||||||
|
public System.Func<bool> PointerBlocked;
|
||||||
|
/// <summary>True while a window takes the keyboard (options): no panning or turning keys.</summary>
|
||||||
|
public System.Func<bool> KeysBlocked;
|
||||||
|
/// <summary>R: the game controller's reset (own lane, Godot reset_camera); without it R resets yaw and zoom.</summary>
|
||||||
|
public System.Action ResetRequested;
|
||||||
public float Zoom = LuneCameraMath.DefaultZoom;
|
public float Zoom = LuneCameraMath.DefaultZoom;
|
||||||
public float Yaw;
|
public float Yaw;
|
||||||
Vec2 _focus;
|
Vec2 _focus;
|
||||||
|
|
@ -19,6 +25,40 @@ namespace Luneraria.View
|
||||||
float _goalZoom = LuneCameraMath.DefaultZoom;
|
float _goalZoom = LuneCameraMath.DefaultZoom;
|
||||||
float _goalYaw;
|
float _goalYaw;
|
||||||
Vector3 _dragAnchor;
|
Vector3 _dragAnchor;
|
||||||
|
bool _dragging;
|
||||||
|
|
||||||
|
public Vec2 Focus { get { return _focus; } }
|
||||||
|
|
||||||
|
public void RotateStep(int direction)
|
||||||
|
{
|
||||||
|
_goalYaw += direction * Mathf.PI * .25f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Yaw back to 0 and the default zoom at a point (Godot reset_view).</summary>
|
||||||
|
public void ResetView(Vec2 point, bool instant = false)
|
||||||
|
{
|
||||||
|
_goalYaw = 0f;
|
||||||
|
LookAt(point, LuneCameraMath.DefaultZoom, instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ground outline of the view (the four screen corners on the ground plane at the focus
|
||||||
|
/// height), in map coordinates; rays above the horizon end at 120 m.
|
||||||
|
/// </summary>
|
||||||
|
public Vec2[] Footprint(Camera camera)
|
||||||
|
{
|
||||||
|
float height = Ground != null ? Ground.StandHeight(_focus) : 0f;
|
||||||
|
var plane = new Plane(Vector3.up, new Vector3(0f, height, 0f));
|
||||||
|
var result = new Vec2[4];
|
||||||
|
Vector2[] corners = { new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) };
|
||||||
|
for (int index = 0; index < 4; index++)
|
||||||
|
{
|
||||||
|
Ray ray = camera.ViewportPointToRay(new Vector3(corners[index].x, corners[index].y, 0f));
|
||||||
|
Vector3 point = plane.Raycast(ray, out float distance) && distance < 120f ? ray.GetPoint(distance) : ray.GetPoint(120f);
|
||||||
|
result[index] = LuneSpace.Map(point);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public void LookAt(Vec2 point, float zoom = -1f, bool instant = false)
|
public void LookAt(Vec2 point, float zoom = -1f, bool instant = false)
|
||||||
{
|
{
|
||||||
|
|
@ -89,7 +129,9 @@ namespace Luneraria.View
|
||||||
|
|
||||||
void HandleInput(float dt)
|
void HandleInput(float dt)
|
||||||
{
|
{
|
||||||
var pan = new Vector2(Input.GetAxisRaw("Horizontal"), -Input.GetAxisRaw("Vertical"));
|
bool keys = KeysBlocked == null || !KeysBlocked();
|
||||||
|
bool pointer = PointerBlocked == null || !PointerBlocked();
|
||||||
|
var pan = !keys ? Vector2.zero : new Vector2(Input.GetAxisRaw("Horizontal"), -Input.GetAxisRaw("Vertical"));
|
||||||
if (pan != Vector2.zero)
|
if (pan != Vector2.zero)
|
||||||
{
|
{
|
||||||
// Godot: right = (cos yaw, 0, -sin yaw), forward = (-sin yaw, 0, -cos yaw), map frame.
|
// Godot: right = (cos yaw, 0, -sin yaw), forward = (-sin yaw, 0, -cos yaw), map frame.
|
||||||
|
|
@ -98,25 +140,35 @@ namespace Luneraria.View
|
||||||
Vec2 move = right * (pan.x * Zoom * .9f * dt) - forward * (pan.y * Zoom * .9f * dt);
|
Vec2 move = right * (pan.x * Zoom * .9f * dt) - forward * (pan.y * Zoom * .9f * dt);
|
||||||
LookAt(_goalFocus + move);
|
LookAt(_goalFocus + move);
|
||||||
}
|
}
|
||||||
float wheel = Input.mouseScrollDelta.y;
|
float wheel = pointer ? Input.mouseScrollDelta.y : 0f;
|
||||||
if (wheel > 0f)
|
if (wheel > 0f)
|
||||||
_goalZoom = Mathf.Max(LuneCameraMath.MinZoom, _goalZoom - Mathf.Max(2.5f, _goalZoom * .09f));
|
_goalZoom = Mathf.Max(LuneCameraMath.MinZoom, _goalZoom - Mathf.Max(2.5f, _goalZoom * .09f));
|
||||||
else if (wheel < 0f)
|
else if (wheel < 0f)
|
||||||
_goalZoom = Mathf.Min(LuneCameraMath.MaxZoom, _goalZoom + Mathf.Max(2.5f, _goalZoom * .09f));
|
_goalZoom = Mathf.Min(LuneCameraMath.MaxZoom, _goalZoom + Mathf.Max(2.5f, _goalZoom * .09f));
|
||||||
if (Input.GetKeyDown(KeyCode.Q))
|
if (keys && Input.GetKeyDown(KeyCode.Q))
|
||||||
_goalYaw -= Mathf.PI * .25f;
|
_goalYaw -= Mathf.PI * .25f;
|
||||||
if (Input.GetKeyDown(KeyCode.E))
|
if (keys && Input.GetKeyDown(KeyCode.E))
|
||||||
_goalYaw += Mathf.PI * .25f;
|
_goalYaw += Mathf.PI * .25f;
|
||||||
if (Input.GetKeyDown(KeyCode.R))
|
if (keys && Input.GetKeyDown(KeyCode.R))
|
||||||
|
{
|
||||||
|
if (ResetRequested != null)
|
||||||
|
ResetRequested();
|
||||||
|
else
|
||||||
{
|
{
|
||||||
_goalYaw = 0f;
|
_goalYaw = 0f;
|
||||||
LookAt(_goalFocus, LuneCameraMath.DefaultZoom);
|
LookAt(_goalFocus, LuneCameraMath.DefaultZoom);
|
||||||
}
|
}
|
||||||
if (Input.GetKeyDown(KeyCode.O))
|
}
|
||||||
|
if (keys && Input.GetKeyDown(KeyCode.O))
|
||||||
Overview();
|
Overview();
|
||||||
if (Input.GetMouseButtonDown(2))
|
if (Input.GetMouseButtonDown(2) && pointer)
|
||||||
|
{
|
||||||
_dragAnchor = Input.mousePosition;
|
_dragAnchor = Input.mousePosition;
|
||||||
if (Input.GetMouseButton(2))
|
_dragging = true;
|
||||||
|
}
|
||||||
|
if (!Input.GetMouseButton(2))
|
||||||
|
_dragging = false;
|
||||||
|
if (_dragging)
|
||||||
{
|
{
|
||||||
Vector3 delta = Input.mousePosition - _dragAnchor;
|
Vector3 delta = Input.mousePosition - _dragAnchor;
|
||||||
_dragAnchor = Input.mousePosition;
|
_dragAnchor = Input.mousePosition;
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using Luneraria.Core;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace Luneraria.View
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Temporary match readout for U2 (round, phase, buildings, heroes, boss, winner). Not a HUD:
|
|
||||||
/// the G3 HUD is ported in U3 and replaces this overlay.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LuneDebugOverlay : MonoBehaviour
|
|
||||||
{
|
|
||||||
public LuneMatchDriver Driver;
|
|
||||||
GUIStyle _style;
|
|
||||||
GUIContent _content = new GUIContent();
|
|
||||||
float _next;
|
|
||||||
|
|
||||||
void OnGUI()
|
|
||||||
{
|
|
||||||
if (Driver == null || Driver.Runner == null)
|
|
||||||
return;
|
|
||||||
if (_style == null)
|
|
||||||
_style = new GUIStyle(GUI.skin.box) { alignment = TextAnchor.UpperLeft, fontSize = 15, wordWrap = true, padding = new RectOffset(10, 10, 8, 8) };
|
|
||||||
// Four refreshes a second: building the text every GUI pass produced garbage each frame.
|
|
||||||
if (Time.unscaledTime >= _next)
|
|
||||||
{
|
|
||||||
_content.text = Text(Driver.Runner);
|
|
||||||
_next = Time.unscaledTime + .25f;
|
|
||||||
}
|
|
||||||
GUI.Box(new Rect(12, 12, 430, 150), _content, _style);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Text(LuneMatchRunner runner)
|
|
||||||
{
|
|
||||||
LuneMatch match = runner.Match;
|
|
||||||
var text = new StringBuilder();
|
|
||||||
text.AppendFormat("U2 Debug (kein HUD) · Runde {0} · {1}\n", match.RoundNumber, runner.Current);
|
|
||||||
for (int team = 0; team < 2; team++)
|
|
||||||
{
|
|
||||||
int towers = match.Map.Buildings.Count(b => b.Team == team && b.Tier != "core" && match.BuildingHp[b.Id] > 0.0);
|
|
||||||
double core = match.BuildingHp[team == 0 ? "blau_ancient" : "rot_ancient"];
|
|
||||||
text.AppendFormat("{0}: Türme {1}/8 · Ancient {2:0}\n", team == 0 ? "Blau" : "Rot", towers, core);
|
|
||||||
}
|
|
||||||
if (match.Combat != null)
|
|
||||||
{
|
|
||||||
int heroes = match.Combat.Units.Count(u => u.Kind == "hero" && u.Alive);
|
|
||||||
int boss = match.Combat.Units.Count(u => u.Kind == "boss" && u.Alive);
|
|
||||||
text.AppendFormat("Kampf {0:0.0} s · Einheiten {1} · Helden {2} · Boss {3}\n", match.Combat.Elapsed, match.Combat.Units.Count(u => u.IsMobile() && u.Alive), heroes, boss);
|
|
||||||
}
|
|
||||||
if (match.Phase == Phase.Over)
|
|
||||||
text.AppendFormat("Sieger: {0} · {1}", match.Winner == 0 ? "Blau" : match.Winner == 1 ? "Rot" : "Unentschieden", match.EndNote);
|
|
||||||
return text.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
fileFormatVersion: 2
|
|
||||||
guid: dd301446233234cbe8a983e51f0aa25e
|
|
||||||
|
|
@ -15,6 +15,11 @@ namespace Luneraria.View
|
||||||
public LuneMatchDriver Driver;
|
public LuneMatchDriver Driver;
|
||||||
public LuneUnitLibrary Library;
|
public LuneUnitLibrary Library;
|
||||||
public Camera ViewCamera;
|
public Camera ViewCamera;
|
||||||
|
/// <summary>
|
||||||
|
/// Planning only: the human's draft army in its formation (set by the game controller, U3;
|
||||||
|
/// Godot _rebuild_preview). Bot matches leave it empty and show no units while planning.
|
||||||
|
/// </summary>
|
||||||
|
public LuneCombat Preview;
|
||||||
|
|
||||||
readonly Dictionary<int, LuneUnitView> _units = new Dictionary<int, LuneUnitView>();
|
readonly Dictionary<int, LuneUnitView> _units = new Dictionary<int, LuneUnitView>();
|
||||||
readonly Dictionary<string, LuneBuildingView> _buildings = new Dictionary<string, LuneBuildingView>();
|
readonly Dictionary<string, LuneBuildingView> _buildings = new Dictionary<string, LuneBuildingView>();
|
||||||
|
|
@ -59,15 +64,19 @@ namespace Luneraria.View
|
||||||
_clock.Restart();
|
_clock.Restart();
|
||||||
LuneMatch match = Driver.Runner.Match;
|
LuneMatch match = Driver.Runner.Match;
|
||||||
float dt = Time.deltaTime;
|
float dt = Time.deltaTime;
|
||||||
if (match.Combat != _combat)
|
// Planning shows the preview when there is one (human seat); a bot match keeps the last
|
||||||
|
// combat's figures standing, unsynced, as in Godot.
|
||||||
|
bool planning = Driver.Runner.Current == LuneMatchRunner.Stage.Planning;
|
||||||
|
LuneCombat shown = planning && Preview != null ? Preview : match.Combat;
|
||||||
|
if (shown != _combat)
|
||||||
{
|
{
|
||||||
foreach (LuneUnitView view in _units.Values)
|
foreach (LuneUnitView view in _units.Values)
|
||||||
Destroy(view.gameObject);
|
Destroy(view.gameObject);
|
||||||
_units.Clear();
|
_units.Clear();
|
||||||
_towerSerials.Clear();
|
_towerSerials.Clear();
|
||||||
_combat = match.Combat;
|
_combat = shown;
|
||||||
}
|
}
|
||||||
bool showUnits = _combat != null && Driver.Runner.Current != LuneMatchRunner.Stage.Planning;
|
bool showUnits = _combat != null && (!planning || _combat == Preview);
|
||||||
float alpha = Driver.Runner.Current == LuneMatchRunner.Stage.Combat ? (float)Driver.Runner.Clock.Alpha : 1f;
|
float alpha = Driver.Runner.Current == LuneMatchRunner.Stage.Combat ? (float)Driver.Runner.Clock.Alpha : 1f;
|
||||||
if (showUnits)
|
if (showUnits)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ Stand: 1. Oktober 2026.
|
||||||
| Teil | Inhalt | Zustand |
|
| Teil | Inhalt | Zustand |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| U3A | Entscheidung UI Toolkit, Sichtmodell mit Privatsphäre, menschlicher Sitz, Theme-Grundlagen (Farben, Platten, Symbole, Schriften) | **fertig** |
|
| U3A | Entscheidung UI Toolkit, Sichtmodell mit Privatsphäre, menschlicher Sitz, Theme-Grundlagen (Farben, Platten, Symbole, Schriften) | **fertig** |
|
||||||
| U3B | Komponenten, Porträts, Tooltip, alle HUD-Bereiche, Optionen, Partieende | in Arbeit |
|
| U3B | Komponenten, Porträts, Tooltip, alle HUD-Bereiche, Optionen, Partieende | **fertig** |
|
||||||
| U3C | Eingabe und Befehle: Tasten, Kamera-Ziele, Auswahl im Feld, Bereit-Sperre, Planungsvorschau | offen |
|
| U3C | Eingabe und Befehle: Tasten, Kamera-Ziele, Auswahl im Feld, Bereit-Sperre, Planungsvorschau | **fertig** |
|
||||||
| U3D | Prüfung (Teile A–H von `verify_g3`), Spieltest über die echte Oberfläche, Bilder, Leistung | offen |
|
| U3D | Prüfung (Teile A–H von `verify_g3`), Spieltest über die echte Oberfläche, Bilder, Leistung | in Arbeit |
|
||||||
|
|
||||||
Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/luneraria/G3-HUD.md`, Commit `1b540f2`). Es wird übertragen, nicht neu erfunden.
|
Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/luneraria/G3-HUD.md`, Commit `1b540f2`). Es wird übertragen, nicht neu erfunden.
|
||||||
|
|
||||||
|
|
@ -96,3 +96,56 @@ Eine Abweichung: Im öffentlichen Digest (Testhilfe) steht beim Boss die öffent
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| EditMode gesamt | 23/23, davon 12 neue: öffentliches Bild für alle sechs Sitze gleich (Planung und Kampf), fremde Entwürfe (Held, Champion-Buchung) bis zum Kampf unsichtbar, private Boss-Lane-Wahl, Lane-Bedrohung bei Turm-HP und Ankündigungen, Einheitenzahlen je Lane, Bosszustände, nächste Welle gleich der Regelwelle (Runden 1–24), Bereit-Sperre, Zahlenformate, menschlicher Sitz, Bot-Partie unverändert |
|
| EditMode gesamt | 23/23, davon 12 neue: öffentliches Bild für alle sechs Sitze gleich (Planung und Kampf), fremde Entwürfe (Held, Champion-Buchung) bis zum Kampf unsichtbar, private Boss-Lane-Wahl, Lane-Bedrohung bei Turm-HP und Ankündigungen, Einheitenzahlen je Lane, Bosszustände, nächste Welle gleich der Regelwelle (Runden 1–24), Bereit-Sperre, Zahlenformate, menschlicher Sitz, Bot-Partie unverändert |
|
||||||
| PlayMode-Probe | UI Toolkit rendert im Batchmodus (siehe oben) |
|
| PlayMode-Probe | UI Toolkit rendert im Batchmodus (siehe oben) |
|
||||||
|
|
||||||
|
## U3B · Komponenten und Bereiche
|
||||||
|
|
||||||
|
Alles liegt unter `Assets/Luneraria/UI/Hud/` und ist aus Godot übertragen:
|
||||||
|
|
||||||
|
| Unity | Godot | Inhalt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `LuneHud.cs` | `lune_hud.gd` | die fünf Bereiche, Synchronisierung (10 Hz Planung, 5 Hz Kampf, Minimap 10 Hz, Ereignisse 4 Hz), UI-Größe (mindestens 1560 × 880 logische Pixel), Ereignis-Erkennung aus öffentlichem Zustand, Tooltips |
|
||||||
|
| `LuneUi.cs`, `LuneElements.cs` | `lune_ui_theme.gd`, `status_badge.gd`, `hp_bar.gd`, `tower_indicator.gd`, `tab_bar.gd`, `lune_glyph.gd` | Textvarianten, Platten- und Knopfvarianten, Grundelemente |
|
||||||
|
| `LuneComponents.cs` | `resource_display.gd`, `team_panel.gd`, `unit_card.gd`, `army_chip.gd`, `item_slot.gd`, `event_feed.gd`, `modal_panel.gd` | Gold, Team, Kaufkarten, Armee, Items, Ereignisse, Fenster |
|
||||||
|
| `LuneLanePanels.cs` | `lane_indicator.gd`, `boss_indicator.gd`, `next_wave_panel.gd` | Lanes, Bossgrotte, nächste Welle |
|
||||||
|
| `LuneArmyPanels.cs` | `champion_panel.gd`, `unit_detail.gd` | Angriffschampions, ausgewählte Einheit |
|
||||||
|
| `LuneHeroPanels.cs` | `hero_card.gd`, `hero_detail.gd` | Heldenkarte, Heldenfenster (Anwerben, Werte, Items, Talente) |
|
||||||
|
| `LuneMinimap.cs` | `lune_minimap.gd`, `lune_minimap_overlay.gd` | Minimap mit eigener Ebene für den Kamerarahmen |
|
||||||
|
| `LunePortraits.cs` | `portrait_cache.gd`, `portrait_rect.gd` | Porträts aus den echten Modellen, einmal gerendert |
|
||||||
|
| `LuneTooltip.cs` | `lune_tooltip.gd` | der eine Tooltip |
|
||||||
|
| `LuneSettingsPanel.cs`, `LunePreferences.cs` | `lune_settings.gd`, `preferences.gd` | Optionen (F1) und gespeicherte Einstellungen |
|
||||||
|
|
||||||
|
Die offenen Punkte aus G3 sind umgesetzt:
|
||||||
|
- **Champion-Etikett:** „1 WELLE“ steht als Band am unteren Porträtrand und verdeckt die Köpfe nicht mehr.
|
||||||
|
- **Typ-Symbole:** Schadens- und Rüstungstyp stehen als farbige Symbole auf den Einheitenkarten. Die Farben gleichen der Wellenvorschau: Ein Schadenstyp hat die Farbe der Rüstung, gegen die er 130 % trifft (Legion-TD-Prinzip).
|
||||||
|
- **Armeewert:** Die Summe des Einsatzes steht in der Armeezeile („Wert 185“).
|
||||||
|
- **Helden auf der Minimap:** kleine Porträts im Goldring statt Punkten, der eigene Held mit weißem Ring (Dota-Prinzip).
|
||||||
|
|
||||||
|
Die Heldenkarte hat einen reservierten Befehlsplatz für die Ultimates der geplanten Helden (`PlannedHeroes`, Auslöser `Command`). Er bleibt verborgen, solange keiner dieser Helden spielbar ist.
|
||||||
|
|
||||||
|
## U3C · Eingabe und Befehle
|
||||||
|
|
||||||
|
`Assets/Luneraria/UI/Game/LuneGame.cs` ist das Gegenstück zur HUD-Seite von `lune_game.gd`.
|
||||||
|
|
||||||
|
- **Befehle:** Die Befehle des Menschen laufen über dieselbe Schnittstelle wie die der Bots (`LuneMatch.Submit`). Jeder Befehl meldet sein Ergebnis im HUD zurück.
|
||||||
|
- **Bereit:** Bereit geht an die Host-Schleife; der Kampf beginnt, sobald alle bereit sind. Nach einem Kampf ist Bereit eine Sekunde lang gesperrt.
|
||||||
|
- **Tasten wie in G3:**
|
||||||
|
- 1–5 kaufen, Leertaste meldet Bereit (im Kampf: Pause), P pausiert.
|
||||||
|
- Tab wechselt die Lane, F2–F4 springen zu Top, Mid und Bottom.
|
||||||
|
- B, G und H zeigen den eigenen Ancient, die Grotte und den eigenen Helden.
|
||||||
|
- R setzt die Kamera auf die eigene Lane zurück.
|
||||||
|
- F1 öffnet die Optionen, M schaltet den Ton, F11 das Vollbild, Esc schließt Fenster und Auswahl.
|
||||||
|
- **Kamera und Maus:**
|
||||||
|
- Klicks auf Lanes, Ereignisse, Banner, Bossgrotte und Minimap bewegen die Kamera.
|
||||||
|
- Über HUD-Flächen zoomt das Mausrad die Welt nicht.
|
||||||
|
- Ein Linksklick im Feld wählt eine eigene Figur (innerhalb von 34 px).
|
||||||
|
- **Planung:** Die eigene Entwurfsarmee steht in ihrer Formation. Die eigenen Aufstellungsflächen leuchten; Lanes mit eigenen Einheiten sind hervorgehoben.
|
||||||
|
- **Optionen:** Sie pausieren einen laufenden Kampf offline.
|
||||||
|
- **Menü:** Ein Hauptmenü gibt es im Unity-Port noch nicht. „Menü“ fragt deshalb nach und beendet das Spiel.
|
||||||
|
|
||||||
|
Bot-Partien (Tests, Benchmark) laufen unverändert; der Spielcontroller folgt dem Sitz der laufenden Partie. In der Szene ersetzt der Spielcontroller die U2-Debug-Anzeige.
|
||||||
|
|
||||||
|
Die Szenen- und Bibliotheksbauer arbeiten jetzt idempotent:
|
||||||
|
- Animator-Controller und Post-Processing-Profil werden an Ort und Stelle aktualisiert.
|
||||||
|
- Vorher erzeugte jeder Szenenbau neue interne IDs in 15 Dateien, obwohl der Inhalt gleich blieb.
|
||||||
|
- Die Szene selbst entsteht weiter bei jedem Bau neu; Unity vergibt dabei neue Objekt-IDs.
|
||||||
|
- Zwei Bauten hintereinander ergeben inhaltlich dieselbe Szene. Ersetzt man die IDs durch Platzhalter, sind alle 2840 Objekteinträge gleich; nur ihre Reihenfolge in der Datei ändert sich, weil Unity nach ID sortiert.
|
||||||
|
|
|
||||||
BIN
docs/port/screenshots/u3/aufloesungen/kampf-1152x720.png
Normal file
|
After Width: | Height: | Size: 997 KiB |
BIN
docs/port/screenshots/u3/aufloesungen/kampf-1280x720.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/port/screenshots/u3/aufloesungen/kampf-2560x1080.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
docs/port/screenshots/u3/aufloesungen/kampf-2560x1440.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
docs/port/screenshots/u3/pruefung/b-status.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/port/screenshots/u3/pruefung/c-einheit.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/port/screenshots/u3/pruefung/c-held.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/port/screenshots/u3/pruefung/c-items.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/port/screenshots/u3/pruefung/d-boss-eigen.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
docs/port/screenshots/u3/pruefung/d-champions.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/port/screenshots/u3/pruefung/f-tooltip.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |