U3A HUD-Grundlagen: UI Toolkit, Sichtmodell, menschlicher Sitz

Beginn des Ports des G3-HUD (Referenz luneraria/g3, 1b540f2).

- Entscheidung UI Toolkit statt uGUI nach kurzer Bewertung und einer Probe:
  Panel rendert im Batchmodus in eine 1920x1080-Textur (Skalierung wie Godot
  canvas_items/expand, 1600x960), gefaste Verlaufsplatten, alle 50 Symbole,
  Schriften aus den variablen Fonts der Referenz.
- Sichtmodell portiert (LuneHudModel, LuneLaneThreat, LuneStats) mit den
  Privatsphäre-Regeln der Referenz: Gold, Entwurf, Rückgängig und neue
  Buchungen nur für den eigenen Sitz; Lanes, Gebäude, Boss und Wellen aus
  öffentlichem Zustand.
- Menschlicher Sitz in LuneMatchRunner/LuneMatchDriver: Bots planen die
  übrigen fünf Sitze, die Planung wartet auf Bereit; Tempo 1x/2x/4x und Pause
  nur im Kampf. Bot-Partien unverändert.
- Theme-Grundlagen: Farben und Zahlenformate, Platten (forged_style.gd),
  Vektorsymbole (lune_glyphs.gd) mit Painter2D, statische SDF-Atlanten aus
  den benannten Instanzen von Cantarell und Cinzel (OFL).
- Tests: EditMode 23/23 (12 neue: gleiches öffentliches Bild für alle Sitze,
  fremde Entwürfe privat, private Boss-Lane-Wahl, Lane-Bedrohung, Bosszustände,
  nächste Welle, Bereit-Sperre, Formate, menschlicher Sitz); PlayMode 3/3,
  Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Dokumentation: U3-UI (Bewertung, U3A), U5-VISUAL (Vorgaben des
  Projektinhabers: stilisierter Realismus für die ganze Welt, erst in U5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-10-01 23:57:45 +02:00
parent 4744a47114
commit 4dee9b18d2
59 changed files with 20890 additions and 24 deletions

View file

@ -0,0 +1,18 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f80a232d1902841a6b0949361a252976, type: 3}
m_Name: LuneUi
m_EditorClassIdentifier: Luneraria.UI::Luneraria.UI.LuneUiAssets
Text: {fileID: 11400000, guid: 9ed8c741ab987452c8ecd52e5252d418, type: 2}
TextBold: {fileID: 11400000, guid: fe04d22bb582e4cd99959615019c6244, type: 2}
Display: {fileID: 11400000, guid: 84c5b4f661d34482e8f3a990d043245e, type: 2}
Theme: {fileID: -4733365628477956816, guid: 38a51cb4adf3e41879e4510c6a0d7ef6, type: 3}

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1422b9d009fb1472f89313f983a20781
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 00dc6e7d377f8485592a9211a2ab975c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ed2da158559184dc5955652374cf0334
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,309 @@
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using NUnit.Framework;
namespace Luneraria.UI.Tests
{
/// <summary>
/// U3: the HUD's view model (LuneHudModel, LuneLaneThreat, LuneStats) against the rules of the
/// G3 reference (lune_hud_model.gd, lune_lane_threat.gd, verify_g3 parts B, D and E at model
/// level): privacy of gold, drafts and bookings, the same public picture for every seat, lane
/// threat levels, boss states, the coming wave, number formats, and the human seat of the host
/// loop (planning waits for Ready; bot matches unchanged).
/// </summary>
public sealed class HudModelTests
{
const int Human = 1;
static LuneMatch Fresh(int seed = 4, int human = Human)
{
var match = new LuneMatch();
match.Setup(new MatchOptions { Seed = seed, HumanSlot = human });
for (int slot = 0; slot < 6; slot++)
{
if (slot != human)
LuneBot.Plan(match, slot, "");
}
return match;
}
static string Digest(LuneMatch match, int viewer)
{
return new LuneHudModel(match, viewer).PublicDigest();
}
static void AssertSameForAll(LuneMatch match, string moment)
{
string reference = Digest(match, 0);
for (int viewer = 1; viewer < 6; viewer++)
Assert.AreEqual(reference, Digest(match, viewer), moment + ": public picture of seat " + viewer);
}
// ---------------------------------------------------------------- privacy (verify_g3 part E)
[Test]
public void EverySeatSeesTheSamePublicPicture()
{
LuneMatch match = Fresh();
AssertSameForAll(match, "planning round 1");
match.SetReady(Human);
match.BeginCombat();
for (int step = 0; step < 400 && match.Phase == Phase.Combat; step++)
{
match.StepCombat();
if (step % 100 == 0)
AssertSameForAll(match, "combat step " + step);
}
}
[Test]
public void OtherSeatsPlansStayPrivateUntilCombat()
{
LuneMatch match = Fresh();
match.SetReady(Human);
match.BeginCombat();
while (match.Phase == Phase.Combat)
match.StepCombat();
Assert.AreEqual(Phase.Planning, match.Phase, "the first wave must not end the match");
for (int slot = 0; slot < 6; slot++)
{
if (slot != Human)
LuneBot.Plan(match, slot, "");
}
var viewer = new LuneHudModel(match, Human);
string before = viewer.PublicDigest();
string membersBefore = string.Join("|", viewer.Members(1));
// An enemy recruits a hero and books an attack champion on Mid: both stay private.
int enemy = Enumerable.Range(3, 3).First(slot => match.Players[slot].Draft.NewBookings.Count < LuneContent.MaxBookings);
match.Players[enemy].Draft.Gold += 2000;
if (match.Players[enemy].Draft.Hero == null)
Assert.IsTrue(match.Submit(enemy, Command.HeroBuy(LuneContent.Heroes.Keys[0])).Ok, "hero recruit");
string championId = LuneContent.Champions.Keys[0];
CommandResult booking = match.Submit(enemy, Command.ChampionBook(championId, "mid"));
Assert.IsTrue(booking.Ok, "champion booking: " + booking.Message);
Assert.AreEqual(before, viewer.PublicDigest(), "an enemy draft changed the public picture (buildings, lanes, seats, boss, wave)");
Assert.AreEqual(membersBefore, string.Join("|", viewer.Members(1)), "an enemy draft hero is visible before combat");
Assert.AreEqual(match.GoldOf(Human), viewer.OwnGold, "own gold");
// The enemy sees its own draft: gold, booking and hero.
var enemyView = new LuneHudModel(match, enemy);
Assert.AreEqual(match.Players[enemy].Draft.Gold, enemyView.OwnGold, "enemy's own gold");
Assert.IsTrue(enemyView.OwnBookings().Any(b => b.Fresh && b.ChampionId == championId), "own fresh booking");
Assert.IsNotNull(enemyView.OwnHero, "own draft hero");
}
[Test]
public void BossLaneChoiceIsPrivateToTheChooser()
{
LuneMatch match = Fresh();
match.RoundNumber = 4;
match.Boss.Claim = new BossClaim { Team = 1, WonRound = 3, DeployRound = 5, ChooserSlot = 4, Lane = "top" };
match.Players[4].Draft.BossLane = "bot";
BossInfoCheck(new LuneHudModel(match, 4).Boss(), "deploy_next", "bot", true, true);
BossInfoCheck(new LuneHudModel(match, Human).Boss(), "deploy_next", "top", false, false);
AssertSameForAll(match, "boss claim with a private lane choice");
}
static void BossInfoCheck(LuneHudModel.BossInfo boss, string state, string lane, bool own, bool chooser)
{
Assert.AreEqual(state, boss.State);
Assert.AreEqual(lane, boss.Lane);
Assert.AreEqual(own, boss.Own);
Assert.AreEqual(chooser, boss.Chooser);
}
// ---------------------------------------------------------------- lane threat
static LuneLaneThreat.Result Threat(LuneMatch match, int team, string lane)
{
return LuneLaneThreat.Evaluate(match, team, lane);
}
[Test]
public void LaneThreatFollowsTowerHealth()
{
LuneMatch match = Fresh();
foreach (string lane in LuneMap.Lanes)
{
for (int team = 0; team < 2; team++)
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, team, lane).Level, lane + "/" + team + " at the start");
}
double outer = match.Map.BuildingDef("blau_mid_outer").MaxHp;
double inner = match.Map.BuildingDef("blau_mid_inner").MaxHp;
match.BuildingHp["blau_mid_outer"] = outer * .8;
LuneLaneThreat.Result result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Contested, result.Level);
CollectionAssert.AreEqual(new[] { "Grenzwacht bei 80 %" }, result.Reasons);
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, 1, "mid").Level, "the enemy's view of its own half");
match.BuildingHp["blau_mid_outer"] = outer * .4;
Assert.AreEqual(LuneLaneThreat.Pressure, Threat(match, 0, "mid").Level);
match.BuildingHp["blau_mid_outer"] = 0.0;
result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Pressure, result.Level);
CollectionAssert.AreEqual(new[] { "Grenzwacht gefallen" }, result.Reasons);
match.BuildingHp["blau_mid_inner"] = inner * .7;
CollectionAssert.AreEqual(new[] { "Grenzwacht gefallen", "Bastion bei 70 %" }, Threat(match, 0, "mid").Reasons);
match.BuildingHp["blau_mid_inner"] = inner * .4;
Assert.AreEqual(LuneLaneThreat.Critical, Threat(match, 0, "mid").Level);
match.BuildingHp["blau_mid_inner"] = 0.0;
result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Critical, result.Level);
Assert.AreEqual("KRITISCH", result.Name);
}
[Test]
public void AnnouncedDangersRaiseTheThreat()
{
LuneMatch match = Fresh();
string championId = LuneContent.Champions.Keys[0];
match.Players[4].Bookings.Add(new Booking { Uid = "test-b1", ChampionId = championId, Lane = "mid", DeployRound = match.RoundNumber, Cost = 0 });
LuneLaneThreat.Result result = Threat(match, 0, "mid");
Assert.AreEqual(LuneLaneThreat.Contested, result.Level);
CollectionAssert.AreEqual(new[] { "Angriffschampion " + LuneContent.Champions[championId].Name + " angekündigt" }, result.Reasons);
Assert.AreEqual(LuneLaneThreat.Calm, Threat(match, 1, "mid").Level, "an own champion is no threat");
match.Boss.Claim = new BossClaim { Team = 1, WonRound = 1, DeployRound = match.RoundNumber, ChooserSlot = 4, Lane = "top" };
Assert.AreEqual(LuneLaneThreat.Pressure, Threat(match, 0, "top").Level, "enemy boss with the Grenzwacht standing");
match.BuildingHp["blau_top_outer"] = 0.0;
Assert.AreEqual(LuneLaneThreat.Critical, Threat(match, 0, "top").Level, "enemy boss after the Grenzwacht fell");
var model = new LuneHudModel(match, Human);
Assert.AreEqual(1, model.Lane("mid").Dangers.Count, "danger listed in the lane");
LuneHudModel.WaveInfo wave = model.NextWave();
Assert.AreEqual(1, wave.Champions.Count);
Assert.IsFalse(wave.Champions[0].Own);
Assert.IsNotNull(wave.Boss);
Assert.AreEqual("top", wave.Boss.Lane);
}
[Test]
public void BattlefieldCountsTheLaneUnits()
{
LuneMatch match = Fresh();
match.SetReady(Human);
match.BeginCombat();
for (int step = 0; step < 300; step++)
match.StepCombat();
var model = new LuneHudModel(match, Human);
foreach (string lane in LuneMap.Lanes)
{
int own = match.Combat.Units.Count(u => u.Alive && u.IsMobile() && u.Zone == "lane" && u.Lane == lane && u.Team == 0);
int enemy = match.Combat.Units.Count(u => u.Alive && u.IsMobile() && u.Zone == "lane" && u.Lane == lane && u.Team == 1);
LuneHudModel.LaneInfo info = model.Lane(lane);
Assert.IsTrue(info.HasCounts);
Assert.AreEqual(own, info.OwnCount, lane + " own units");
Assert.AreEqual(enemy, info.EnemyCount, lane + " enemy units");
Assert.AreEqual(4, info.Towers.Length);
}
}
// ---------------------------------------------------------------- boss and waves
[Test]
public void BossStatesFromPublicState()
{
LuneMatch match = Fresh();
Assert.AreEqual("available", new LuneHudModel(match, Human).Boss().State, "round 1");
match.RoundNumber = 2;
match.Boss.NextAvailableRound = 5;
LuneHudModel.BossInfo boss = new LuneHudModel(match, Human).Boss();
Assert.AreEqual("cooldown", boss.State);
Assert.AreEqual(3, boss.RoundsLeft);
match.Boss.Claim = new BossClaim { Team = 0, WonRound = 2, DeployRound = 3, ChooserSlot = 0, Lane = "bot" };
boss = new LuneHudModel(match, Human).Boss();
Assert.AreEqual("deploy_next", boss.State);
Assert.IsTrue(boss.Own);
Assert.AreEqual(LuneContent.BossBountyFor(3), boss.Bounty);
Assert.IsFalse(new LuneHudModel(match, 4).Boss().Own, "the enemy's view");
}
[Test]
public void NextWaveIsTheRulesWave()
{
LuneMatch match = Fresh();
for (int round = 1; round <= 24; round++)
{
match.RoundNumber = round;
LuneHudModel.WaveInfo wave = new LuneHudModel(match, Human).NextWave();
List<UnitDef> rules = LuneWaves.Build(match.Balance, round);
Assert.AreEqual(round, wave.Round);
Assert.AreEqual(rules.Count, wave.Count);
Assert.AreEqual(rules.Count, wave.Monsters.Sum(m => m.Count), "round " + round);
List<string> before = round > 1 ? LuneWaves.UnlockedTypes(match.Balance, round - 1) : new List<string>();
foreach (LuneHudModel.MonsterGroup group in wave.Monsters)
Assert.AreEqual(round > 1 && !before.Contains(group.Id), group.New, "round " + round + " " + group.Id);
}
}
[Test]
public void ReadyLocksThePlan()
{
LuneMatch match = Fresh();
var model = new LuneHudModel(match, Human);
Assert.IsFalse(model.Locked);
Assert.IsFalse(model.CanUndo, "nothing to undo yet");
Assert.IsTrue(match.Submit(Human, Command.Buy(match.BaseIds(match.Players[Human].Bund)[0])).Ok);
Assert.IsTrue(model.CanUndo);
match.SetReady(Human);
Assert.IsTrue(model.Locked);
Assert.IsFalse(model.CanUndo, "undo after Ready");
model.ReadyCount(out int ready, out int humans);
Assert.AreEqual(1, humans);
Assert.AreEqual(1, ready);
Assert.AreEqual("self", model.StatusOf(Human));
Assert.AreEqual("bot", model.StatusOf(0));
}
// ---------------------------------------------------------------- formats
[Test]
public void NumbersAreFormattedAsInGodot()
{
Assert.AreEqual("12.000", LuneUiTheme.Number(12000));
Assert.AreEqual("999", LuneUiTheme.Number(999));
Assert.AreEqual("-1.500", LuneUiTheme.Number(-1500));
Assert.AreEqual("1.234.568", LuneUiTheme.Number(1234567.5));
Assert.AreEqual("1,25", LuneUiTheme.Decimal(1.25, 2));
Assert.AreEqual("2,0", LuneUiTheme.Decimal(2.0));
Assert.AreEqual("Leicht", LuneStats.StrongAgainst(0));
Assert.AreEqual("Schwer", LuneStats.WeakAgainst(0));
Assert.AreEqual(1, LuneStats.StageOf("g01"));
Assert.AreEqual(2, LuneStats.StageOf("w04"));
Assert.AreEqual("Grenzwacht", LuneHudModel.BuildingTitle("blau_mid_outer"));
Assert.AreEqual("Kernwacht", LuneHudModel.BuildingTitle("rot_base_2"));
Assert.AreEqual("MID · Bastion Rot", LuneHudModel.BuildingPlace("rot_mid_inner"));
Assert.AreEqual("Basis Blau · Kernwacht 1", LuneHudModel.BuildingPlace("blau_base_1"));
}
// ---------------------------------------------------------------- the human seat of the host loop
[Test]
public void HumanSeatPlanningWaitsForReady()
{
var runner = new LuneMatchRunner(new MatchOptions { Seed = 4, HumanSlot = Human });
Assert.AreEqual(Human, runner.HumanSlot);
for (int frame = 0; frame < 600; frame++)
runner.Advance(1.0 / 60.0);
Assert.AreEqual(LuneMatchRunner.Stage.Planning, runner.Current, "planning waits for the human");
Assert.IsFalse(runner.Match.Players[Human].Ready);
Assert.IsEmpty(runner.Match.Players[Human].Draft.Roster, "the bots never plan the human seat");
for (int slot = 0; slot < 6; slot++)
{
if (slot != Human)
Assert.IsTrue(runner.Match.Players[slot].Ready, "bot seat " + slot + " ready");
}
Assert.IsTrue(runner.Ready());
Assert.AreEqual(LuneMatchRunner.Stage.Combat, runner.Current, "Ready starts the combat when everyone is ready");
int frames = 0;
while (runner.Current == LuneMatchRunner.Stage.Combat && frames++ < 100000)
runner.Advance(1.0 / 60.0);
runner.Advance(1.0 / 60.0);
Assert.AreEqual(LuneMatchRunner.Stage.Planning, runner.Current, "planning follows the settlement at once");
Assert.IsFalse(runner.Match.Players[Human].Ready, "the human plans the next round again");
}
[Test]
public void BotMatchStillStartsByItself()
{
var runner = new LuneMatchRunner(new MatchOptions { Seed = 4, HumanSlot = -1 });
Assert.AreEqual(-1, runner.HumanSlot);
Assert.IsFalse(runner.Ready(), "no human seat");
runner.Advance(runner.PlanningSeconds + .01);
Assert.AreEqual(LuneMatchRunner.Stage.Combat, runner.Current);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8191e33402ba940d79d36bdcb4877cad

View file

@ -0,0 +1,26 @@
{
"name": "Luneraria.UI.Tests.EditMode",
"rootNamespace": "Luneraria.UI.Tests",
"references": [
"Luneraria.Core",
"Luneraria.View",
"Luneraria.UI",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c0d1b8364f3ec4c339506546b4c36993
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 394ebec8a17f64895857fc387b8c62bf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,23 @@
{
"name": "Luneraria.UI.Tests.PlayMode",
"rootNamespace": "Luneraria.UI.Tests",
"references": [
"Luneraria.Core",
"Luneraria.View",
"Luneraria.UI",
"UnityEngine.TestRunner"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f68d704577bda4e20a2b4f22e92841a2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,133 @@
using System.Collections;
using System.IO;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
namespace Luneraria.UI.Tests
{
/// <summary>
/// U3A decision probe: a UI Toolkit panel renders into a texture in batch mode, with the plate
/// drawing, all glyphs and the three font weights (Regular, Regular with a 0.25 px outline for
/// the G3 weight 560, Bold). Writes tools/out/ui-spike.png (docs/port/screenshots/u3/00).
/// </summary>
public sealed class UiToolkitProbeTests
{
sealed class Plate : VisualElement
{
readonly LunePlateStyle _style;
public Plate(LunePlateStyle style)
{
_style = style;
style.ApplyPadding(this);
generateVisualContent += context => LunePlates.Draw(context, new Rect(0, 0, layout.width, layout.height), _style);
}
}
sealed class Glyph : VisualElement
{
public Glyph(string name, Color color, float edge)
{
this.style.width = edge;
this.style.height = edge;
generateVisualContent += context => LuneGlyphs.Draw(context.painter2D, name, new Rect(0, 0, layout.width, layout.height), color);
}
}
static Label Text(string value, FontAsset font, float size, Color color, float outline = 0f, float spacing = 0f)
{
var label = new Label(value);
label.style.unityFontDefinition = FontDefinition.FromSDFFont(font);
label.style.fontSize = size;
label.style.color = color;
label.style.letterSpacing = spacing;
label.style.marginLeft = 0;
label.style.paddingLeft = 0;
if (outline > 0f)
{
label.style.unityTextOutlineWidth = outline;
label.style.unityTextOutlineColor = color;
}
label.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f, color = new Color(0f, 0f, 0f, .55f) };
return label;
}
[UnityTest]
public IEnumerator PanelRendersIntoTexture()
{
#if UNITY_EDITOR
var assets = UnityEditor.AssetDatabase.LoadAssetAtPath<LuneUiAssets>("Assets/Luneraria/Settings/LuneUi.asset");
#else
LuneUiAssets assets = null;
#endif
Assert.IsNotNull(assets);
var target = new RenderTexture(1920, 1080, 0, RenderTextureFormat.ARGB32);
var settings = ScriptableObject.CreateInstance<PanelSettings>();
settings.themeStyleSheet = assets.Theme;
settings.targetTexture = target;
settings.scaleMode = PanelScaleMode.ScaleWithScreenSize;
settings.referenceResolution = new Vector2Int(1600, 960);
settings.screenMatchMode = PanelScreenMatchMode.Expand;
settings.clearColor = true;
settings.colorClearValue = new Color(.28f, .36f, .22f, 1f);
var host = new GameObject("UiSpike");
var document = host.AddComponent<UIDocument>();
document.panelSettings = settings;
yield return null;
VisualElement root = document.rootVisualElement;
var panel = new Plate(LunePlateStyle.Hud(LuneUiTheme.SlateTop, LuneUiTheme.SlateBottom, LuneUiTheme.Edge, 1f, 6f, 12f, 10f));
panel.style.position = Position.Absolute;
panel.style.left = 12;
panel.style.top = 12;
panel.style.width = 760;
root.Add(panel);
panel.Add(Text("WELLE 21", assets.Display, 30, LuneUiTheme.Hex("f4e6c4"), 0f, 3f));
panel.Add(Text("DEIN ANCIENT · BLAU", assets.TextBold, 14, LuneUiTheme.BronzeLight, 0f, 2f));
panel.Add(Text("Regular 400: Einheiten mit „Grotte“ entsenden. 12.000 / 12.000", assets.Text, 15, LuneUiTheme.Text));
panel.Add(Text("Regular + 0,25 Kontur: Einheiten mit „Grotte“ entsenden. 12.000 / 12.000", assets.Text, 15, LuneUiTheme.Text, .25f));
panel.Add(Text("Regular + 0,45 Kontur: Einheiten mit „Grotte“ entsenden. 12.000 / 12.000", assets.Text, 15, LuneUiTheme.Text, .45f));
panel.Add(Text("Bold 700: Einheiten mit „Grotte“ entsenden. 12.000 / 12.000", assets.TextBold, 15, LuneUiTheme.Text));
panel.Add(Text("235", assets.TextBold, 24, LuneUiTheme.Gold));
var glyphs = new VisualElement();
glyphs.style.flexDirection = FlexDirection.Row;
glyphs.style.flexWrap = Wrap.Wrap;
glyphs.style.marginTop = 8;
panel.Add(glyphs);
foreach (string name in LuneGlyphs.Names)
{
var glyph = new Glyph(name, name.StartsWith("emblem_1") || name == "shape_1" ? LuneUiTheme.Team[1] : name.StartsWith("emblem_0") || name == "shape_0" ? LuneUiTheme.Team[0] : LuneUiTheme.Text, 28f);
glyph.style.marginRight = 6;
glyph.style.marginBottom = 6;
glyphs.Add(glyph);
}
var buttons = new VisualElement();
buttons.style.flexDirection = FlexDirection.Row;
panel.Add(buttons);
var primary = new Plate(LunePlateStyle.Menu(LuneUiTheme.Hex("e8c47e"), LuneUiTheme.Hex("b98a4a"), LuneUiTheme.Hex("fff0cc"), 1.5f, 9f, 30f, 15f));
primary.Add(Text("BEREIT", assets.Display, 22, LuneUiTheme.Ink, 0f, 2f));
buttons.Add(primary);
var card = new Plate(LunePlateStyle.Hud(LuneUiTheme.Hex("19222c"), LuneUiTheme.Hex("10161d"), LuneUiTheme.Hex("34424f"), 1f, 7f, 6f, 6f));
card.style.width = 116;
card.style.height = 132;
card.style.marginLeft = 10;
buttons.Add(card);
for (int frame = 0; frame < 6; frame++)
yield return null;
RenderTexture.active = target;
var image = new Texture2D(1920, 1080, TextureFormat.RGBA32, false);
image.ReadPixels(new Rect(0, 0, 1920, 1080), 0, 0);
image.Apply();
RenderTexture.active = null;
string file = Path.Combine(Path.GetDirectoryName(Application.dataPath), "tools/out/ui-spike.png");
Directory.CreateDirectory(Path.GetDirectoryName(file));
File.WriteAllBytes(file, image.EncodeToPNG());
Color corner = image.GetPixel(1900, 20);
Color panelPixel = image.GetPixel(100, 1080 - 100);
Debug.Log("LUNE_SPIKE corner " + corner + " panel " + panelPixel + " layout " + panel.layout);
Object.Destroy(host);
Assert.Greater(panel.layout.height, 50f);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2414c96b20eed4bd4b2ec981899351d1

8
Assets/Luneraria/UI.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8274853ff66f74957a3faf62bbcdc61f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 87a5adcffce054fd5899eeae7e84e74e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,92 @@
using System.IO;
using System.Linq;
using System.Text;
using Luneraria.UI;
using UnityEditor;
using UnityEngine;
using UnityEngine.TextCore.LowLevel;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
namespace Luneraria.EditorTools
{
/// <summary>
/// Builds Settings/LuneUi.asset (U3): static SDF font atlases from the variable fonts of the
/// reference (assets/fonts, OFL). Unity's text engine has no weight axis; FreeType loads the
/// named instances through the face index (named instance &lt;&lt; 16): Cantarell Regular (3) and
/// Bold (4), Cinzel Bold (2). The atlases hold every character the HUD texts use, so the player
/// never needs the font files.
/// Unity -batchmode -projectPath . -executeMethod Luneraria.EditorTools.LuneUiAssetsBuilder.Build -logFile -
/// </summary>
public static class LuneUiAssetsBuilder
{
public const string AssetPath = "Assets/Luneraria/Settings/LuneUi.asset";
const string Fonts = "Assets/Luneraria/UI/Fonts/";
public static void Build()
{
var assets = AssetDatabase.LoadAssetAtPath<LuneUiAssets>(AssetPath);
if (assets == null)
{
assets = ScriptableObject.CreateInstance<LuneUiAssets>();
AssetDatabase.CreateAsset(assets, AssetPath);
}
assets.Text = Font("Cantarell-VF.otf", 3, "Cantarell-Regular");
assets.TextBold = Font("Cantarell-VF.otf", 4, "Cantarell-Bold");
assets.Display = Font("Cinzel-VF.ttf", 2, "Cinzel-Bold");
assets.Theme = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>("Assets/Luneraria/UI/LuneRuntimeTheme.tss");
EditorUtility.SetDirty(assets);
AssetDatabase.SaveAssets();
Debug.Log("LUNE_UI built " + AssetPath);
if (Application.isBatchMode)
EditorApplication.Exit(0);
}
/// <summary>Printable ASCII, Latin-1 and the punctuation and symbols of the HUD texts.</summary>
public static string Characters()
{
var text = new StringBuilder();
for (char c = (char)32; c < 127; c++)
text.Append(c);
for (char c = (char)160; c <= 255; c++)
text.Append(c);
text.Append("–—‘’‚“”„•…‰‹›€→←↑↓−×★≈≤≥∞·ŒœŠšŽžŸ");
return new string(text.ToString().Distinct().ToArray());
}
static FontAsset Font(string file, int namedInstance, string name)
{
string path = Fonts + name + ".asset";
if (AssetDatabase.LoadAssetAtPath<FontAsset>(path) != null)
AssetDatabase.DeleteAsset(path);
string source = Path.GetFullPath(Fonts + file);
FontAsset font = FontAsset.CreateFontAsset(source, namedInstance << 16, 64, 8, GlyphRenderMode.SDFAA, 1024, 1024);
if (font == null)
throw new System.InvalidOperationException("Font asset failed: " + file + " instance " + namedInstance);
font.name = name;
AssetDatabase.CreateAsset(font, path);
if (!font.TryAddCharacters(Characters(), out string missing))
Debug.LogWarning("LUNE_UI " + name + " misses: " + missing);
for (int index = 0; index < font.atlasTextures.Length; index++)
{
Texture2D atlas = font.atlasTextures[index];
if (atlas == null)
continue;
atlas.name = name + " Atlas " + index;
AssetDatabase.AddObjectToAsset(atlas, font);
}
font.material.name = name + " Material";
AssetDatabase.AddObjectToAsset(font.material, font);
font.atlasPopulationMode = AtlasPopulationMode.Static;
// A static atlas needs no source at runtime: keep no machine path and never clear it in builds.
var serialized = new SerializedObject(font);
serialized.FindProperty("m_SourceFontFilePath").stringValue = Fonts + file;
serialized.FindProperty("m_ClearDynamicDataOnBuild").boolValue = false;
serialized.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(font);
AssetDatabase.SaveAssets();
Debug.Log(string.Format("LUNE_UI font {0}: {1} / {2}, {3} glyphs, {4} atlas(es)", name, font.faceInfo.familyName, font.faceInfo.styleName, font.glyphTable.Count, font.atlasTextures.Length));
return font;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f929a7592cc0947bc89a95e9f73b5416

View file

@ -0,0 +1,18 @@
{
"name": "Luneraria.UI.Editor",
"rootNamespace": "Luneraria.EditorTools",
"references": [
"Luneraria.Core",
"Luneraria.View",
"Luneraria.UI"
],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 554f57ef9b1504ba296369a7d999eaa9
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dd70591b054624795bd726102254f947
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fe04d22bb582e4cd99959615019c6244
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9ed8c741ab987452c8ecd52e5252d418
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View file

@ -0,0 +1,21 @@
fileFormatVersion: 2
guid: 4c6a010ea5ef044029a2777643a87712
TrueTypeFontImporter:
externalObjects: {}
serializedVersion: 4
fontSize: 16
forceTextureCase: -2
characterSpacing: 0
characterPadding: 1
includeFontData: 1
fontNames:
- Cantarell
fallbackFontReferences: []
customCharacters:
fontRenderingMode: 0
ascentCalculationMode: 1
useLegacyBoundsCalculation: 0
shouldRoundAdvanceValue: 1
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84c5b4f661d34482e8f3a990d043245e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View file

@ -0,0 +1,21 @@
fileFormatVersion: 2
guid: 1f3e8bb8eba104f40995e4dd72febe2c
TrueTypeFontImporter:
externalObjects: {}
serializedVersion: 4
fontSize: 16
forceTextureCase: -2
characterSpacing: 0
characterPadding: 1
includeFontData: 1
fontNames:
- Cinzel
fallbackFontReferences: []
customCharacters:
fontRenderingMode: 0
ascentCalculationMode: 1
useLegacyBoundsCalculation: 0
shouldRoundAdvanceValue: 1
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,111 @@
Copyright (c) 2009--2019, The Cantarell Authors
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-------------------------------------------------------------------------------
Scripts contain parts of the source code of glyphsLib:
# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a09e621236d0a4dfe8983c87f70b74ad
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 85500d806221d439abe4e6c35dd58afe
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1 @@
@import url("unity-theme://default");

View file

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 38a51cb4adf3e41879e4510c6a0d7ef6
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
disableValidation: 0
unsupportedSelectorAction: 0

View file

@ -0,0 +1,19 @@
using UnityEngine;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Fonts and panel theme of the HUD (Settings/LuneUi.asset). The fonts are static SDF atlases
/// built from named instances of the G3 variable fonts (LuneUiAssetsBuilder): Cantarell
/// Regular and Bold for HUD text, Cinzel Bold for titles.
/// </summary>
public sealed class LuneUiAssets : ScriptableObject
{
public FontAsset Text;
public FontAsset TextBold;
public FontAsset Display;
public ThemeStyleSheet Theme;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f80a232d1902841a6b0949361a252976

View file

@ -0,0 +1,17 @@
{
"name": "Luneraria.UI",
"rootNamespace": "Luneraria.UI",
"references": [
"Luneraria.Core",
"Luneraria.View"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 53c6ebebe49a9417aa18995e9480970b
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c623eddd7d5194c9db6d6890e87a22d5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,567 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using Luneraria.Core;
namespace Luneraria.UI
{
/// <summary>Network state the HUD shows (role, connection); implemented by the session in U4.</summary>
public interface ILuneHudSession
{
bool IsNetworked { get; }
bool IsAuthority { get; }
}
/// <summary>
/// What one seat may see, prepared for display (port of scripts/luneraria/ui/lune_hud_model.gd).
/// Every HUD element reads through this model, so the privacy rules live in one place:
/// - gold, draft, undo and uncommitted bookings exist only for the viewer's own seat;
/// - of other players the model exposes names, lanes, readiness, human/bot/offline and the
/// committed hero (public since the last combat), never their gold or current plans;
/// - lanes, buildings, the boss and the waves come from public state only.
/// The model never changes the match; commands still go through the game controller.
/// </summary>
public sealed class LuneHudModel
{
public static readonly Dictionary<string, string> TowerNames = new Dictionary<string, string>
{ { "outer", "Grenzwacht" }, { "inner", "Bastion" }, { "base", "Kernwacht" }, { "core", "Ancient" } };
public static readonly Dictionary<string, string> TierText = new Dictionary<string, string>
{ { "outer", "Außenturm" }, { "inner", "Innenturm" }, { "base", "Basisturm" }, { "core", "Ancient" } };
public readonly LuneMatch State;
public readonly int Slot;
/// <summary>Optional network session; only read for the display (role, connection).</summary>
public ILuneHudSession Session;
readonly HashSet<int> _wasHuman = new HashSet<int>();
public LuneHudModel(LuneMatch state, int viewer, ILuneHudSession session = null)
{
State = state;
Slot = System.Math.Max(0, viewer);
Session = session;
}
// ---------------------------------------------------------------- phase and own seat
public bool Planning { get { return State.Phase == Phase.Planning; } }
public bool Fighting { get { return State.Phase == Phase.Combat && State.Combat != null; } }
public bool Over { get { return State.Phase == Phase.Over; } }
public Player Me { get { return State.Players[Slot]; } }
public int Team { get { return Me.Team; } }
public int Enemy { get { return 1 - Team; } }
public int OwnGold { get { return State.GoldOf(Slot); } }
/// <summary>Base income paid at the end of the current round's combat (rules: LuneBalance.RoundIncome).</summary>
public int Income { get { return State.Balance.RoundIncome(State.RoundNumber); } }
/// <summary>After Ready the plan is binding (the host refuses later commands).</summary>
public bool Locked { get { return Planning && Me.Ready; } }
public List<RosterEntry> OwnRoster { get { return Planning ? Me.Draft.Roster : Me.Roster; } }
/// <summary>The own hero: the draft in planning, the committed hero otherwise; null without a hero.</summary>
public HeroState OwnHero { get { return Planning ? Me.Draft.Hero : Me.Hero; } }
public bool CanUndo { get { return Planning && !Locked && Me.Undo.Count > 0; } }
public int SupplyCap { get { return State.Balance.SupplyCap; } }
public bool Networked { get { return Session != null && Session.IsNetworked; } }
public bool IsGuest { get { return Networked && !Session.IsAuthority; } }
public sealed class BookingView
{
public string Uid = "";
public string ChampionId = "";
public string Lane = "";
public int DeployRound;
public int Cost;
/// <summary>Booked in this planning (still refundable).</summary>
public bool Fresh;
public override string ToString() { return Uid + ":" + ChampionId + ":" + Lane + ":" + DeployRound + ":" + Fresh; }
}
public List<BookingView> OwnBookings()
{
var result = new List<BookingView>();
if (Planning)
{
foreach (Booking booking in Me.Draft.NewBookings)
result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = State.RoundNumber + 1, Fresh = true });
}
foreach (Booking booking in Me.Bookings)
{
if (booking.DeployRound >= State.RoundNumber)
result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = booking.DeployRound });
}
return result;
}
// ---------------------------------------------------------------- units and heroes
public string RoleOf(string typeId) { return State.RoleOf(typeId); }
public sealed class TroopInfo
{
public UnitDef Def;
public string Role = "";
public int Stage;
}
/// <summary>Catalogue definition of a troop with its role and stage.</summary>
public TroopInfo Troop(string typeId)
{
return new TroopInfo { Def = DataCatalog.GetUnit(typeId), Role = RoleOf(typeId), Stage = LuneStats.StageOf(typeId) };
}
/// <summary>Live combat unit for a persistent uid of the viewer (or null).</summary>
public LuneUnit LiveUnit(string uid)
{
if (!Fighting || string.IsNullOrEmpty(uid))
return null;
foreach (LuneUnit unit in State.Combat.Units)
{
if (unit.PersistentUid == uid)
return unit;
}
return null;
}
public UnitDef HeroDefinition(HeroState hero)
{
UnitDef definition = LuneContent.HeroDefinition(hero);
definition.Role = ContentData.HeroRoles.GetValueOrDefault(hero.HeroId, "front");
return definition;
}
// ---------------------------------------------------------------- buildings
public sealed class BuildingInfo
{
public string Id = "";
public int Team;
public string Tier = "outer";
public string Lane = "";
public bool Alive;
public double Hp;
public double MaxHp = 1.0;
public double Fraction;
public bool Attackable;
public BuildingInfo[] Base;
public override string ToString()
{
return Id + ":" + Alive + ":" + LuneUiTheme.Round(Hp) + ":" + Attackable;
}
}
public BuildingInfo Building(string id)
{
BuildingDef definition = State.Map.BuildingDef(id);
double maxHp = definition != null ? definition.MaxHp : 1.0;
double hp = State.BuildingHp.GetValueOrDefault(id, 0.0);
bool alive = hp > 0.0;
if (Fighting && State.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
{
alive = unit.Alive;
hp = alive ? unit.Hp : 0.0;
}
return new BuildingInfo
{
Id = id, Team = definition != null ? definition.Team : 0, Tier = definition != null ? definition.Tier : "outer", Lane = definition != null ? definition.Lane : "",
Alive = alive, Hp = hp, MaxHp = maxHp, Fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0),
Attackable = alive && definition != null && LuneMap.IsAttackable(definition, AliveMap()),
};
}
Dictionary<string, bool> AliveMap()
{
if (Fighting)
return State.Combat.AliveMap();
var result = new Dictionary<string, bool>();
foreach (KeyValuePair<string, double> pair in State.BuildingHp)
result[pair.Key] = pair.Value > 0.0;
return result;
}
public static string BuildingTitle(string id)
{
string[] parts = id.Split('_');
if (parts[1] == "ancient")
return "Ancient";
if (parts[1] == "base")
return "Kernwacht";
return TowerNames.TryGetValue(parts[2], out string name) ? name : id;
}
public static string BuildingPlace(string id)
{
string[] parts = id.Split('_');
string team = parts[0] == "blau" ? "Blau" : "Rot";
if (parts[1] == "ancient")
return "Ancient " + team;
if (parts[1] == "base")
return string.Format("Basis {0} · Kernwacht {1}", team, parts[2]);
return string.Format("{0} · {1} {2}", LuneMap.LaneNames[parts[1]].ToUpperInvariant(), BuildingTitle(id), team);
}
/// <summary>A team's Ancient with its two base towers (Kernwacht) in Base.</summary>
public BuildingInfo Ancient(int team)
{
string prefix = team == 0 ? "blau" : "rot";
BuildingInfo core = Building(prefix + "_ancient");
core.Base = new[] { Building(prefix + "_base_1"), Building(prefix + "_base_2") };
return core;
}
// ---------------------------------------------------------------- players
public string PlayerName(int seat)
{
return seat == Slot ? "Du" : State.Players[seat].Name;
}
/// <summary>Seat status for display: self, human, bot, or offline (was human, a bot plays the seat).</summary>
public string StatusOf(int seat)
{
Player player = State.Players[seat];
if (player.IsHuman)
_wasHuman.Add(seat);
if (seat == Slot)
return "self";
if (player.IsHuman)
return "human";
return _wasHuman.Contains(seat) ? "offline" : "bot";
}
public sealed class MemberInfo
{
public int Slot;
public string Name = "";
public string Lane = "";
public bool IsSelf;
public bool Ready;
public string Status = "";
public string Bund = "";
public string HeroId = "";
public int HeroLevel;
public override string ToString()
{
return Slot + ":" + Name + ":" + Lane + ":" + IsSelf + ":" + Ready + ":" + Status + ":" + Bund + ":" + HeroId + ":" + HeroLevel;
}
}
public List<MemberInfo> Members(int team)
{
var result = new List<MemberInfo>();
for (int seat = team * 3; seat < team * 3 + 3; seat++)
{
Player player = State.Players[seat];
// Own seat: current draft. Everyone else: the committed hero, public since the last combat.
HeroState hero = seat == Slot ? OwnHero : player.Hero;
result.Add(new MemberInfo
{
Slot = seat, Name = PlayerName(seat), Lane = player.Lane, IsSelf = seat == Slot, Ready = player.Ready, Status = StatusOf(seat), Bund = player.Bund,
HeroId = hero != null ? hero.HeroId : "", HeroLevel = hero != null ? LuneContent.LevelFor(hero.Xp) : 0,
});
}
return result;
}
/// <summary>Ready humans and humans in the match.</summary>
public void ReadyCount(out int ready, out int humans)
{
ready = 0;
humans = 0;
foreach (Player player in State.Players)
{
if (!player.IsHuman)
continue;
humans++;
if (player.Ready)
ready++;
}
}
// ---------------------------------------------------------------- lanes
public sealed class HeroBrief
{
public string Model = "";
public string Name = "";
public int Level;
public override string ToString() { return Model + ":" + Level; }
}
public sealed class LaneInfo
{
public string Lane = "";
public string Title = "";
public string OwnPlayer = "";
public string EnemyPlayer = "";
public bool OwnIsMe;
/// <summary>Own inner, own outer, enemy outer, enemy inner: the chain from the own base to the enemy's.</summary>
public BuildingInfo[] Towers;
public LuneLaneThreat.Result Threat;
public readonly List<Announcement> Dangers = new List<Announcement>();
public HeroBrief OwnHero;
public HeroBrief EnemyHero;
/// <summary>Unit counts exist only in combat.</summary>
public bool HasCounts;
public int OwnCount;
public int EnemyCount;
}
public LaneInfo Lane(string lane)
{
int ownSeat = State.SlotOf(Team, lane);
int enemySeat = State.SlotOf(Enemy, lane);
string own = Team == 0 ? "blau" : "rot";
string enemy = Team == 0 ? "rot" : "blau";
var result = new LaneInfo
{
Lane = lane, Title = LuneMap.LaneNames[lane].ToUpperInvariant(), OwnPlayer = PlayerName(ownSeat), EnemyPlayer = PlayerName(enemySeat), OwnIsMe = ownSeat == Slot,
Towers = new[] { Building(own + "_" + lane + "_inner"), Building(own + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_inner") },
Threat = LuneLaneThreat.Evaluate(State, Team, lane), OwnHero = HeroOf(ownSeat), EnemyHero = HeroOf(enemySeat),
};
foreach (Announcement entry in State.Announcements())
{
if (entry.Lane == lane)
result.Dangers.Add(entry);
}
if (Fighting)
{
LuneLaneThreat.Battle battle = LuneLaneThreat.Battlefield(State, Team, lane);
result.HasCounts = true;
result.OwnCount = battle.OwnCount;
result.EnemyCount = battle.EnemyCount;
}
return result;
}
/// <summary>A seat's hero for display: the own draft for the viewer, the committed (public) hero for everyone else; null without a hero.</summary>
public HeroBrief HeroOf(int seat)
{
HeroState hero = seat == Slot ? OwnHero : State.Players[seat].Hero;
if (hero == null)
return null;
HeroSpec spec = LuneContent.Heroes[hero.HeroId];
return new HeroBrief { Model = spec.Model, Name = spec.Name, Level = LuneContent.LevelFor(hero.Xp) };
}
// ---------------------------------------------------------------- boss grotto
public sealed class BossInfo
{
/// <summary>available, contested, conquered, failed, deploy_next, deploying, defeated or cooldown.</summary>
public string State = "cooldown";
public int Team = -1;
public string Lane = "";
public int DeployRound;
public int WonRound;
public int Bounty;
public int RoundsLeft;
public bool Own;
public bool Chooser;
public int NextRound;
public bool HasHp;
public double Hp;
public double MaxHp = 1.0;
/// <summary>The public part (without the viewer's own/chooser flags).</summary>
public string PublicText()
{
return string.Format(CultureInfo.InvariantCulture, "{0}:{1}:{2}:{3}:{4}:{5}:{6}:{7}:{8}", State, Team, Lane, DeployRound, WonRound, Bounty, RoundsLeft, NextRound,
HasHp ? LuneUiTheme.Round(Hp) + "/" + LuneUiTheme.Round(MaxHp) : "-");
}
public override string ToString() { return PublicText() + ":" + Own + ":" + Chooser; }
}
/// <summary>
/// Public boss state: available, contested, conquered, failed, deploy_next, deploying,
/// defeated, cooldown. On a guest the grotto fight is read from the mirrored units. The
/// chooser sees the lane of the own draft; everyone else the public claim lane.
/// </summary>
public BossInfo Boss()
{
return Boss(false);
}
BossInfo Boss(bool publicOnly)
{
BossClaim claim = State.Boss.Claim;
int round = State.RoundNumber;
var result = new BossInfo { RoundsLeft = System.Math.Max(0, State.Boss.NextAvailableRound - round), NextRound = State.Boss.NextAvailableRound };
if (Fighting)
{
foreach (LuneUnit unit in State.Combat.Units)
{
if (unit.Kind != "boss")
continue;
if (unit.Zone == "grotto")
{
if (unit.Alive)
{
result.State = "contested";
return result;
}
int winner = -1;
foreach (LuneUnit other in State.Combat.Units)
{
if (other.Alive && other.Zone == "grotto" && other.Team <= 1)
winner = other.Team;
}
result.State = winner >= 0 ? "conquered" : "failed";
result.Team = winner;
result.Own = winner == Team;
result.DeployRound = round + 1;
return result;
}
if (unit.Zone == "lane")
{
result.State = unit.Alive ? "deploying" : "defeated";
result.Team = unit.Team;
result.Own = unit.Team == Team;
result.Lane = unit.Lane;
result.DeployRound = round;
result.Bounty = LuneContent.BossBountyFor(round);
result.HasHp = true;
result.Hp = unit.Hp;
result.MaxHp = unit.MaxHp;
return result;
}
}
}
if (claim != null && claim.DeployRound >= round)
{
result.State = Planning ? "deploy_next" : "deploying";
result.Team = claim.Team;
result.Own = claim.Team == Team;
result.WonRound = claim.WonRound;
result.DeployRound = claim.DeployRound;
result.Bounty = LuneContent.BossBountyFor(claim.DeployRound);
result.Chooser = claim.ChooserSlot == Slot;
// The chooser's lane change is private until combat; others see the public claim lane.
string chosen = !publicOnly && result.Chooser && Planning ? Me.Draft.BossLane ?? "" : "";
result.Lane = chosen.Length > 0 ? chosen : claim.Lane;
return result;
}
if (State.GrottoAvailable())
result.State = "available";
return result;
}
// ---------------------------------------------------------------- waves
public sealed class MonsterGroup
{
public string Id = "";
public string Name = "";
public int Count;
public double Hp;
public double Damage;
public double Range;
public bool New;
public int Armor;
public int Attack;
public override string ToString() { return Id + "x" + Count + (New ? "+" : ""); }
}
public sealed class ChampionNotice
{
public int Team;
public string Lane = "";
public string ChampionId = "";
public string Name = "";
public bool Own;
public override string ToString() { return Team + ":" + Lane + ":" + ChampionId; }
}
public sealed class BossNotice
{
public int Team;
public string Lane = "";
public int Bounty;
public bool Own;
public override string ToString() { return Team + ":" + Lane + ":" + Bounty; }
}
public sealed class WaveInfo
{
public int Round;
public readonly List<MonsterGroup> Monsters = new List<MonsterGroup>();
public int Count;
public readonly List<ChampionNotice> Champions = new List<ChampionNotice>();
public BossNotice Boss;
public override string ToString()
{
return Round + "|" + string.Join(",", Monsters) + "|" + Count + "|" + string.Join(",", Champions) + "|" + (Boss != null ? Boss.ToString() : "-");
}
}
/// <summary>
/// The wave of the coming combat (planning) or the one after (combat). Monsters are public
/// and identical for both sides; champions and the boss only once they are announced.
/// </summary>
public WaveInfo NextWave()
{
int round = Fighting ? State.RoundNumber + 1 : State.RoundNumber;
List<UnitDef> wave = LuneWaves.Build(State.Balance, round);
List<string> before = round > 1 ? LuneWaves.UnlockedTypes(State.Balance, round - 1) : new List<string>();
var result = new WaveInfo { Round = round, Count = wave.Count };
var groups = new Dictionary<string, MonsterGroup>();
foreach (UnitDef monster in wave)
{
if (!groups.TryGetValue(monster.Id, out MonsterGroup group))
{
group = new MonsterGroup
{
Id = monster.Id, Name = monster.DisplayName, Hp = monster.MaxHp, Damage = monster.Damage, Range = monster.AttackRange,
New = round > 1 && !before.Contains(monster.Id), Armor = monster.ArmorType, Attack = monster.AttackType,
};
groups[monster.Id] = group;
result.Monsters.Add(group);
}
group.Count++;
}
if (!Fighting)
{
foreach (Announcement entry in State.Announcements())
{
if (entry.Kind == "boss")
result.Boss = new BossNotice { Team = entry.Team, Lane = entry.Lane, Bounty = entry.Bounty, Own = entry.Team == Team };
else
result.Champions.Add(new ChampionNotice { Team = entry.Team, Lane = entry.Lane, ChampionId = entry.ChampionId, Name = LuneContent.Champions[entry.ChampionId].Name, Own = entry.Team == Team });
}
}
return result;
}
// ---------------------------------------------------------------- network test support
/// <summary>
/// Everything the HUD shows that must be identical for every seat (host, guest, bot views),
/// as one string. The boss uses the public claim lane: the chooser's draft lane is private.
/// </summary>
public string PublicDigest()
{
var text = new StringBuilder();
text.Append("round ").Append(State.RoundNumber).Append(" phase ").Append(State.Phase).Append('\n');
foreach (BuildingDef definition in State.Map.Buildings)
{
BuildingInfo entry = Building(definition.Id);
text.Append(definition.Id).Append('=').Append(entry.Alive).Append('/').Append(LuneUiTheme.Round(entry.Hp)).Append(' ');
}
text.Append('\n');
foreach (string name in LuneMap.Lanes)
{
for (int side = 0; side < 2; side++)
{
LuneLaneThreat.Result threat = LuneLaneThreat.Evaluate(State, side, name);
text.Append(name).Append('/').Append(side).Append('=').Append(threat.Level).Append(' ').Append(string.Join(";", threat.Reasons)).Append('\n');
}
}
foreach (Player player in State.Players)
{
text.Append(player.Slot).Append(':').Append(player.Lane).Append(':').Append(player.Ready).Append(':').Append(player.IsHuman).Append(':')
.Append(player.Hero != null ? player.Hero.HeroId + "@" + player.Hero.Xp : "-").Append(' ');
}
text.Append('\n').Append("boss ").Append(Boss(true).PublicText()).Append('\n');
text.Append("wave ").Append(NextWave()).Append('\n');
return text.ToString();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3221278a1afde4ef49945d1280a93322

View file

@ -0,0 +1,192 @@
using System.Collections.Generic;
using Luneraria.Core;
namespace Luneraria.UI
{
/// <summary>
/// Deterministic lane threat for one team (port of scripts/luneraria/ui/lune_lane_threat.gd),
/// read only from public state: building HP, the units on the battlefield (positions and HP,
/// which every client also sees) and the public announcements of this round. It never reads
/// drafts, gold, unannounced bookings or bot notes, so host and guest compute the same level.
/// Positions are projected onto the lane, because a network guest has positions but no lane
/// progress.
///
/// RUHIG nothing below applies
/// UMKÄMPFT a lane tower is below 85 %, an enemy attack champion is announced here, or
/// enemy units stand in the own half of the lane
/// UNTER DRUCK the Grenzwacht fell or is below 50 %, enemies attack the Grenzwacht, the enemy
/// has more than 1.5 times the own unit HP in the own half, or the enemy boss
/// is deployed on this lane
/// KRITISCH both lane towers fell (base open), the Bastion is below 50 % after the
/// Grenzwacht fell, enemies attack the Bastion, or the enemy boss comes while the
/// Grenzwacht is already down
/// </summary>
public static class LuneLaneThreat
{
public const int Calm = 0;
public const int Contested = 1;
public const int Pressure = 2;
public const int Critical = 3;
public static readonly string[] Names = { "RUHIG", "UMKÄMPFT", "UNTER DRUCK", "KRITISCH" };
public static readonly string[] Glyphs = { "check", "melee", "warning", "warning" };
public sealed class Result
{
public int Level;
public string Name = "";
public readonly List<string> Reasons = new List<string>();
}
public sealed class Battle
{
public double Length;
public int OwnCount;
public int EnemyCount;
public double OwnHp;
public double EnemyHp;
/// <summary>Deepest enemy position measured from the own base along the lane (infinity without enemies).</summary>
public double EnemyDepth = double.PositiveInfinity;
public double OwnFront;
}
static void Building(LuneMatch state, string id, out bool alive, out double fraction)
{
double maxHp = state.Map.BuildingDef(id)?.MaxHp ?? 1.0;
if (state.Phase == Phase.Combat && state.Combat != null && state.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
{
alive = unit.Alive;
fraction = alive ? GdMath.Clamp(unit.Hp / GdMath.Max(1.0, maxHp), 0.0, 1.0) : 0.0;
return;
}
double hp = state.BuildingHp.GetValueOrDefault(id, 0.0);
alive = hp > 0.0;
fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0);
}
static string Percent(double value)
{
return LuneUiTheme.Round(value * 100.0) + " %";
}
public static Result Evaluate(LuneMatch state, int team, string lane)
{
var result = new Result();
int level = Calm;
string prefix = team == 0 ? "blau" : "rot";
int enemy = 1 - team;
Building(state, prefix + "_" + lane + "_outer", out bool outerAlive, out double outer);
Building(state, prefix + "_" + lane + "_inner", out bool innerAlive, out double inner);
if (!outerAlive && !innerAlive)
{
level = Critical;
result.Reasons.Add("Grenzwacht und Bastion gefallen – der Weg zur Basis ist offen");
}
else if (!outerAlive)
{
level = Pressure;
result.Reasons.Add("Grenzwacht gefallen");
if (inner < .5)
{
level = Critical;
result.Reasons.Add("Bastion bei " + Percent(inner));
}
else if (inner < .85)
{
result.Reasons.Add("Bastion bei " + Percent(inner));
}
}
else if (outer < .5)
{
level = Pressure;
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
}
else if (outer < .85)
{
level = Contested;
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
}
foreach (Announcement entry in state.Announcements())
{
if (entry.Team != enemy || entry.Lane != lane)
continue;
if (entry.Kind == "boss")
{
level = System.Math.Max(level, !outerAlive ? Critical : Pressure);
result.Reasons.Add("Gegnerische Schlundkrone greift hier an");
}
else
{
level = System.Math.Max(level, Contested);
result.Reasons.Add("Angriffschampion " + LuneContent.Champions[entry.ChampionId].Name + " angekündigt");
}
}
if (state.Phase == Phase.Combat && state.Combat != null)
{
Battle battle = Battlefield(state, team, lane);
double length = battle.Length;
if (battle.EnemyCount > 0)
{
double deepest = battle.EnemyDepth;
double innerAt = length * state.Balance.InnerTowerFraction;
double outerAt = length * state.Balance.OuterTowerFraction;
double reach = state.Balance.TowerRange * .8;
if (deepest <= innerAt + reach)
{
level = Critical;
result.Reasons.Add("Gegner greifen die Bastion oder die Basis an");
}
else if (deepest <= outerAt + reach && outerAlive)
{
level = System.Math.Max(level, Pressure);
result.Reasons.Add("Gegner greifen die Grenzwacht an");
}
else if (deepest <= length * .5)
{
level = System.Math.Max(level, Contested);
result.Reasons.Add("Gegner in der eigenen Lanehälfte");
}
if (battle.EnemyHp > battle.OwnHp * 1.5 && deepest <= length * .5)
{
level = System.Math.Max(level, Pressure);
result.Reasons.Add(string.Format("Übermacht: {0} gegen {1} Einheiten", battle.EnemyCount, battle.OwnCount));
}
}
}
result.Level = level;
result.Name = Names[level];
return result;
}
/// <summary>
/// Units of both sides on one lane: counts, HP sums and the deepest enemy position measured
/// from the own base along the lane (from positions only).
/// </summary>
public static Battle Battlefield(LuneMatch state, int team, string lane)
{
LanePath path = state.Map.LanePaths[lane];
var result = new Battle { Length = path.Length };
if (state.Combat == null)
return result;
foreach (LuneUnit unit in state.Combat.Units)
{
if (!unit.Alive || !unit.IsMobile() || unit.Zone != "lane" || unit.Lane != lane || unit.Team > 1)
continue;
double s = path.Project(unit.Position);
double fromOwnBase = team == 0 ? s : path.Length - s;
if (unit.Team == team)
{
result.OwnCount++;
result.OwnHp += unit.Hp;
result.OwnFront = GdMath.Max(result.OwnFront, fromOwnBase);
}
else
{
result.EnemyCount++;
result.EnemyHp += unit.Hp;
result.EnemyDepth = GdMath.Min(result.EnemyDepth, fromOwnBase);
}
}
return result;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 201a11a896ae1465781ac57b8f54ffbf

View file

@ -0,0 +1,181 @@
using System.Collections.Generic;
using Luneraria.Core;
using UnityEngine;
namespace Luneraria.UI
{
/// <summary>Tooltip content (Godot: the dictionary with title, subtitle, stats, text, note, warn).</summary>
public sealed class LuneTip
{
public string Title = "";
public string Subtitle = "";
public string Glyph = "";
public Color? GlyphColor;
/// <summary>Rows [glyph, name, value].</summary>
public readonly List<string[]> Stats = new List<string[]>();
public string Text = "";
public string Note = "";
public string Warn = "";
public bool IsEmpty { get { return Title.Length == 0 && Subtitle.Length == 0 && Stats.Count == 0 && Text.Length == 0 && Note.Length == 0 && Warn.Length == 0; } }
public LuneTip Stat(string glyph, string name, string value)
{
Stats.Add(new[] { glyph, name, value });
return this;
}
/// <summary>Everything the tooltip shows, as one string (tests and change detection).</summary>
public override string ToString()
{
var parts = new List<string> { Title, Subtitle };
foreach (string[] row in Stats)
parts.Add(row[1] + ": " + row[2]);
parts.Add(Text);
parts.Add(Note);
parts.Add(Warn);
return string.Join("\n", parts.FindAll(p => p.Length > 0));
}
}
/// <summary>
/// Display figures from the real unit definitions and combat rules (port of
/// scripts/luneraria/ui/lune_stats.gd): damage per hit, one attack per second times the attack
/// tempo bonus, range, armour and damage type with the damage matrix (LuneUnit.DamageMatrix).
/// Nothing is invented; values are only formatted.
/// </summary>
public static class LuneStats
{
static readonly Dictionary<string, string> RoleNames = new Dictionary<string, string>
{ { "front", "Tank" }, { "melee", "Nahkampf" }, { "ranged", "Fernkampf" }, { "splash", "Flächenschaden" }, { "support", "Support" } };
static readonly Dictionary<string, string> RoleGlyphs = new Dictionary<string, string>
{ { "front", "tank" }, { "melee", "melee" }, { "ranged", "ranged" }, { "splash", "splash" }, { "support", "support" } };
public static string RoleName(string role)
{
return RoleNames.TryGetValue(role ?? "", out string name) ? name : role;
}
public static string RoleGlyph(string role)
{
return RoleGlyphs.TryGetValue(role ?? "", out string glyph) ? glyph : "melee";
}
static int Find(int attackType, double factor)
{
int row = GdMath.Clamp(attackType, 0, 2);
for (int armor = 0; armor < 3; armor++)
{
if (LuneUnit.DamageMatrix[row, armor] == factor)
return armor;
}
return -1;
}
/// <summary>Armour class the damage type hits for 130 % (DamageMatrix).</summary>
public static string StrongAgainst(int attackType)
{
return ContentData.ArmorNames[Find(attackType, 1.30)];
}
/// <summary>Armour class the damage type hits for 75 % (DamageMatrix).</summary>
public static string WeakAgainst(int attackType)
{
return ContentData.ArmorNames[Find(attackType, 0.75)];
}
public static double HitDamage(UnitDef definition)
{
return definition.Damage * (1.0 + definition.DamageBonus);
}
public static double AttacksPerSecond(UnitDef definition)
{
return 1.0 + GdMath.Clamp(definition.AttackSpeedBonus, -.25, .5);
}
public static string ArmorName(int armorType)
{
return ContentData.ArmorNames[GdMath.Clamp(armorType, 0, 2)];
}
public static string DamageName(int attackType)
{
return ContentData.DamageNames[GdMath.Clamp(attackType, 0, 2)];
}
/// <summary>Stat rows [glyph, name, value] for tooltips and detail panels.</summary>
public static List<string[]> Rows(UnitDef definition, bool withMove = false)
{
int attackType = definition.AttackType;
double reduction = definition.ReductionBonus;
var result = new List<string[]>
{
new[] { "heart", "Leben", LuneUiTheme.Number(definition.MaxHp) },
new[] { "sword", "Schaden/Treffer", LuneUiTheme.Decimal(HitDamage(definition)) },
new[] { "tempo", "Angriffstempo", LuneUiTheme.Decimal(AttacksPerSecond(definition), 2) + " /s" },
new[] { "range", "Reichweite", LuneUiTheme.Decimal(definition.AttackRange) + " m" },
new[] { "armor", "Rüstung", ArmorName(definition.ArmorType) + (reduction > 0.0 ? " · −" + LuneUiTheme.Round(reduction * 100.0) + " % Schaden" : "") },
new[] { "splash", "Angriffsart", DamageName(attackType) + " · stark gegen " + StrongAgainst(attackType) },
};
if (withMove)
result.Add(new[] { "move", "Lauftempo", LuneUiTheme.Decimal(definition.Speed) + " m/s" });
if (definition.Regen > 0.0)
result.Add(new[] { "heart", "Regeneration", LuneUiTheme.Decimal(definition.Regen * 100.0) + " % Leben/s" });
if (definition.ItemSplash > 0.0)
result.Add(new[] { "splash", "Splitter", LuneUiTheme.Round(definition.ItemSplash * 100.0) + " % an bis zu 2 Gegner" });
return result;
}
/// <summary>Compact pairs [glyph, "Name Wert"] for narrow panels (two per row).</summary>
public static List<string[]> ShortRows(UnitDef definition)
{
int attackType = GdMath.Clamp(definition.AttackType, 0, 2);
return new List<string[]>
{
new[] { "heart", "Leben " + LuneUiTheme.Number(definition.MaxHp) },
new[] { "sword", "Schaden " + LuneUiTheme.Decimal(HitDamage(definition)) },
new[] { "tempo", "Tempo " + LuneUiTheme.Decimal(AttacksPerSecond(definition), 2) + "/s" },
new[] { "range", "Reichweite " + LuneUiTheme.Decimal(definition.AttackRange) + " m" },
new[] { "armor", "Rüstung " + ArmorName(definition.ArmorType) },
new[] { "splash", DamageName(attackType) + " › " + StrongAgainst(attackType) },
};
}
/// <summary>"Schaden pro Sekunde" from hit damage and tempo, without abilities or type modifiers.</summary>
public static double Dps(UnitDef definition)
{
return HitDamage(definition) * AttacksPerSecond(definition);
}
/// <summary>Stage of a troop id: even numbers are the upgraded stage (g02, w04 …).</summary>
public static int StageOf(string typeId)
{
int number = int.Parse(typeId.Substring(1));
return number % 2 == 0 ? 2 : 1;
}
/// <summary>Tooltip content for a troop type (shop cards, army chips).</summary>
public static LuneTip TroopTip(string typeId, string role, string extra = "")
{
UnitDef data = DataCatalog.GetUnit(typeId);
string upgrade = data.UpgradeId ?? "";
string note;
if (upgrade.Length > 0)
{
UnitDef next = DataCatalog.GetUnit(upgrade);
note = string.Format("Aufwertung: {0} für {1} Gold (Leben {2} → {3}, Schaden {4} → {5})", next.DisplayName, next.Cost,
LuneUiTheme.Number(data.MaxHp), LuneUiTheme.Number(next.MaxHp), LuneUiTheme.Decimal(data.Damage), LuneUiTheme.Decimal(next.Damage));
}
else
{
note = "Höchste Stufe.";
}
var tip = new LuneTip { Title = data.DisplayName, Glyph = RoleGlyph(role),
Subtitle = string.Format("{0} · Stufe {1} · {2} Gold", RoleName(role), StageOf(typeId) == 2 ? "II" : "I", data.Cost),
Text = data.AbilityDescription ?? "", Note = note, Warn = extra ?? "" };
tip.Stats.AddRange(Rows(data));
return tip;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 743293fdb90d74ec9afb6629a70928a7

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f83fce8e4470d4335a25f56c932be60e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,482 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Vector symbols of the HUD (port of scripts/luneraria/ui/theme/lune_glyphs.gd), drawn from a
/// unit square so they stay sharp at every UI scale. Team emblems follow the G2 world: Blau =
/// Mondsichel (round shapes), Rot = Sonnenscheibe (rayed, angular), the grotto = hexagon.
/// </summary>
public static class LuneGlyphs
{
public static readonly string[] Names = { "coin", "emblem_0", "emblem_1", "emblem_2", "shape_0", "shape_1", "shape_2",
"tank", "melee", "ranged", "splash", "support", "siege", "crown", "champion", "hourglass", "tower",
"ancient", "warning", "check", "close", "bot", "offline", "clock", "heart", "tempo", "range", "armor",
"move", "star", "lock", "up", "lane_top", "lane_mid", "lane_bot", "grotto", "pause", "play", "menu",
"gear", "human", "sword", "basaltschild", "glutklinge", "rankenmantel", "funkenfokus", "quellamulett",
"kristallsplitter", "hero", "skull" };
const float Tau = Mathf.PI * 2f;
static readonly Dictionary<string, Vector2[]> Cache = new Dictionary<string, Vector2[]>();
/// <summary>Draws one symbol into rect (unit square centred, aspect kept). `accent` is an optional second colour for inner details.</summary>
public static void Draw(Painter2D painter, string glyph, Rect rect, Color color, Color accent = default)
{
if (string.IsNullOrEmpty(glyph))
return;
var f = new Frame(rect);
Color dark = accent.a > 0f ? accent : new Color(color.r * .45f, color.g * .45f, color.b * .45f, color.a);
switch (glyph)
{
case "coin":
{
Vector2 c = f.P(.5f, .5f);
float r = f.S * .42f;
Disc(painter, c, r, color);
ArcLine(painter, c, r, 0f, Tau, LuneUiTheme.Darkened(color, .35f), Mathf.Max(1f, r * .12f));
ArcLine(painter, c, r * .62f, 0f, Tau, LuneUiTheme.Darkened(color, .28f), Mathf.Max(1f, r * .1f));
ArcLine(painter, c, r * .8f, Mathf.PI * 1.1f, Mathf.PI * 1.45f, new Color(1f, 1f, 1f, .55f * color.a), Mathf.Max(1f, r * .1f));
break;
}
case "emblem_0":
Fill(painter, f, Crescent(), color);
break;
case "emblem_1":
Disc(painter, f.P(.5f, .5f), f.S * .2f, color);
for (int index = 0; index < 8; index++)
{
float angle = Tau * index / 8f - Mathf.PI * .5f;
Vector2 tip = new Vector2(.5f, .5f) + Dir(angle) * .47f;
Vector2 left = new Vector2(.5f, .5f) + Dir(angle - .22f) * .27f;
Vector2 right = new Vector2(.5f, .5f) + Dir(angle + .22f) * .27f;
Fill(painter, f, new[] { left, tip, right }, color);
}
break;
case "emblem_2":
{
List<Vector2> hexagon = Circle(6, new Vector2(.5f, .5f), .42f, -Mathf.PI * .5f);
hexagon.Add(hexagon[0]);
Line(painter, f, hexagon.ToArray(), color, .08f);
Fill(painter, f, new[] { V(.5f, .3f), V(.64f, .5f), V(.5f, .7f), V(.36f, .5f) }, color);
break;
}
case "shape_0":
Disc(painter, f.P(.5f, .5f), f.S * .4f, color);
break;
case "shape_1":
Fill(painter, f, new[] { V(.5f, .06f), V(.94f, .5f), V(.5f, .94f), V(.06f, .5f) }, color);
break;
case "shape_2":
Fill(painter, f, Circle(6, new Vector2(.5f, .5f), .44f, -Mathf.PI * .5f).ToArray(), color);
break;
case "tank":
case "armor":
{
var shield = new List<Vector2> { V(.2f, .16f), V(.8f, .16f), V(.8f, .48f), V(.72f, .66f), V(.5f, .87f), V(.28f, .66f), V(.2f, .48f) };
if (glyph == "tank")
{
Fill(painter, f, shield.ToArray(), color);
Line(painter, f, new[] { V(.5f, .24f), V(.5f, .74f) }, dark, .07f);
}
else
{
shield.Add(shield[0]);
Line(painter, f, shield.ToArray(), color, .09f);
}
break;
}
case "melee":
case "sword":
{
Vector2[] blade = { V(.5f, .06f), V(.56f, .14f), V(.56f, .62f), V(.44f, .62f), V(.44f, .14f) };
Fill(painter, f, Rot(blade, Mathf.PI * .25f), color);
Fill(painter, f, Rot(new[] { V(.3f, .62f), V(.7f, .62f), V(.7f, .69f), V(.3f, .69f) }, Mathf.PI * .25f), color);
Fill(painter, f, Rot(new[] { V(.46f, .69f), V(.54f, .69f), V(.54f, .88f), V(.46f, .88f) }, Mathf.PI * .25f), color);
break;
}
case "ranged":
{
var bow = new Vector2[13];
for (int index = 0; index < 13; index++)
{
float angle = Mathf.PI * .62f + Mathf.PI * .76f * index / 12f;
bow[index] = new Vector2(.78f, .5f) + Dir(angle) * .5f;
}
Line(painter, f, Rot(bow, -Mathf.PI * .25f), color, .08f);
Line(painter, f, Rot(new[] { bow[0], bow[12] }, -Mathf.PI * .25f), color, .03f);
Line(painter, f, Rot(new[] { V(.14f, .5f), V(.82f, .5f) }, -Mathf.PI * .25f), color, .06f);
Fill(painter, f, Rot(new[] { V(.9f, .5f), V(.76f, .41f), V(.76f, .59f) }, -Mathf.PI * .25f), color);
break;
}
case "splash":
Fill(painter, f, Star(8, .46f, .2f), color);
Disc(painter, f.P(.5f, .5f), f.S * .1f, dark);
break;
case "support":
Fill(painter, f, new[] { V(.4f, .14f), V(.6f, .14f), V(.6f, .4f), V(.86f, .4f), V(.86f, .6f), V(.6f, .6f),
V(.6f, .86f), V(.4f, .86f), V(.4f, .6f), V(.14f, .6f), V(.14f, .4f), V(.4f, .4f) }, color);
break;
case "siege":
Fill(painter, f, Rot(new[] { V(.18f, .18f), V(.74f, .18f), V(.74f, .42f), V(.18f, .42f) }, -Mathf.PI * .2f), color);
Fill(painter, f, Rot(new[] { V(.42f, .4f), V(.52f, .4f), V(.52f, .92f), V(.42f, .92f) }, -Mathf.PI * .2f), color);
break;
case "crown":
case "hero":
Fill(painter, f, new[] { V(.12f, .74f), V(.12f, .3f), V(.32f, .5f), V(.5f, .2f), V(.68f, .5f), V(.88f, .3f), V(.88f, .74f) }, color);
Fill(painter, f, new[] { V(.12f, .78f), V(.88f, .78f), V(.88f, .88f), V(.12f, .88f) }, color);
if (glyph == "crown")
Disc(painter, f.P(.5f, .6f), f.S * .06f, dark);
break;
case "skull":
Fill(painter, f, Circle(20, new Vector2(.5f, .44f), .34f).ToArray(), color);
Fill(painter, f, new[] { V(.32f, .6f), V(.68f, .6f), V(.64f, .86f), V(.36f, .86f) }, color);
Disc(painter, f.P(.37f, .45f), f.S * .09f, dark);
Disc(painter, f.P(.63f, .45f), f.S * .09f, dark);
break;
case "champion":
Fill(painter, f, new[] { V(.5f, .06f), V(.82f, .46f), V(.61f, .46f), V(.61f, .9f), V(.39f, .9f), V(.39f, .46f), V(.18f, .46f) }, color);
break;
case "hourglass":
Fill(painter, f, new[] { V(.24f, .12f), V(.76f, .12f), V(.56f, .5f), V(.44f, .5f) }, color);
Fill(painter, f, new[] { V(.44f, .5f), V(.56f, .5f), V(.76f, .88f), V(.24f, .88f) }, color);
Line(painter, f, new[] { V(.18f, .1f), V(.82f, .1f) }, color, .06f);
Line(painter, f, new[] { V(.18f, .9f), V(.82f, .9f) }, color, .06f);
break;
case "tower":
Fill(painter, f, new[] { V(.28f, .92f), V(.28f, .36f), V(.22f, .36f), V(.22f, .14f), V(.33f, .14f), V(.33f, .23f),
V(.44f, .23f), V(.44f, .14f), V(.56f, .14f), V(.56f, .23f), V(.67f, .23f), V(.67f, .14f),
V(.78f, .14f), V(.78f, .36f), V(.72f, .36f), V(.72f, .92f) }, color);
Fill(painter, f, new[] { V(.44f, .92f), V(.44f, .72f), V(.5f, .66f), V(.56f, .72f), V(.56f, .92f) }, dark);
break;
case "ancient":
Fill(painter, f, new[] { V(.5f, .05f), V(.76f, .4f), V(.6f, .95f), V(.4f, .95f), V(.24f, .4f) }, color);
Line(painter, f, new[] { V(.5f, .05f), V(.5f, .95f) }, dark, .04f);
Line(painter, f, new[] { V(.24f, .4f), V(.5f, .52f), V(.76f, .4f) }, dark, .04f);
break;
case "warning":
Fill(painter, f, new[] { V(.5f, .08f), V(.94f, .88f), V(.06f, .88f) }, color);
Fill(painter, f, new[] { V(.46f, .34f), V(.54f, .34f), V(.53f, .64f), V(.47f, .64f) }, dark);
Disc(painter, f.P(.5f, .75f), f.S * .05f, dark);
break;
case "check":
Line(painter, f, new[] { V(.16f, .52f), V(.4f, .76f), V(.86f, .24f) }, color, .13f);
break;
case "close":
Line(painter, f, new[] { V(.2f, .2f), V(.8f, .8f) }, color, .12f);
Line(painter, f, new[] { V(.8f, .2f), V(.2f, .8f) }, color, .12f);
break;
case "bot":
case "gear":
{
Vector2 c = f.P(.5f, .5f);
for (int index = 0; index < 8; index++)
Fill(painter, f, Rot(new[] { V(.43f, .06f), V(.57f, .06f), V(.57f, .24f), V(.43f, .24f) }, Tau * index / 8f), color);
Disc(painter, c, f.S * .3f, color);
Disc(painter, c, f.S * .12f, dark);
break;
}
case "human":
Disc(painter, f.P(.5f, .3f), f.S * .17f, color);
Fill(painter, f, new[] { V(.2f, .9f), V(.24f, .62f), V(.38f, .52f), V(.62f, .52f), V(.76f, .62f), V(.8f, .9f) }, color);
break;
case "offline":
ArcLine(painter, f.P(.5f, .5f), f.S * .38f, 0f, Tau, color, Mathf.Max(1f, f.S * .1f));
Line(painter, f, new[] { V(.22f, .78f), V(.78f, .22f) }, color, .1f);
break;
case "clock":
ArcLine(painter, f.P(.5f, .5f), f.S * .4f, 0f, Tau, color, Mathf.Max(1f, f.S * .09f));
Line(painter, f, new[] { V(.5f, .24f), V(.5f, .5f), V(.68f, .62f) }, color, .08f);
break;
case "heart":
Fill(painter, f, Heart(), color);
break;
case "tempo":
Fill(painter, f, new[] { V(.6f, .04f), V(.22f, .56f), V(.46f, .56f), V(.38f, .96f), V(.78f, .42f), V(.54f, .42f), V(.66f, .04f) }, color);
break;
case "range":
{
Vector2 c = f.P(.5f, .5f);
ArcLine(painter, c, f.S * .38f, 0f, Tau, color, Mathf.Max(1f, f.S * .08f));
ArcLine(painter, c, f.S * .18f, 0f, Tau, color, Mathf.Max(1f, f.S * .08f));
Disc(painter, c, f.S * .06f, color);
Line(painter, f, new[] { V(.5f, .02f), V(.5f, .16f) }, color, .07f);
Line(painter, f, new[] { V(.5f, .84f), V(.5f, .98f) }, color, .07f);
Line(painter, f, new[] { V(.02f, .5f), V(.16f, .5f) }, color, .07f);
Line(painter, f, new[] { V(.84f, .5f), V(.98f, .5f) }, color, .07f);
break;
}
case "move":
Line(painter, f, new[] { V(.18f, .2f), V(.46f, .5f), V(.18f, .8f) }, color, .11f);
Line(painter, f, new[] { V(.52f, .2f), V(.8f, .5f), V(.52f, .8f) }, color, .11f);
break;
case "star":
Fill(painter, f, Star(5, .47f, .2f), color);
break;
case "lock":
Fill(painter, f, new[] { V(.22f, .46f), V(.78f, .46f), V(.78f, .9f), V(.22f, .9f) }, color);
ArcLine(painter, f.P(.5f, .46f), f.S * .19f, Mathf.PI, Tau, color, Mathf.Max(1f, f.S * .09f));
Disc(painter, f.P(.5f, .64f), f.S * .06f, dark);
break;
case "up":
Fill(painter, f, new[] { V(.5f, .08f), V(.86f, .48f), V(.62f, .48f), V(.62f, .9f), V(.38f, .9f), V(.38f, .48f), V(.14f, .48f) }, color);
break;
case "lane_top":
case "lane_mid":
case "lane_bot":
{
Line(painter, f, new[] { V(.12f, .12f), V(.88f, .12f), V(.88f, .88f), V(.12f, .88f), V(.12f, .12f) }, LuneUiTheme.WithAlpha(color, color.a * .3f), .05f);
Vector2[] path = glyph == "lane_top" ? new[] { V(.2f, .8f), V(.2f, .2f), V(.8f, .2f) }
: glyph == "lane_mid" ? new[] { V(.2f, .8f), V(.8f, .2f) } : new[] { V(.2f, .8f), V(.8f, .8f), V(.8f, .2f) };
Line(painter, f, path, color, .12f);
break;
}
case "grotto":
{
var arch = new List<Vector2> { V(.08f, .9f) };
for (int index = 0; index < 13; index++)
{
float angle = Mathf.PI + Mathf.PI * index / 12f;
arch.Add(new Vector2(.5f, .62f) + new Vector2(Mathf.Cos(angle) * .42f, Mathf.Sin(angle) * .5f));
}
arch.Add(V(.92f, .9f));
Fill(painter, f, arch.ToArray(), color);
var mouth = new List<Vector2> { V(.3f, .9f) };
for (int index = 0; index < 9; index++)
{
float angle = Mathf.PI + Mathf.PI * index / 8f;
mouth.Add(new Vector2(.5f, .72f) + new Vector2(Mathf.Cos(angle) * .2f, Mathf.Sin(angle) * .26f));
}
mouth.Add(V(.7f, .9f));
Fill(painter, f, mouth.ToArray(), dark);
break;
}
case "pause":
Fill(painter, f, new[] { V(.24f, .16f), V(.42f, .16f), V(.42f, .84f), V(.24f, .84f) }, color);
Fill(painter, f, new[] { V(.58f, .16f), V(.76f, .16f), V(.76f, .84f), V(.58f, .84f) }, color);
break;
case "play":
Fill(painter, f, new[] { V(.26f, .14f), V(.84f, .5f), V(.26f, .86f) }, color);
break;
case "menu":
foreach (float y in new[] { .24f, .5f, .76f })
Line(painter, f, new[] { V(.16f, y), V(.84f, y) }, color, .1f);
break;
case "basaltschild":
{
Fill(painter, f, Circle(6, new Vector2(.5f, .5f), .44f, -Mathf.PI * .5f).ToArray(), color);
List<Vector2> inner = Circle(6, new Vector2(.5f, .5f), .27f, -Mathf.PI * .5f);
inner.Add(inner[0]);
Line(painter, f, inner.ToArray(), dark, .07f);
break;
}
case "glutklinge":
{
Vector2[] blade = { V(.5f, .06f), V(.57f, .15f), V(.57f, .66f), V(.43f, .66f), V(.43f, .15f) };
Fill(painter, f, Rot(blade, Mathf.PI * .25f), color);
Fill(painter, f, Rot(new[] { V(.3f, .66f), V(.7f, .66f), V(.7f, .73f), V(.3f, .73f) }, Mathf.PI * .25f), color);
Fill(painter, f, new[] { V(.2f, .5f), V(.3f, .3f), V(.36f, .42f), V(.42f, .22f), V(.5f, .44f), V(.4f, .6f) }, accent.a > 0f ? dark : LuneUiTheme.Hex("ffb070"));
break;
}
case "rankenmantel":
Fill(painter, f, new[] { V(.5f, .1f), V(.66f, .18f), V(.8f, .9f), V(.5f, .8f), V(.2f, .9f), V(.34f, .18f) }, color);
Line(painter, f, new[] { V(.5f, .22f), V(.42f, .4f), V(.56f, .56f), V(.46f, .74f) }, dark, .05f);
break;
case "funkenfokus":
ArcLine(painter, f.P(.5f, .5f), f.S * .42f, 0f, Tau, color, Mathf.Max(1f, f.S * .08f));
Fill(painter, f, new[] { V(.56f, .16f), V(.3f, .54f), V(.47f, .54f), V(.42f, .84f), V(.7f, .44f), V(.53f, .44f), V(.62f, .16f) }, color);
break;
case "quellamulett":
ArcLine(painter, f.P(.5f, .2f), f.S * .14f, Mathf.PI * .9f, Mathf.PI * 2.1f, color, Mathf.Max(1f, f.S * .06f));
Fill(painter, f, Drop(), color);
Disc(painter, f.P(.44f, .66f), f.S * .06f, new Color(1f, 1f, 1f, .45f * color.a));
break;
case "kristallsplitter":
Shard(painter, f, V(.5f, .5f), 0f, .44f, color);
Shard(painter, f, V(.3f, .62f), -.55f, .3f, color);
Shard(painter, f, V(.7f, .62f), .55f, .3f, color);
break;
default:
Disc(painter, f.P(.5f, .5f), f.S * .3f, color);
break;
}
}
static void Shard(Painter2D painter, Frame f, Vector2 center, float angle, float length, Color color)
{
Vector2[] shard = { center + new Vector2(0f, -length), center + new Vector2(.08f, 0f), center + new Vector2(0f, length * .45f), center + new Vector2(-.08f, 0f) };
Fill(painter, f, Rot(shard, angle, center), color);
}
/// <summary>The unit square fitted into a rect (centred, aspect kept).</summary>
struct Frame
{
public readonly float S;
public readonly Vector2 Origin;
public Frame(Rect rect)
{
S = Mathf.Min(rect.width, rect.height);
Origin = rect.position + (rect.size - new Vector2(S, S)) * .5f;
}
public Vector2 P(float x, float y) { return Origin + new Vector2(x, y) * S; }
public Vector2 P(Vector2 p) { return Origin + p * S; }
}
static Vector2 V(float x, float y) { return new Vector2(x, y); }
static Vector2 Dir(float angle) { return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)); }
static Vector2[] Rot(Vector2[] points, float angle) { return Rot(points, angle, new Vector2(.5f, .5f)); }
static Vector2[] Rot(Vector2[] points, float angle, Vector2 pivot)
{
var result = new Vector2[points.Length];
float c = Mathf.Cos(angle);
float s = Mathf.Sin(angle);
for (int index = 0; index < points.Length; index++)
{
Vector2 d = points[index] - pivot;
result[index] = pivot + new Vector2(d.x * c - d.y * s, d.x * s + d.y * c);
}
return result;
}
static void Fill(Painter2D painter, Frame f, Vector2[] points, Color color)
{
if (points.Length < 3)
return;
painter.fillColor = color;
painter.BeginPath();
painter.MoveTo(f.P(points[0]));
for (int index = 1; index < points.Length; index++)
painter.LineTo(f.P(points[index]));
painter.ClosePath();
painter.Fill(FillRule.NonZero);
}
static void Line(Painter2D painter, Frame f, Vector2[] points, Color color, float width)
{
painter.strokeColor = color;
painter.lineWidth = Mathf.Max(1f, width * f.S);
painter.lineJoin = LineJoin.Miter;
painter.lineCap = LineCap.Butt;
painter.BeginPath();
painter.MoveTo(f.P(points[0]));
for (int index = 1; index < points.Length; index++)
painter.LineTo(f.P(points[index]));
painter.Stroke();
}
static void Disc(Painter2D painter, Vector2 center, float radius, Color color)
{
painter.fillColor = color;
painter.BeginPath();
painter.Arc(center, radius, Angle.Degrees(0f), Angle.Degrees(360f));
painter.ClosePath();
painter.Fill();
}
/// <summary>Godot draw_arc: start to end angle (radians, clockwise on screen).</summary>
static void ArcLine(Painter2D painter, Vector2 center, float radius, float start, float end, Color color, float width)
{
painter.strokeColor = color;
painter.lineWidth = width;
painter.lineCap = LineCap.Butt;
painter.BeginPath();
painter.Arc(center, radius, Angle.Radians(start), Angle.Radians(end));
if (end - start >= Tau - .001f)
painter.ClosePath();
painter.Stroke();
}
static List<Vector2> Circle(int points, Vector2 center, float radius, float start = 0f)
{
var result = new List<Vector2>(points);
for (int index = 0; index < points; index++)
result.Add(center + Dir(start + Tau * index / points) * radius);
return result;
}
static Vector2[] Star(int points, float outer, float inner, float start = -Mathf.PI * .5f)
{
var result = new Vector2[points * 2];
for (int index = 0; index < points * 2; index++)
result[index] = new Vector2(.5f, .5f) + Dir(start + Mathf.PI * index / points) * (index % 2 == 0 ? outer : inner);
return result;
}
/// <summary>Crescent (Blau): the outer disc minus an offset disc, as Geometry2D.clip_polygons gave in Godot.</summary>
static Vector2[] Crescent()
{
if (Cache.TryGetValue("crescent", out Vector2[] cached))
return cached;
var outerCenter = new Vector2(.5f, .5f);
var innerCenter = new Vector2(.66f, .4f);
const float outerRadius = .42f;
const float innerRadius = .33f;
List<Vector2> outer = Circle(48, outerCenter, outerRadius);
List<Vector2> inner = Circle(48, innerCenter, innerRadius);
// The outer arc outside the inner disc, then the inner arc inside the outer disc, backwards.
int startOuter = 0;
while (!(Inside(outer[startOuter], innerCenter, innerRadius) && !Inside(outer[(startOuter + 1) % 48], innerCenter, innerRadius)))
startOuter++;
var result = new List<Vector2>();
for (int step = 1; step <= 48; step++)
{
Vector2 point = outer[(startOuter + step) % 48];
if (Inside(point, innerCenter, innerRadius))
break;
result.Add(point);
}
int startInner = 0;
while (!(Inside(inner[startInner], outerCenter, outerRadius) && !Inside(inner[(startInner + 1) % 48], outerCenter, outerRadius)))
startInner++;
for (int step = 0; step < 48; step++)
{
Vector2 point = inner[((startInner - step) % 48 + 48) % 48];
if (!Inside(point, outerCenter, outerRadius))
break;
result.Add(point);
}
cached = result.ToArray();
Cache["crescent"] = cached;
return cached;
}
static bool Inside(Vector2 point, Vector2 center, float radius)
{
return (point - center).sqrMagnitude < radius * radius;
}
static Vector2[] Heart()
{
if (Cache.TryGetValue("heart", out Vector2[] cached))
return cached;
cached = new Vector2[40];
for (int index = 0; index < 40; index++)
{
float t = Tau * index / 40f;
float x = 16f * Mathf.Pow(Mathf.Sin(t), 3);
float y = 13f * Mathf.Cos(t) - 5f * Mathf.Cos(2f * t) - 2f * Mathf.Cos(3f * t) - Mathf.Cos(4f * t);
cached[index] = new Vector2(.5f + x / 38f, .47f - y / 38f);
}
Cache["heart"] = cached;
return cached;
}
static Vector2[] Drop()
{
var result = new Vector2[18];
result[0] = new Vector2(.5f, .16f);
for (int index = 0; index < 17; index++)
{
float angle = -Mathf.PI * .15f + Mathf.PI * 1.3f * index / 16f;
result[1 + index] = new Vector2(.5f, .64f) + Dir(angle) * .22f;
}
return result;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2c6054b8320f4bd498dc306758262e2

View file

@ -0,0 +1,198 @@
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// A vector-drawn plate (port of scripts/ui/theme/forged_style.gd): chamfered corners, a
/// vertical fill gradient, an edge line, an inset line, an optional top highlight and a soft
/// shadow. Drawn from the element's size, so it stays crisp at every UI scale.
/// </summary>
public sealed class LunePlateStyle
{
public Color FillTop = LuneUiTheme.Hex("1b2027");
public Color FillBottom = LuneUiTheme.Hex("12161b");
public Color Edge = LuneUiTheme.Hex("8a6a3c");
public float EdgeWidth = 1.5f;
public Color Inner = new Color(1f, 1f, 1f, .05f);
public float InnerInset = 4f;
public float Chamfer = 7f;
public Color Highlight = Color.clear;
public Color Shadow = Color.clear;
public float ShadowSize;
/// <summary>Grows the drawing outside the element (focus rings).</summary>
public float Grow;
public bool DrawFill = true;
/// <summary>Content margins (Godot content_margin_*), applied as padding.</summary>
public float PadX = 12f;
public float PadY = 12f;
/// <summary>SaumwachtTheme.plate (inner .045) plus LuneUiTheme.plate (inner .035, inset 3, margins).</summary>
public static LunePlateStyle Hud(Color top, Color bottom, Color edge, float edgeWidth = 1f, float cut = 6f, float padX = 12f, float padY = 10f)
{
return new LunePlateStyle { FillTop = top, FillBottom = bottom, Edge = edge, EdgeWidth = edgeWidth, Chamfer = cut, Inner = new Color(1f, 1f, 1f, .035f), InnerInset = 3f, PadX = padX, PadY = padY };
}
/// <summary>SaumwachtTheme.plate with margins: menu plates (inner .045, inset 4).</summary>
public static LunePlateStyle Menu(Color top, Color bottom, Color edge, float edgeWidth = 1.5f, float cut = 7f, float padX = 12f, float padY = 12f)
{
return new LunePlateStyle { FillTop = top, FillBottom = bottom, Edge = edge, EdgeWidth = edgeWidth, Chamfer = cut, Inner = new Color(1f, 1f, 1f, .045f), InnerInset = 4f, PadX = padX, PadY = padY };
}
/// <summary>Keyboard focus ring outside the element (SaumwachtTheme.focus_ring).</summary>
public static LunePlateStyle FocusRing(float cut = 7f)
{
return new LunePlateStyle { DrawFill = false, Edge = LuneUiTheme.Focus, EdgeWidth = 2.5f, Inner = LuneUiTheme.WithAlpha(LuneUiTheme.Focus, .55f), InnerInset = 4.5f,
Chamfer = cut + 4f, Grow = 4f, Shadow = LuneUiTheme.WithAlpha(LuneUiTheme.Focus, .22f), ShadowSize = 5f, PadX = 0f, PadY = 0f };
}
public void ApplyPadding(VisualElement element)
{
element.style.paddingLeft = PadX;
element.style.paddingRight = PadX;
element.style.paddingTop = PadY;
element.style.paddingBottom = PadY;
}
public LunePlateStyle WithShadow(Color shadow, float size)
{
var copy = (LunePlateStyle)MemberwiseClone();
copy.Shadow = shadow;
copy.ShadowSize = size;
return copy;
}
public LunePlateStyle WithInner(Color inner)
{
var copy = (LunePlateStyle)MemberwiseClone();
copy.Inner = inner;
return copy;
}
}
public static class LunePlates
{
static readonly Vector2[] Points = new Vector2[8];
/// <summary>The eight corners of a chamfered rectangle, clockwise from the top left cut.</summary>
public static Vector2[] PlatePoints(Rect rect, float cut, Vector2[] into = null)
{
Vector2[] result = into ?? new Vector2[8];
float c = Mathf.Min(cut, Mathf.Min(rect.width, rect.height) * .5f);
Vector2 p = rect.min;
Vector2 e = rect.max;
result[0] = new Vector2(p.x + c, p.y);
result[1] = new Vector2(e.x - c, p.y);
result[2] = new Vector2(e.x, p.y + c);
result[3] = new Vector2(e.x, e.y - c);
result[4] = new Vector2(e.x - c, e.y);
result[5] = new Vector2(p.x + c, e.y);
result[6] = new Vector2(p.x, e.y - c);
result[7] = new Vector2(p.x, p.y + c);
return result;
}
static Rect Grow(Rect rect, float amount)
{
return new Rect(rect.x - amount, rect.y - amount, rect.width + amount * 2f, rect.height + amount * 2f);
}
public static void Draw(MeshGenerationContext context, Rect rect, LunePlateStyle style)
{
Rect r = Grow(rect, style.Grow);
if (r.width < 2f || r.height < 2f)
return;
Painter2D painter = context.painter2D;
if (style.Shadow.a > 0f && style.ShadowSize > 0f)
{
for (int layer = 0; layer < 3; layer++)
{
float spread = style.ShadowSize * (layer + 1) / 3f;
var soft = new Color(style.Shadow.r, style.Shadow.g, style.Shadow.b, style.Shadow.a / 3f);
if (style.DrawFill)
{
Rect offset = new Rect(r.x, r.y + spread * .5f, r.width, r.height);
Polygon(painter, PlatePoints(Grow(offset, spread), style.Chamfer + spread, Points), soft);
}
else
{
// Rings only glow outwards; the element underneath stays untinted.
Outline(painter, PlatePoints(Grow(r, spread), style.Chamfer + spread, Points), soft, style.ShadowSize / 3f + 1f);
}
}
}
if (style.DrawFill)
Gradient(context, PlatePoints(r, style.Chamfer, Points), r, style.FillTop, style.FillBottom);
if (style.Highlight.a > 0f)
{
float y = r.y + style.EdgeWidth + 1f;
Line(painter, new Vector2(r.x + style.Chamfer, y), new Vector2(r.xMax - style.Chamfer, y), style.Highlight, 1f);
}
if (style.Inner.a > 0f && style.InnerInset > 0f)
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)
Outline(painter, PlatePoints(Grow(r, -style.EdgeWidth * .5f), style.Chamfer, Points), style.Edge, style.EdgeWidth);
}
/// <summary>Convex polygon with a vertical colour gradient (Godot: polygon with per-vertex colours).</summary>
public static void Gradient(MeshGenerationContext context, Vector2[] points, Rect bounds, Color top, Color bottom)
{
int count = points.Length;
MeshWriteData mesh = context.Allocate(count + 1, count * 3);
Vector2 center = Vector2.zero;
foreach (Vector2 point in points)
center += point;
center /= count;
mesh.SetNextVertex(new Vertex { position = new Vector3(center.x, center.y, Vertex.nearZ), tint = Color.Lerp(top, bottom, Fraction(center.y, bounds)) });
foreach (Vector2 point in points)
mesh.SetNextVertex(new Vertex { position = new Vector3(point.x, point.y, Vertex.nearZ), tint = Color.Lerp(top, bottom, Fraction(point.y, bounds)) });
// Clockwise triangles in screen space (y down).
for (int index = 0; index < count; index++)
{
mesh.SetNextIndex(0);
mesh.SetNextIndex((ushort)(1 + index));
mesh.SetNextIndex((ushort)(1 + (index + 1) % count));
}
}
static float Fraction(float y, Rect bounds)
{
return Mathf.Clamp01((y - bounds.y) / Mathf.Max(1f, bounds.height));
}
public static void Polygon(Painter2D painter, Vector2[] points, Color color)
{
painter.fillColor = color;
painter.BeginPath();
painter.MoveTo(points[0]);
for (int index = 1; index < points.Length; index++)
painter.LineTo(points[index]);
painter.ClosePath();
painter.Fill();
}
public static void Outline(Painter2D painter, Vector2[] points, Color color, float width)
{
painter.strokeColor = color;
painter.lineWidth = width;
painter.lineJoin = LineJoin.Miter;
painter.BeginPath();
painter.MoveTo(points[0]);
for (int index = 1; index < points.Length; index++)
painter.LineTo(points[index]);
painter.ClosePath();
painter.Stroke();
}
public static void Line(Painter2D painter, Vector2 from, Vector2 to, Color color, float width)
{
painter.strokeColor = color;
painter.lineWidth = width;
painter.lineCap = LineCap.Butt;
painter.BeginPath();
painter.MoveTo(from);
painter.LineTo(to);
painter.Stroke();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c1d1aab2221b4145b24e47b3401e048

View file

@ -0,0 +1,107 @@
using System;
using System.Globalization;
using UnityEngine;
namespace Luneraria.UI
{
/// <summary>
/// Colours and number formats of the Champions of Luneraria HUD (port of
/// scripts/luneraria/ui/theme/lune_ui_theme.gd and the colours of the Saumwacht menu theme it
/// extends): dark blue-anthracite plates, bronze and gold only on what the player can act on,
/// quiet team accents. Colours are sRGB, as in Godot.
/// </summary>
public static class LuneUiTheme
{
public static readonly Color SlateTop = new Color(.075f, .1f, .135f, .9f);
public static readonly Color SlateBottom = new Color(.05f, .068f, .094f, .93f);
public static readonly Color Edge = Hex("2b3848");
public static readonly Color EdgeLight = Hex("455870");
public static readonly Color Text = Hex("ebe5d8");
public static readonly Color Bright = Hex("f6eedc");
public static readonly Color Muted = Hex("9aa4b0");
public static readonly Color Faint = Hex("6a7480");
public static readonly Color Gold = Hex("f2c96b");
// Saumwacht menu theme (saumwacht_theme.gd).
public static readonly Color Bronze = Hex("b7874a");
public static readonly Color BronzeLight = Hex("e4bd7c");
public static readonly Color BronzeDeep = Hex("6b4f2e");
public static readonly Color Ink = Hex("1a120a");
public static readonly Color Focus = Hex("f6e2b0");
public static readonly Color Parchment = Hex("efe6d2");
/// <summary>Team accents: Blau (cool, crescent moon), Rot (warm, sun disc), neutral grotto (violet).</summary>
public static readonly Color[] Team = { Hex("6cb4ff"), Hex("ff7b5e"), Hex("b78cff") };
public static readonly Color[] TeamDark = { Hex("1c3f6a"), Hex("722a1f"), Hex("45305f") };
public static readonly string[] TeamNames = { "Blau", "Rot", "Grotte" };
public static readonly string[] TeamEmblems = { "Mondsichel", "Sonnenscheibe", "Grotte" };
/// <summary>Threat levels RUHIG, UMKÄMPFT, UNTER DRUCK, KRITISCH.</summary>
public static readonly Color[] Threat = { Hex("7cc9a6"), Hex("e5c35e"), Hex("ff9448"), Hex("ff584a") };
public static readonly Color Champion = Hex("e2a15e");
public static readonly Color Hero = Hex("f2c96b");
public static readonly Color Good = Hex("8fdcb4");
public static readonly Color Warn = Hex("f0c060");
public static readonly Color Danger = Hex("ff6a55");
public static readonly Color Info = Hex("a9c6e8");
/// <summary>Lower bound for any HUD text: 14 logical px stay above 10 px at 1280×720.</summary>
public const int MinFont = 14;
public static Color Hex(string hex)
{
if (!ColorUtility.TryParseHtmlString("#" + hex, out Color color))
throw new ArgumentException("Bad colour " + hex);
return color;
}
/// <summary>Godot Color.darkened: rgb times (1 - amount), alpha kept.</summary>
public static Color Darkened(Color color, float amount)
{
return new Color(color.r * (1f - amount), color.g * (1f - amount), color.b * (1f - amount), color.a);
}
/// <summary>Godot Color.lightened: rgb towards white by amount, alpha kept.</summary>
public static Color Lightened(Color color, float amount)
{
return new Color(color.r + (1f - color.r) * amount, color.g + (1f - color.g) * amount, color.b + (1f - color.b) * amount, color.a);
}
public static Color WithAlpha(Color color, float alpha)
{
return new Color(color.r, color.g, color.b, alpha);
}
public static Color TeamColor(int team)
{
return Team[Mathf.Clamp(team, 0, 2)];
}
public static string TeamName(int team)
{
return TeamNames[Mathf.Clamp(team, 0, 2)];
}
/// <summary>Whole numbers with German thousands separators (12.000), rounded like Godot (half away from zero).</summary>
public static string Number(double value)
{
long whole = (long)Math.Round(value, MidpointRounding.AwayFromZero);
string digits = Math.Abs(whole).ToString(CultureInfo.InvariantCulture);
string result = "";
while (digits.Length > 3)
{
result = "." + digits.Substring(digits.Length - 3) + result;
digits = digits.Substring(0, digits.Length - 3);
}
return (whole < 0 ? "-" : "") + digits + result;
}
/// <summary>Fixed decimals with a decimal comma (Godot: "%.1f" % value with "." replaced).</summary>
public static string Decimal(double value, int places = 1)
{
return value.ToString("F" + places, CultureInfo.InvariantCulture).Replace(".", ",");
}
/// <summary>Godot round(): half away from zero.</summary>
public static int Round(double value)
{
return (int)Math.Round(value, MidpointRounding.AwayFromZero);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 10ef80825d4ba4b23904f45aa397b86e

View file

@ -4,18 +4,27 @@ using UnityEngine;
namespace Luneraria.View
{
/// <summary>
/// Hosts a bot match in the scene (U2C/U2D). Update only hands frame time (times Speed) to the
/// runner's fixed clock; it never decides anything in combat. Seed, start round and extra gold
/// are the public fixtures of the golden scenarios.
/// Hosts a match in the scene (U2C/U2D; human seat and tempo since U3). Update only hands frame
/// time (times the speed) to the runner's fixed clock; it never decides anything in combat.
/// Seed, start round and extra gold are the public fixtures of the golden scenarios. With
/// HumanSlot &gt;= 0 the HUD plans that seat and planning waits for its Ready; tempo steps
/// (1×, 2×, 4×) and pause act only in combat, as in Godot (lune_game.gd).
/// </summary>
public sealed class LuneMatchDriver : MonoBehaviour
{
/// <summary>Tempo steps of the HUD's tempo button.</summary>
public static readonly int[] SpeedSteps = { 1, 2, 4 };
public int Seed = 2;
public int StartRound = 1;
public int ExtraGold;
public string Profile = "";
/// <summary>Match speed: simulated seconds per real second.</summary>
/// <summary>The seat a human plays through the HUD; -1 for a bot match (U2 tests, benchmark).</summary>
public int HumanSlot = -1;
/// <summary>Match speed: simulated seconds per real second (tests run faster than 1).</summary>
public float Speed = 1f;
/// <summary>Index into SpeedSteps (the player's tempo, on top of Speed).</summary>
public int SpeedIndex;
public bool Paused;
public bool RecordTrace = true;
@ -24,11 +33,30 @@ namespace Luneraria.View
public double LastAdvanceMs { get; private set; }
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
public int Tempo { get { return SpeedSteps[Mathf.Clamp(SpeedIndex, 0, SpeedSteps.Length - 1)]; } }
public void Restart()
{
Runner = new LuneMatchRunner(new MatchOptions { Seed = Seed, HumanSlot = -1 }, StartRound, ExtraGold, Profile);
Runner = new LuneMatchRunner(new MatchOptions { Seed = Seed, HumanSlot = HumanSlot }, StartRound, ExtraGold, Profile);
if (RecordTrace)
Runner.Trace = new LuneTrace();
SpeedIndex = 0;
Paused = false;
}
/// <summary>Next tempo step (1× → 2× → 4× → 1×).</summary>
public void CycleSpeed()
{
SpeedIndex = (SpeedIndex + 1) % SpeedSteps.Length;
}
/// <summary>Pause exists only in combat; planning waits for the player anyway.</summary>
public bool TogglePause()
{
if (Runner == null || Runner.Current != LuneMatchRunner.Stage.Combat)
return false;
Paused = !Paused;
return true;
}
void Awake()
@ -40,8 +68,9 @@ namespace Luneraria.View
void Update()
{
_clock.Restart();
if (!Paused)
Runner.Advance(Time.deltaTime * Speed);
// Pause holds only the combat steps (Godot: a paused game still plans and settles).
if (!(Paused && Runner.Current == LuneMatchRunner.Stage.Combat))
Runner.Advance(Time.deltaTime * Speed * Tempo);
LastAdvanceMs = _clock.Elapsed.TotalMilliseconds;
}
}

View file

@ -4,10 +4,13 @@ using Luneraria.Core;
namespace Luneraria.View
{
/// <summary>
/// Runs a bot match on the host (U2C/U2D) against wall-clock time: planning (the six bots plan
/// through the command interface), combat in fixed steps, a pause after the settlement, the
/// next round, until an Ancient falls. Only the core decides; time only paces the calls, so
/// the sequence of core calls is the one of LuneTrace.RunCore at every frame rate.
/// Runs a match on the host (U2C/U2D, human seat since U3) against wall-clock time: planning
/// (the bots plan through the command interface), combat in fixed steps, a pause after the
/// settlement, the next round, until an Ancient falls. Only the core decides; time only paces
/// the calls, so a bot match calls the core in the order of LuneTrace.RunCore at every frame
/// rate. With a human seat (HumanSlot &gt;= 0) the bots plan the other five seats and planning
/// waits for the human's Ready, as offline in Godot (lune_game.gd); there is no pause after
/// the settlement.
/// </summary>
public sealed class LuneMatchRunner
{
@ -16,6 +19,8 @@ namespace Luneraria.View
public readonly LuneMatch Match;
public readonly LuneClock Clock;
public readonly string Profile;
/// <summary>The seat a human plans through the HUD; -1 for a bot match.</summary>
public readonly int HumanSlot;
public LuneTrace Trace;
public double PlanningSeconds = 1.0;
public double SettledSeconds = 2.0;
@ -34,6 +39,9 @@ namespace Luneraria.View
Match = NewMatch(options, startRound, extraGold);
Clock = new LuneClock(Match.Balance.SimStep);
Profile = profile;
HumanSlot = options.HumanSlot >= 0 && options.HumanSlot < 6 ? options.HumanSlot : -1;
if (HumanSlot >= 0)
SettledSeconds = 0.0;
EnterPlanning();
}
@ -55,8 +63,11 @@ namespace Luneraria.View
Current = Stage.Planning;
_stageTime = 0.0;
for (int slot = 0; slot < 6; slot++)
{
if (slot != HumanSlot)
LuneBot.Plan(Match, slot, Profile);
}
}
/// <summary>Advances by simulated seconds (frame time times match speed).</summary>
public void Advance(double seconds)
@ -66,13 +77,9 @@ namespace Luneraria.View
{
case Stage.Planning:
_stageTime += seconds;
if (_stageTime >= PlanningSeconds)
{
Match.BeginCombat();
Clock.Reset();
Current = Stage.Combat;
CombatStarted?.Invoke(Match.Combat);
}
// A bot match starts after a short look at the plans; a human starts it with Ready.
if (HumanSlot < 0 ? _stageTime >= PlanningSeconds : Match.AllReady())
StartCombat();
break;
case Stage.Combat:
StepsThisFrame = Clock.Advance(seconds, StepOnce);
@ -87,6 +94,28 @@ namespace Luneraria.View
}
}
/// <summary>
/// The human's Ready (Godot _on_ready): the plan becomes binding; once every seat is ready
/// the combat starts at once.
/// </summary>
public bool Ready()
{
if (Current != Stage.Planning || HumanSlot < 0 || Match.Phase != Phase.Planning)
return false;
Match.SetReady(HumanSlot);
if (Match.AllReady())
StartCombat();
return true;
}
void StartCombat()
{
Match.BeginCombat();
Clock.Reset();
Current = Stage.Combat;
CombatStarted?.Invoke(Match.Combat);
}
bool StepOnce()
{
Match.StepCombat();

View file

@ -1,6 +1,13 @@
# U3 · G3-HUD portieren
Stand: 30. September 2026 · **nicht begonnen**.
Stand: 1. Oktober 2026.
| Teil | Inhalt | Zustand |
| --- | --- | --- |
| 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 |
| U3C | Eingabe und Befehle: Tasten, Kamera-Ziele, Auswahl im Feld, Bereit-Sperre, Planungsvorschau | offen |
| U3D | Prüfung (Teile A–H von `verify_g3`), Spieltest über die echte Oberfläche, Bilder, Leistung | offen |
Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/luneraria/G3-HUD.md`, Commit `1b540f2`). Es wird übertragen, nicht neu erfunden.
@ -14,8 +21,78 @@ Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/lunerari
- Minimap, Bereit-Zustand, Tooltips, Ereignisse.
- Datenzugang über ein Sichtmodell mit denselben Privatsphäre-Regeln wie `LuneHudModel`.
## Offene Entscheidung
UI Toolkit oder uGUI: vor Beginn eine kurze technische Bewertung, dann **ein** System als Hauptlösung.
Die geplanten Helden (`PlannedHeroes`) mit Command-Menü für Ultimates sind im Layout vorzusehen.
Der Bestand in Godot umfasst rund 5.100 Zeilen:
- `lune_hud.gd` (1.058 Zeilen),
- `lune_hud_model.gd` (333),
- 22 Komponenten,
- Theme (`lune_ui_theme.gd`, `forged_style.gd`) und Vektorsymbole (`lune_glyphs.gd`),
- Minimap, Porträt-Cache, Optionen,
- die Verdrahtung in `lune_game.gd` (Befehle, Tasten, Kamera, Auswahl).
Das Hauptmenü (`lune_menu.gd`, mit Netzwerk-Host/Join) ist der Startbildschirm des Spiels und gehört nicht zum HUD.
## Entscheidung: UI Toolkit
Vor Beginn war eine kurze technische Bewertung gefordert, dann **ein** System als Hauptlösung. Gewählt ist **UI Toolkit**; uGUI kommt im HUD nicht vor.
| Kriterium | UI Toolkit | uGUI |
| --- | --- | --- |
| Aufbau wie in Godot (Komponenten im Code, Container) | `VisualElement` und Flexbox entsprechen Godots Box-Containern; Komponenten sind C#-Klassen | GameObjects mit RectTransform und Layout-Gruppen; jede Zeile ein Objekt, Layout-Gruppen teuer beim Neuaufbau |
| Eigenes Zeichnen (Godot `_draw`: Platten, 50 Symbole, Balken, Minimap) | `generateVisualContent` mit `Painter2D` (Pfade, Bögen, konkave Flächen) und eigenen Meshes (Verlauf) | eigene `Graphic`-Klassen; konkave Flächen müsste man selbst triangulieren |
| Skalierung wie Godot (`canvas_items`, `expand`, Basis 1600×960) | `PanelSettings`: Scale With Screen Size, Expand, 1600×960 ergibt bei 16:9 dieselbe logische Fläche 1706×960 | `CanvasScaler` gleichwertig |
| Prüfbarkeit im Batchmodus | Panel rendert in eine RenderTexture beliebiger Größe; Elemente per Name und Pick abfragbar | Canvas über eine Kamera rendern; Abfragen über GameObjects |
| Tooltips, Weltelemente | Laufzeit-Tooltip selbst bauen (Godot hatte ohnehin einen eigenen); Weltbalken sind schon Meshes | eingebaut nur im Editor; Weltraum-Canvas reif |
| Empfehlung in Unity 6 | empfohlenes System für neue Laufzeit-Oberflächen | weiter unterstützt, älterer Ansatz |
**Probe vor der Entscheidung** (`Tests/UI/PlayMode/UiToolkitProbeTests`, Bild `screenshots/u3/00-ui-toolkit-probe.png`):
- Ein Panel rendert im Batchmodus in eine 1920×1080-Textur.
- Die Position stimmt mit Godot überein: 12 logische Pixel ergeben 13,5 Pixel bei 1920×1080.
- Die Platten mit Fasen, Verlauf, Innen- und Kantenlinie entstehen korrekt.
- Alle 50 Symbole zeichnen sich, auch die konkaven (Mondsichel, Turm, Kreuz).
- Die Schriften kommen aus Godots variablen Fonts.
## U3A · Grundlagen
### Sichtmodell und Privatsphäre
Portiert nach `Assets/Luneraria/UI/Model/`, Verhalten wie in Godot:
| Datei | Inhalt |
| --- | --- |
| `LuneHudModel.cs` | einziger Datenzugang des HUD: Phase, eigener Sitz (Gold, Entwurf, Rückgängig, Buchungen), Gebäude, Ancients, Mitspieler, Lanes, Boss, nächste Welle, öffentlicher Digest |
| `LuneLaneThreat.cs` | Lane-Bedrohung RUHIG · UMKÄMPFT · UNTER DRUCK · KRITISCH, nur aus öffentlichem Zustand (Turm-HP, Positionen, Ankündigungen) |
| `LuneStats.cs` | Wertzeilen und Tooltip-Inhalte aus den echten Definitionen (Schaden je Treffer, Tempo, Reichweite, Rüstungsmatrix) |
Die Privatsphäre-Regeln gelten unverändert:
- Gold, Entwurf, Rückgängig und neue Buchungen gibt es nur für den eigenen Sitz.
- Von anderen sieht man Name, Lane, Bereitschaft, Mensch/Bot/offline und den bestätigten Helden.
- Lanes, Gebäude, Boss und Wellen kommen aus öffentlichem Zustand.
Eine Abweichung: Im öffentlichen Digest (Testhilfe) steht beim Boss die öffentliche Claim-Lane. Godot nahm dort auch die private Lane-Wahl des Wählenden auf, sodass der Digest für ihn vom Bild der anderen abweichen konnte.
### Menschlicher Sitz
- `LuneMatchRunner` und `LuneMatchDriver` haben einen menschlichen Sitz bekommen (`HumanSlot`).
- Die Bots planen nur die anderen fünf Sitze. Die Planung wartet auf „Bereit“ des Menschen, wie offline in Godot (`lune_game.gd`), und nach der Abrechnung beginnt sofort die nächste Planung.
- Tempo (1×, 2×, 4×) und Pause wirken nur im Kampf.
- Bot-Partien (`HumanSlot = -1`) laufen unverändert. Die Bildraten-Tests prüfen das mit denselben Fingerabdrücken.
### Theme, Symbole, Schriften
- `Theme/LuneUiTheme.cs`: alle Farben aus `lune_ui_theme.gd` und dem Saumwacht-Menütheme, Zahlenformate (12.000, Dezimalkomma).
- `Theme/LunePlates.cs`: die Platten aus `forged_style.gd` (Fase, Verlauf, Innenlinie, Kante, Schatten, Fokusring).
- `Theme/LuneGlyphs.cs`: alle 50 Symbole aus `lune_glyphs.gd` mit `Painter2D`.
- Schriften: Cantarell und Cinzel als statische SDF-Atlanten (`LuneUiAssetsBuilder`, `Settings/LuneUi.asset`, Lizenz OFL).
- Unitys Textsystem hat keine Gewichtsachse. FreeType lädt aber die benannten Instanzen der variablen Fonts: Cantarell Regular und Bold, Cinzel Bold.
- Godots Gewicht 700 und darüber (Versalien, Werte, Titel) passt damit genau.
- Das Gewicht 560 der Fließtexte hat keine benannte Instanz. Es entsteht aus Regular mit einer 0,25-px-Kontur in der Textfarbe; im Vergleichsbild wirkt das wie Medium.
- In Cantarell fehlt „★“ (Godot nahm eine Ersatzschrift). Das HUD zeichnet den Stern als Symbol.
### Prüfung U3A
| Test | Ergebnis |
| --- | --- |
| 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) |

View file

@ -2,6 +2,14 @@
Stand: 30. September 2026 · **nicht begonnen**. U5 ist die eigentliche Begründung für den Unity-Wechsel.
## Vorgaben des Projektinhabers (1. Oktober 2026)
- **Reihenfolge unverändert:** erst U3 (HUD) und U4 (Netzwerk), dann U5. Die Modelle werden nicht vorgezogen.
- **Stil:** stilisierter Realismus wie Dota 2 und aktuelle MOBAs. Dazu gehören PBR-Oberflächen, ausgeformte Details und glaubwürdige Proportionen. Die Silhouetten bleiben klar und aus der Draufsicht auch bei über 100 Einheiten lesbar.
- **Umfang:** Einheiten, Gebäude und Umgebung, also die ganze Welt, nicht nur eine repräsentative Gruppe.
- **Weg:** Die Quelle bleiben Blender-Generatoren im Repository mit reproduzierbarem FBX-Export und den U2A-Prüfungen. Dazu kommen gebackene PBR-Texturen (Albedo, Normal, Rauigkeit/Metall, AO).
- **Grenze:** Figuren mit handmodellierter Anatomie, Gesichtern und Haaren entstehen nicht prozedural. Sie brauchen Sculpts von Künstlern oder lizenzierte Asset-Bibliotheken. Downloads dafür gibt es nur nach Freigabe und Lizenzprüfung.
## Vertical Slice
Eine repräsentative Mid-Lane:

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB