U3D Abnahme: Prüfung, Feld um das HUD, Bilder, Leistung

Das G3-HUD ist abgenommen; der Meilenstein U3 ist damit fertig.

- Prüfung (Port von verify_g3, Teile A–H plus I): 718 Prüfungen ohne
  Fehler. Teil I prüft die Feld-Darstellung um das HUD (U3-Zugabe aus
  dem Motivvergleich): Aufstellungsflächen, Grotteneingang, ruhender
  Boss, Namensschilder, Eigentümer-Ring, Ancient-Balken.
- Feld um das HUD: Namensschilder für Helden (mit Stufe), Angriffs-
  champions und den eingesetzten Boss („für Blau“), Eigentümer-Ring,
  Ancient-Balken bei Schaden, ruhende Schlundkrone auf dem Podest,
  eigener Grotteneingang, Aufstellungsflächen als eigene Objekte.
- Schriften korrigiert: Die Referenz deklariert Cantarell 560/700–760
  und Cinzel 700, wendet die Gewichte aber nie an (variation_opentype
  mit dem String-Schlüssel „wght“ wird von Godot ignoriert). G3 zeigt
  Regular 400, der Port ebenso — Cinzel-Regular statt Bold, keine
  Kontur zur Aufdickung. Laufweite aus spacing_glyph umgerechnet;
  dunkler Saum als Schatten statt TextCore-Kontur.
- Werkzeuge: HudViewsTests (14 G3-Motive), HudPlaytest (Spieltest über
  die echte Oberfläche per Befehlsdateien), ui_ms_per_frame im
  LuneBenchmark. Layout folgt den echten Panelhöhen, Kamera startet wie
  Godot _ready auf der eigenen Lane.
- Tests: EditMode 23/23, PlayMode 13/13, Kerntests 402/402, Verify
  718/0. Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A,
  7B8E43AEFC0D60C5).
- Bilder: 14 Motive bei 1920×1080, Auflösungen 1152×720 bis 2560×1440,
  Prüf-Schlüsselbilder, Spieltest-Screenshots.
- Leistung (macOS-Player mit HUD, 116 Einheiten): 169 fps Übersicht,
  218 fps Mid nah; HUD-Skripte 0,090 bzw. 0,078 ms je Bild.
- Dokumentation: U3-UI (U3D, Schriftbefund), PORT-QA (U3), PORT-STATUS
  (U3 fertig, U4 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-10-02 12:55:06 +02:00
parent 2b86c0d3b5
commit 66867f3332
76 changed files with 64939 additions and 63435 deletions

View file

@ -0,0 +1,180 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!43 &4300000
Mesh:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: OwnerRingDiamond
serializedVersion: 12
m_SubMeshes:
- serializedVersion: 2
firstByte: 0
indexCount: 24
topology: 0
baseVertex: 0
firstVertex: 0
vertexCount: 10
localAABB:
m_Center: {x: -0.000000029802322, y: 0, z: -0.000000059604645}
m_Extent: {x: 0.79195964, y: 0, z: 0.79195964}
m_Shapes:
vertices: []
shapes: []
channels: []
fullWeights: []
m_BindPose: []
m_BoneNameHashes:
m_RootBoneNameHash: 0
m_BonesAABB: []
m_VariableBoneCountWeights:
m_Data:
m_MeshCompression: 0
m_IsReadable: 1
m_KeepVertices: 1
m_KeepIndices: 1
m_IndexFormat: 0
m_IndexBuffer: 000001000300000003000200020003000500020005000400040005000700040007000600060007000900060009000800
m_VertexData:
serializedVersion: 3
m_VertexCount: 10
m_Channels:
- stream: 0
offset: 0
format: 0
dimension: 3
- stream: 0
offset: 12
format: 0
dimension: 3
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
m_DataSize: 240
_typelessdata: f304353f00000000f304353f00000000000080bf00000000ddbd4a3f00000000ddbd4a3f00000000000080bf00000000f30435bf00000000f304353f00000000000080bf00000000ddbd4abf00000000ddbd4a3f00000000000080bf00000000f40435bf00000000f20435bf00000000000080bf00000000debd4abf00000000dcbd4abf00000000000080bf00000000f104353f00000000f50435bf00000000ffff7fbf00000000dbbd4a3f00000000dfbd4abf00000000000080bf00000000f304353f00000000f304353f00000000000080bf00000000ddbd4a3f00000000ddbd4a3f00000000000080bf00000000
m_CompressedMesh:
m_Vertices:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_UV:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Normals:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Tangents:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Weights:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_NormalSigns:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_TangentSigns:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_FloatColors:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_BoneIndices:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_Triangles:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_UVInfo: 0
m_LocalAABB:
m_Center: {x: -0.000000029802322, y: 0, z: -0.000000059604645}
m_Extent: {x: 0.79195964, y: 0, z: 0.79195964}
m_MeshUsageFlags: 0
m_CookingOptions: 30
m_BakedConvexCollisionMesh:
m_BakedTriangleCollisionMesh:
'm_MeshMetrics[0]': 1
'm_MeshMetrics[1]': 1
m_MeshOptimizationFlags: 1
m_StreamData:
serializedVersion: 2
offset: 0
size: 0
path:
m_MeshLodInfo:
serializedVersion: 2
m_LodSelectionCurve:
serializedVersion: 1
m_LodSlope: 0
m_LodBias: 0
m_NumLevels: 1
m_SubMeshes:
- serializedVersion: 2
m_Levels:
- serializedVersion: 1
m_IndexStart: 0
m_IndexCount: 0

View file

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

View file

@ -0,0 +1,180 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!43 &4300000
Mesh:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: OwnerRingRound
serializedVersion: 12
m_SubMeshes:
- serializedVersion: 2
firstByte: 0
indexCount: 240
topology: 0
baseVertex: 0
firstVertex: 0
vertexCount: 82
localAABB:
m_Center: {x: 0, y: 0, z: 0}
m_Extent: {x: 1.12, y: 0, z: 1.12}
m_Shapes:
vertices: []
shapes: []
channels: []
fullWeights: []
m_BindPose: []
m_BoneNameHashes:
m_RootBoneNameHash: 0
m_BonesAABB: []
m_VariableBoneCountWeights:
m_Data:
m_MeshCompression: 0
m_IsReadable: 1
m_KeepVertices: 1
m_KeepIndices: 1
m_IndexFormat: 0
m_IndexBuffer: 000001000300000003000200020003000500020005000400040005000700040007000600060007000900060009000800080009000b0008000b000a000a000b000d000a000d000c000c000d000f000c000f000e000e000f0011000e0011001000100011001300100013001200120013001500120015001400140015001700140017001600160017001900160019001800180019001b0018001b001a001a001b001d001a001d001c001c001d001f001c001f001e001e001f0021001e0021002000200021002300200023002200220023002500220025002400240025002700240027002600260027002900260029002800280029002b0028002b002a002a002b002d002a002d002c002c002d002f002c002f002e002e002f0031002e0031003000300031003300300033003200320033003500320035003400340035003700340037003600360037003900360039003800380039003b0038003b003a003a003b003d003a003d003c003c003d003f003c003f003e003e003f0041003e0041004000400041004300400043004200420043004500420045004400440045004700440047004600460047004900460049004800480049004b0048004b004a004a004b004d004a004d004c004c004d004f004c004f004e004e004f0051004e0051005000
m_VertexData:
serializedVersion: 3
m_VertexCount: 82
m_Channels:
- stream: 0
offset: 0
format: 0
dimension: 3
- stream: 0
offset: 12
format: 0
dimension: 3
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
- stream: 0
offset: 0
format: 0
dimension: 0
m_DataSize: 1968
_typelessdata: 0000803f000000000000000000000000000080bf00000000295c8f3f000000000000000000000000000080bf0000000025d97c3f000000005c30203e00000000ffff7fbf0000000052988d3f000000005d69333e00000000000080bf000000007178733f000000007a379e3e00000000000080bf00000000ed57883f00000000e533b13e00000000000080bf000000000119643f000000007171e83e00000000000080bf000000002a787f3f000000000c2b023f00000000000080bf00000000bd1b4f3f000000001879163f00000000000080bf000000001bf6673f00000000a087283f00000000000080bf00000000f304353f00000000f304353f00000000ffff7fbf00000000ddbd4a3f00000000ddbd4a3f00000000000080bf000000001979163f00000000bd1b4f3f00000000000080bf00000000a187283f000000001bf6673f00000000000080bf000000007271e83e000000000119643f00000000ffff7fbf000000000d2b023f000000002a787f3f00000000000080bf0000000079379e3e000000007178733f00000000000080bf00000000e433b13e00000000ed57883f00000000000080bf000000005d30203e0000000025d97c3f00000000000080bf000000005e69333e0000000052988d3f00000000000080bf000000002ebd3bb3000000000000803f00000000ffff7fbf00000000854452b300000000295c8f3f00000000000080bf000000005a3020be0000000025d97c3f00000000000080bf000000005b6933be0000000052988d3f00000000000080bf0000000078379ebe000000007178733f00000000ffff7fbf00000000e333b1be00000000ed57883f00000000000080bf000000006e71e8be000000000219643f00000000000080bf000000000a2b02bf000000002b787f3f00000000000080bf00000000177916bf00000000bd1b4f3f00000000ffff7fbf000000009f8728bf000000001bf6673f00000000ffff7fbf00000000f30435bf00000000f304353f00000000ffff7fbf00000000ddbd4abf00000000ddbd4a3f00000000ffff7fbf00000000be1b4fbf000000001779163f00000000000080bf000000001df667bf000000009f87283f00000000000080bf00000000001964bf000000007471e83e00000000000080bf0000000029787fbf000000000e2b023f00000000000080bf00000000707873bf000000007b379e3e00000000000080bf00000000ed5788bf00000000e633b13e00000000ffff7fbf0000000025d97cbf000000005a30203e00000000000080bf0000000052988dbf000000005b69333e00000000ffff7fbf00000000000080bf000000002ebdbbb300000000ffff7fbf00000000295c8fbf000000008544d2b300000000000080bf0000000025d97cbf00000000553020be00000000000080bf0000000052988dbf00000000556933be00000000000080bf00000000717873bf0000000079379ebe00000000ffff7fbf00000000ed5788bf00000000e433b1be00000000000080bf00000000011964bf000000007371e8be00000000ffff7fbf000000002a787fbf000000000d2b02bf00000000000080bf00000000be1b4fbf00000000167916bf00000000000080bf000000001df667bf000000009e8728bf00000000000080bf00000000f40435bf00000000f20435bf00000000000080bf00000000debd4abf00000000dcbd4abf00000000000080bf000000001b7916bf00000000ba1b4fbf00000000000080bf00000000a38728bf0000000018f667bf00000000000080bf000000007771e8be00000000001964bf00000000000080bf000000000f2b02bf0000000029787fbf00000000ffff7fbf000000007d379ebe00000000707873bf00000000000080bf00000000e833b1be00000000ed5788bf00000000ffff7fbf000000005f3020be0000000024d97cbf00000000ffff7fbf00000000606933be0000000052988dbf00000000ffff7fbf000000002ede4c3200000000000080bf00000000000080bf00000000b973653200000000295c8fbf00000000000080bf000000006030203e0000000024d97cbf00000000000080bf000000006169333e0000000052988dbf00000000ffff7fbf000000007e379e3e00000000707873bf00000000000080bf00000000e933b13e00000000ed5788bf00000000000080bf000000006971e83e00000000031964bf00000000ffff7fbf00000000082b023f000000002c787fbf00000000ffff7fbf000000001579163f00000000bf1b4fbf00000000000080bf000000009d87283f000000001ef667bf00000000ffff7fbf00000000f104353f00000000f50435bf00000000000080bf00000000dbbd4a3f00000000dfbd4abf00000000ffff7fbf00000000bc1b4f3f00000000197916bf00000000000080bf000000001af6673f00000000a18728bf00000000ffff7fbf000000000119643f000000007271e8be00000000000080bf000000002a787f3f000000000d2b02bf00000000ffff7fbf000000007178733f0000000078379ebe00000000ffff7fbf00000000ed57883f00000000e333b1be00000000ffff7fbf0000000025d97c3f00000000543020be00000000ffff7fbf0000000052988d3f00000000546933be00000000000080bf000000000000803f000000002ebd3b3400000000000080bf00000000295c8f3f000000008544523400000000000080bf00000000
m_CompressedMesh:
m_Vertices:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_UV:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Normals:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Tangents:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_Weights:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_NormalSigns:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_TangentSigns:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_FloatColors:
m_NumItems: 0
m_Range: 0
m_Start: 0
m_Data:
m_BitSize: 0
m_BoneIndices:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_Triangles:
m_NumItems: 0
m_Data:
m_BitSize: 0
m_UVInfo: 0
m_LocalAABB:
m_Center: {x: 0, y: 0, z: 0}
m_Extent: {x: 1.12, y: 0, z: 1.12}
m_MeshUsageFlags: 0
m_CookingOptions: 30
m_BakedConvexCollisionMesh:
m_BakedTriangleCollisionMesh:
'm_MeshMetrics[0]': 1
'm_MeshMetrics[1]': 1
m_MeshOptimizationFlags: 1
m_StreamData:
serializedVersion: 2
offset: 0
size: 0
path:
m_MeshLodInfo:
serializedVersion: 2
m_LodSelectionCurve:
serializedVersion: 1
m_LodSlope: 0
m_LodBias: 0
m_NumLevels: 1
m_SubMeshes:
- serializedVersion: 2
m_Levels:
- serializedVersion: 1
m_IndexStart: 0
m_IndexCount: 0

View file

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

View file

@ -36,6 +36,8 @@ namespace Luneraria.EditorTools
library.RingRound = Keep(Ring("RingRound", 40, 0f));
library.RingDiamond = Keep(Ring("RingDiamond", 4, 45f));
library.RingHexagon = Keep(Ring("RingHexagon", 6, 0f));
library.OwnerRingRound = Keep(Ring("OwnerRingRound", 40, 0f, 1f, 1.12f));
library.OwnerRingDiamond = Keep(Ring("OwnerRingDiamond", 4, 45f, 1f, 1.12f));
library.Quad = Keep(Quad());
for (int team = 0; team < 3; team++)
{
@ -44,6 +46,11 @@ namespace Luneraria.EditorTools
library.Bolt[team] = Marker("Bolt_" + team, LuneMaterialFactory.TeamLight[team], 3f, false);
}
library.HeroRing = Marker("Ring_Hero", LuneMaterialFactory.Hex("f2c96b"), 2.2f, false);
for (int team = 0; team < 2; team++)
{
library.OwnerRing[team] = Marker("Ring_Owner_" + team, LuneMaterialFactory.TeamColor[team], 3f, false);
library.AncientBarFill[team] = Marker("AncientBarFill_" + team, Color.Lerp(LuneMaterialFactory.TeamLight[team], Color.white, .15f), 1f, true);
}
library.BarBack = Marker("BarBack", new Color(0f, 0f, 0f, .7f), 1f, true);
EditorUtility.SetDirty(library);
AssetDatabase.SaveAssets();
@ -116,7 +123,7 @@ namespace Luneraria.EditorTools
}
/// <summary>Flat ring on the ground (Godot: TorusMesh inner .42, outer .52, flattened).</summary>
static Mesh Ring(string name, int sides, float offsetDegrees)
static Mesh Ring(string name, int sides, float offsetDegrees, float inner = .42f, float outer = .52f)
{
var vertices = new List<Vector3>();
var triangles = new List<int>();
@ -124,8 +131,8 @@ namespace Luneraria.EditorTools
{
float angle = (offsetDegrees + 360f * i / sides) * Mathf.Deg2Rad;
var direction = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle));
vertices.Add(direction * .42f);
vertices.Add(direction * .52f);
vertices.Add(direction * inner);
vertices.Add(direction * outer);
if (i < sides)
{
int a = i * 2;

View file

@ -0,0 +1,33 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: AncientBarFill_0
m_Shader: {fileID: 4800000, guid: 5674755340f3b446aad7884cd0870a4f, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs: []
m_Ints: []
m_Floats:
- _Cull: 0
- _ZTest: 8
m_Colors:
- _Color: {r: 0.33834773, g: 0.63246316, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0086d0d991b3a4d908c78c19584fa356
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,33 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: AncientBarFill_1
m_Shader: {fileID: 4800000, guid: 5674755340f3b446aad7884cd0870a4f, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs: []
m_Ints: []
m_Floats:
- _Cull: 0
- _ZTest: 8
m_Colors:
- _Color: {r: 1, g: 0.49190512, b: 0.24286686, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9606ec1caebc34df4935f43dc11b47c4
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,33 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Ring_Owner_0
m_Shader: {fileID: 4800000, guid: 5674755340f3b446aad7884cd0870a4f, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs: []
m_Ints: []
m_Floats:
- _Cull: 0
- _ZTest: 4
m_Colors:
- _Color: {r: 0.30672523, g: 1.1747177, b: 3, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1

View file

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

View file

@ -0,0 +1,33 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Ring_Owner_1
m_Shader: {fileID: 4800000, guid: 5674755340f3b446aad7884cd0870a4f, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs: []
m_Ints: []
m_Floats:
- _Cull: 0
- _ZTest: 4
m_Colors:
- _Color: {r: 3, g: 0.43238544, b: 0.2725252, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 66a0290d313c549c0852cdd5ba1ac1db
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load diff

View file

@ -14,5 +14,5 @@ MonoBehaviour:
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}
Display: {fileID: 11400000, guid: b5cdf5fef401b44509e1257c6021c15b, type: 2}
Theme: {fileID: -4733365628477956816, guid: 38a51cb4adf3e41879e4510c6a0d7ef6, type: 3}

View file

@ -245,3 +245,11 @@ MonoBehaviour:
- {fileID: 2100000, guid: 57679ea845a534e0cb625512321e1f12, type: 2}
- {fileID: 2100000, guid: ff98f0c178d214560b42be7959bfa214, type: 2}
- {fileID: 2100000, guid: 325b6aa4f2372452a93a1e400cafec61, type: 2}
OwnerRingRound: {fileID: 4300000, guid: 1ffccb020217248f5b3ec147f53883b5, type: 2}
OwnerRingDiamond: {fileID: 4300000, guid: e3032ccf95e8347d380459e02d512abd, type: 2}
OwnerRing:
- {fileID: 2100000, guid: f89d70a6224554988a117b329e149f14, type: 2}
- {fileID: 2100000, guid: 66a0290d313c549c0852cdd5ba1ac1db, type: 2}
AncientBarFill:
- {fileID: 2100000, guid: 0086d0d991b3a4d908c78c19584fa356, type: 2}
- {fileID: 2100000, guid: 9606ec1caebc34df4935f43dc11b47c4, type: 2}

View file

@ -0,0 +1,170 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Luneraria.Core;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace Luneraria.UI.Tests
{
/// <summary>
/// Interactive playtest of the real game (port of tools/luneraria/g3_playtest.gd). It never
/// touches the match state: every action is an input a player gives, at pixel coordinates of
/// the 1920×1080 picture (origin top left). A click reaches the HUD panel as pointer events
/// and the field as LuneGame.ClickField, which ignores points on the HUD, because in the
/// player both see the same mouse; game keys go through LuneGame.Key, the camera keys R, Q,
/// E and O through the rig as in its own key handling. Pointer moves give hover (tooltips).
/// A tester writes command batches and gets a screenshot back after each batch:
/// /tmp/lune_play/cmd-N.txt lines: click X Y · move X Y · key NAME · wait SECONDS · shot NAME · quit
/// /tmp/lune_play/out-N.png the screen after the batch; /tmp/lune_play/ok-N holds round and phase
/// The log of all commands with round and phase goes to docs/port/logs/u3/playtest-log.txt.
/// There is no main menu in the port yet, so the playtest starts in the match of the G1 scene
/// (seed of the scene, 2; LUNE_PLAYTEST_SEED overrides). It ends with quit or after 30 idle minutes.
/// Run: -testFilter "Luneraria.UI.Tests.HudPlaytest".
/// </summary>
[Explicit("Spieltest: wartet auf Befehlsdateien")]
public sealed class HudPlaytest
{
const string Dir = "/tmp/lune_play/";
const string LogFile = "docs/port/logs/u3/playtest-log.txt";
HudSession _s;
readonly List<string> _log = new List<string>();
float _started;
bool _quit;
[UnityTest, Timeout(int.MaxValue)]
public IEnumerator Play()
{
Directory.CreateDirectory(Dir);
foreach (string file in Directory.GetFiles(Dir))
File.Delete(file);
int seed = int.TryParse(Environment.GetEnvironmentVariable("LUNE_PLAYTEST_SEED"), out int value) ? value : 2;
_s = new HudSession();
yield return _s.Open(1920, 1080, seed: seed);
_started = Time.realtimeSinceStartup;
yield return new WaitForSecondsRealtime(.5f);
_s.Capture(Dir + "out-0.png");
File.WriteAllText(Dir + "ok-0", Status());
int next = 1;
float idle = 0f;
while (!_quit && idle < 1800f)
{
string path = Dir + "cmd-" + next + ".txt";
if (!File.Exists(path))
{
idle += Time.unscaledDeltaTime;
yield return null;
continue;
}
idle = 0f;
foreach (string raw in File.ReadAllLines(path))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal))
continue;
Write(next, line);
yield return Command(line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries));
if (_quit)
break;
}
yield return new WaitForSecondsRealtime(.35f);
_s.Capture(Dir + "out-" + next + ".png");
File.WriteAllText(Dir + "ok-" + next, Status());
next++;
}
_s.Close();
Assert.IsTrue(_quit, "the playtest ended with quit");
}
string Status()
{
LuneMatch match = _s.Match;
string phase = match.Phase == Phase.Planning ? "PLANUNG" : match.Phase == Phase.Combat ? "KAMPF" : "ENDE";
return "round=" + match.RoundNumber + " phase=" + phase;
}
void Write(int index, string line)
{
_log.Add(string.Format(CultureInfo.InvariantCulture, "{0,7:0.0} s #{1} [{2}] {3}", Time.realtimeSinceStartup - _started, index, Status(), line));
string file = Path.Combine(HudSession.Root, LogFile);
Directory.CreateDirectory(Path.GetDirectoryName(file));
File.WriteAllText(file, string.Join("\n", _log) + "\n");
}
IEnumerator Command(string[] parts)
{
switch (parts[0])
{
case "click":
{
Vector2 pixel = Pixel(parts);
Vector2 at = _s.Hud.PixelToPanel(pixel);
_s.MoveTo(at);
yield return null;
_s.Pointer(at, EventType.MouseDown);
// The field sees the same press (LuneGame.HandleClicks); it ignores points on the HUD.
_s.Game.ClickField(new Vector2(pixel.x, _s.Height - pixel.y));
yield return null;
_s.Pointer(at, EventType.MouseUp);
yield return null;
break;
}
case "move":
_s.MoveTo(_s.Hud.PixelToPanel(Pixel(parts)));
yield return null;
break;
case "key":
Key(parts[1].ToUpperInvariant());
yield return null;
break;
case "wait":
yield return new WaitForSecondsRealtime(float.Parse(parts[1], CultureInfo.InvariantCulture));
break;
case "shot":
yield return null;
_s.Capture(Dir + parts[1] + ".png");
break;
case "quit":
_quit = true;
break;
default:
Debug.LogWarning("LUNE_PLAYTEST unknown command: " + string.Join(" ", parts));
break;
}
}
static Vector2 Pixel(string[] parts)
{
return new Vector2(float.Parse(parts[1], CultureInfo.InvariantCulture), float.Parse(parts[2], CultureInfo.InvariantCulture));
}
void Key(string name)
{
// Camera keys of the rig (LuneCameraRig.HandleInput), blocked while the options are open.
if (name == "R" || name == "Q" || name == "E" || name == "O")
{
if (_s.Hud.Settings.Open)
return;
if (name == "R")
_s.Rig.ResetRequested?.Invoke();
else if (name == "O")
_s.Rig.Overview();
else
_s.Rig.RotateStep(name == "Q" ? -1 : 1);
return;
}
KeyCode code;
if (name.Length == 1 && char.IsDigit(name[0]))
code = KeyCode.Alpha0 + (name[0] - '0');
else if (name == "ESC")
code = KeyCode.Escape;
else
code = (KeyCode)Enum.Parse(typeof(KeyCode), name, true);
_s.Game.Key(code);
}
}
}

View file

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

View file

@ -40,7 +40,7 @@ namespace Luneraria.UI.Tests
public static string Root { get { return Path.GetDirectoryName(Application.dataPath); } }
/// <summary>Loads the scene and starts a match with the human seat, HUD into a width×height texture.</summary>
public IEnumerator Open(int width = 1920, int height = 1080, int seed = 4, float uiScale = 1f, bool reducedMotion = false, int startRound = 1, int extraGold = 0)
public IEnumerator Open(int width = 1920, int height = 1080, int seed = 4, float uiScale = 1f, bool reducedMotion = false, int startRound = 1, int extraGold = 0, string[] bunds = null)
{
LunePreferences.ResetSession(uiScale, reducedMotion);
yield return HudCaptureTests.LoadScene();
@ -53,19 +53,21 @@ namespace Luneraria.UI.Tests
Rig = Game.Rig;
Camera = View.ViewCamera != null ? View.ViewCamera : Camera.main;
Target = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32);
_sceneTarget = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
// MSAA like the player (URP asset 4×); a camera target texture sets the sample count itself.
_sceneTarget = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 };
Camera.targetTexture = _sceneTarget;
Game.Target = Target;
Driver.HumanSlot = Human;
Driver.Seed = seed;
Driver.StartRound = startRound;
Driver.ExtraGold = extraGold;
Driver.Bunds = bunds;
Driver.Speed = 1f;
Driver.Restart();
yield return Frames(3);
Hud = Game.Hud;
Assert.AreSame(Driver.Runner.Match, Hud.Match, "the HUD shows the driver's match");
Game.ResetCamera(true);
Game.StartCamera(true);
yield return Frames(2);
}

View file

@ -19,7 +19,8 @@ namespace Luneraria.UI.Tests
/// E Ready lock, bots, privacy, a dropped human seat;
/// F input: minimap, lane clicks, hotkeys, field clicks, no click-through, tooltips;
/// G events, critical alerts and reduced motion;
/// H update rates and the portrait cache.
/// H update rates and the portrait cache;
/// I (U3 addition) the field: formation areas, grotto entrance, resting boss, name tags, owner ring, Ancient bar.
/// Every check is counted; the totals go to tools/out/hud-verify-*.txt.
/// </summary>
public sealed class HudVerifyTests
@ -497,5 +498,83 @@ namespace Luneraria.UI.Tests
s.Close();
Done("H");
}
// ---------------------------------------------------------------- I field
static GameObject SceneObject(string name)
{
return Object.FindObjectsByType<Transform>(FindObjectsInactive.Include, FindObjectsSortMode.None).Select(t => t.gameObject).FirstOrDefault(g => g.name == name);
}
/// <summary>
/// U3 addition (from the G3 motif comparison): the field around the HUD. Formation areas and
/// the own grotto entrance in planning, the resting Schlundkrone while the grotto is open,
/// name tags of heroes and of a deployed boss, its owner ring, the Ancients' health bar.
/// </summary>
[UnityTest]
[Timeout(900000)]
public IEnumerator I_Field()
{
var s = new HudSession();
yield return s.Open();
yield return HudSession.Frames(2);
List<Renderer> pads = Object.FindObjectsByType<Renderer>(FindObjectsInactive.Include, FindObjectsSortMode.None).Where(r => r.gameObject.name.StartsWith("Deployment_")).ToList();
Check(pads.Count == 6, "six formation areas in the scene: " + pads.Count);
Check(pads.Where(p => p.gameObject.name.StartsWith("Deployment_0_")).All(p => p.gameObject.activeInHierarchy), "own formation areas shown in planning");
Check(pads.Where(p => p.gameObject.name.StartsWith("Deployment_1_")).All(p => !p.gameObject.activeInHierarchy), "enemy formation areas hidden");
GameObject ownEntry = SceneObject("GrottoEntry_0");
GameObject enemyEntry = SceneObject("GrottoEntry_1");
Check(ownEntry != null && ownEntry.activeInHierarchy, "own grotto entrance shown while the grotto is open");
Check(enemyEntry != null && !enemyEntry.activeInHierarchy, "enemy grotto entrance hidden");
Check(s.View.RestingBoss != null && s.View.RestingBoss.activeInHierarchy, "the Schlundkrone rests in the open grotto");
s.Match.Boss.NextAvailableRound = 5;
yield return HudSession.Frames(2);
Check(ownEntry != null && !ownEntry.activeInHierarchy, "grotto entrance hidden while the grotto is locked");
Check(s.View.RestingBoss != null && !s.View.RestingBoss.activeInHierarchy, "no resting boss while the grotto is locked");
s.Match.Boss.NextAvailableRound = 1;
// The starter army stands on Mid: that area lights up.
yield return s.Starter();
s.Leave();
yield return HudSession.Frames(2);
var block = new MaterialPropertyBlock();
Renderer mid = pads.First(p => p.gameObject.name == "Deployment_0_mid");
mid.GetPropertyBlock(block);
Check(block.GetFloat("_Highlight") > .5f, "the area of a lane with own units lights up");
Renderer top = pads.First(p => p.gameObject.name == "Deployment_0_top");
top.GetPropertyBlock(block);
Check(block.GetFloat("_Highlight") < .5f, "an empty lane's area stays plain");
// A recruited hero carries its name and level in the planning preview.
s.Me.Draft.Gold += 2000;
s.Hud.OpenHero();
string heroId = LuneContent.Heroes.Keys[0];
yield return s.Tap(() => s.Hud.HeroDetail.RecruitButtons[heroId]);
s.Hud.CloseModal();
s.Leave();
yield return HudSession.Frames(3);
string heroTag = LuneContent.Heroes[heroId].Name + " · Stufe 1";
Check(s.Hud.FieldTags.Texts.Contains(heroTag), "hero tag in the planning preview: " + string.Join(", ", s.Hud.FieldTags.Texts));
// An Ancient below full health shows its bar.
double core = s.Match.Map.BuildingDef("blau_ancient").MaxHp;
s.Match.BuildingHp["blau_ancient"] = core * .6;
yield return HudSession.Frames(2);
Transform bar = s.View.Building("blau_ancient").transform.Find("AncientBar");
Check(bar != null && bar.gameObject.activeInHierarchy, "damaged Ancient shows its health bar");
Transform enemyBar = s.View.Building("rot_ancient").transform.Find("AncientBar");
Check(enemyBar == null || !enemyBar.gameObject.activeInHierarchy, "an Ancient at full health shows none");
// Combat with the own team's boss deployed on Mid: areas hidden, boss tag and owner ring.
s.Match.Boss.Claim = new BossClaim { Team = 0, WonRound = 0, DeployRound = s.Match.RoundNumber, ChooserSlot = HudSession.Human, Lane = "mid" };
s.Game.Key(KeyCode.Space);
yield return s.Fight(1.0);
yield return HudSession.Frames(2);
Check(pads.All(p => !p.gameObject.activeInHierarchy), "formation areas hidden in combat");
Check(ownEntry != null && !ownEntry.activeInHierarchy, "grotto entrance hidden in combat");
Check(s.Hud.FieldTags.Texts.Contains("SCHLUNDKRONE · für Blau"), "deployed boss says whom it fights for: " + string.Join(", ", s.Hud.FieldTags.Texts));
Check(s.Hud.FieldTags.Texts.Contains(heroTag), "hero tag in combat");
LuneUnitView boss = s.View.Units.FirstOrDefault(v => v.Unit.Kind == "boss" && v.Unit.Team == 0);
Transform ring = boss != null ? boss.transform.Find("OwnerRing") : null;
Check(ring != null && ring.gameObject.activeInHierarchy, "deployed boss wears the owner ring of its team");
Check(s.View.RestingBoss == null || !s.View.RestingBoss.activeInHierarchy, "no resting boss while it is deployed");
s.Close();
Done("I");
}
}
}

View file

@ -0,0 +1,223 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Luneraria.Core;
using Luneraria.View;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace Luneraria.UI.Tests
{
/// <summary>
/// The fourteen G3 motifs (port of tools/luneraria/g3_views.gd) from the real game with the
/// real HUD at one window size. Like Godot, fixtures only set public match state before or
/// between phases (gold, round, building HP, boss claim, the armies of round 14), the starter
/// army comes from the HUD's own button (its toast stays, as in Godot's pictures), further
/// commands go straight to the match followed by the preview rebuild (Godot command() and
/// _rebuild_preview()), and HUD views open through the HUD's public methods. After a click
/// the pointer leaves the HUD, so no tooltip covers a picture. A capture tool, not part of the
/// suite: -testFilter "Luneraria.UI.Tests.HudViewsTests" (all sizes) or one size by its test
/// name. 1920×1080 is written as PNG (the reference set next to Godot's), the other sizes as
/// JPEG to keep the repository small.
/// </summary>
[Explicit("Bildwerkzeug: nur gezielt per -testFilter")]
public sealed class HudViewsTests
{
/// <summary>The Bünde of Godot's g3_views.gd (the human seat 1 plays the Wurzelbund).</summary>
static readonly string[] Bunds = { "wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt" };
int _width;
int _height;
string _folder;
string _extension;
readonly List<string> _shots = new List<string>();
[UnityTest, Timeout(1800000)] public IEnumerator Motifs_1920x1080() { yield return Run(1920, 1080); }
[UnityTest, Timeout(1800000)] public IEnumerator Motifs_2560x1440() { yield return Run(2560, 1440); }
[UnityTest, Timeout(1800000)] public IEnumerator Motifs_1280x720() { yield return Run(1280, 720); }
[UnityTest, Timeout(1800000)] public IEnumerator Motifs_2560x1080() { yield return Run(2560, 1080); }
IEnumerator Run(int width, int height)
{
_width = width;
_height = height;
_folder = "docs/port/screenshots/u3/motive-" + width + "x" + height + "/";
_extension = width == 1920 && height == 1080 ? ".png" : ".jpg";
_shots.Clear();
yield return PlanningViews();
yield return BossViews();
yield return CombatViews();
yield return AncientViews();
Debug.Log("LUNE_VIEWS " + width + "x" + height + ": " + _shots.Count + " " + string.Join(", ", _shots));
Assert.AreEqual(14, _shots.Count, "all fourteen motifs");
}
IEnumerator Fresh(HudSession s)
{
yield return s.Open(_width, _height, seed: 4, bunds: Bunds);
}
IEnumerator Shot(HudSession s, string label)
{
yield return s.Shot(_folder + label + _extension);
_shots.Add(label);
}
static IEnumerator Wait(float seconds)
{
yield return new WaitForSecondsRealtime(seconds);
}
/// <summary>Godot command(): a command of the human seat straight to the match, then the preview.</summary>
static void Send(HudSession s, Command command)
{
CommandResult result = s.Match.Submit(HudSession.Human, command);
Assert.IsTrue(result.Ok, command.Type + ": " + result.Message);
s.Game.RebuildPreview();
s.Sync();
}
/// <summary>
/// Godot armies(): the given seats plan round n with plenty of gold (profile "ausgewogen"),
/// grotto orders go to the own lane; the human keeps an ordinary amount afterwards.
/// </summary>
static void Armies(HudSession s, int round)
{
LuneMatch match = s.Match;
match.RoundNumber = round;
for (int slot = 0; slot < 6; slot++)
{
Draft draft = match.Players[slot].Draft;
draft.Roster.Clear();
draft.Gold = 100000;
LuneBot.Plan(match, slot, "ausgewogen");
foreach (RosterEntry entry in draft.Roster)
{
if (entry.Lane == LuneMatch.Grotto)
entry.Lane = match.Players[slot].Lane;
}
if (draft.Hero != null && draft.Hero.Lane == LuneMatch.Grotto)
draft.Hero.Lane = match.Players[slot].Lane;
}
match.Players[HudSession.Human].Draft.Gold = 185;
}
// ---------------------------------------------------------------- planning
IEnumerator PlanningViews()
{
var s = new HudSession();
yield return Fresh(s);
// A normal planning round: round 3, starter army bought, some gold left.
s.Match.RoundNumber = 3;
s.Me.Draft.Gold = 420;
yield return s.Starter();
s.Leave();
yield return Wait(.4f);
Assert.AreEqual(3, s.Model.OwnRoster.Count, "starter army");
yield return Shot(s, "01-planung");
// Hero hired and shown.
s.Me.Draft.Gold = 900;
Send(s, Command.HeroBuy("held_ringborke"));
s.Hud.OpenHero();
yield return Wait(.4f);
yield return Shot(s, "02-held-ausgewaehlt");
// Item choice with one item equipped.
Send(s, Command.ItemBuy("glutklinge", s.Me.Draft.Hero.Uid));
s.Hud.OpenItems();
yield return Wait(.3f);
yield return Shot(s, "03-itemauswahl");
s.Hud.CloseModal();
// A normal unit selected.
s.Hud.SelectUnit(s.Model.OwnRoster.First().Uid);
yield return Wait(.3f);
yield return Shot(s, "04-einheit-ausgewaehlt");
s.Hud.ClearSelection();
// Attack champions: one booked for the next round.
Send(s, Command.ChampionBook("ch_belagerung", "mid"));
s.Hud.OpenChampions();
yield return Wait(.3f);
yield return Shot(s, "05-angriffschampion");
s.Close();
}
// ---------------------------------------------------------------- boss
IEnumerator BossViews()
{
var s = new HudSession();
yield return Fresh(s);
s.Match.RoundNumber = 10;
s.Match.Boss.NextAvailableRound = 10;
s.Game.FocusTarget("grotto");
s.Sync();
yield return Wait(1f);
yield return Shot(s, "06-boss-verfuegbar");
// The own team conquered the boss in round 11: deployment in round 12 on Mid, planning of 12.
s.Match.RoundNumber = 12;
s.Match.Boss.Claim = new BossClaim { Team = 0, WonRound = 11, DeployRound = 12, ChooserSlot = HudSession.Human, Lane = "mid" };
s.Match.Boss.NextAvailableRound = 15;
s.Game.FocusLane("mid", true);
s.Sync();
yield return Wait(1f);
yield return Shot(s, "07-boss-angekuendigt");
s.Close();
}
// ---------------------------------------------------------------- combat
IEnumerator CombatViews()
{
var s = new HudSession();
yield return Fresh(s);
Armies(s, 14);
s.Match.BuildingHp["rot_top_outer"] = 0.0;
s.Match.BuildingHp["blau_bot_outer"] = 0.0;
s.Game.Key(KeyCode.Space);
yield return HudSession.Frames(1);
Assert.AreEqual(LuneMatchRunner.Stage.Combat, s.Runner.Current, "combat of round 14");
// Godot advance_combat(22): the clock runs to 22 s, then holds.
yield return s.Fight(22.0);
s.Driver.Paused = true;
foreach (var spec in new[] { ("08-kampf-top", "top"), ("09-kampf-mid", "mid"), ("10-kampf-bottom", "bot") })
{
s.Game.FocusLane(spec.Item2, true);
yield return Wait(.5f);
yield return Shot(s, spec.Item1);
}
Vec2 ruin = s.Match.Map.BuildingDef("blau_bot_outer").Position;
s.Rig.LookAt(ruin, 22f, true);
yield return Wait(.5f);
yield return Shot(s, "13-turm-zerstoert");
s.Rig.LookAt(Vec2.Zero, 46f, true);
yield return Wait(.5f);
yield return Shot(s, "14-grosse-kampfansicht");
s.Close();
}
// ---------------------------------------------------------------- Ancients
IEnumerator AncientViews()
{
var s = new HudSession();
yield return Fresh(s);
s.Match.RoundNumber = 21;
foreach (string id in new[] { "blau_mid_outer", "blau_mid_inner", "blau_base_1", "blau_base_2" })
s.Match.BuildingHp[id] = 0.0;
s.Match.BuildingHp["blau_ancient"] = s.Match.Map.BuildingDef("blau_ancient").MaxHp * .22;
s.Game.FocusTarget("base");
s.Sync();
yield return Wait(1.2f);
yield return Shot(s, "11-eigener-ancient-kritisch");
s.Close();
s = new HudSession();
yield return Fresh(s);
s.Match.RoundNumber = 137;
foreach (string id in new[] { "rot_top_outer", "rot_top_inner", "rot_base_1", "rot_base_2" })
s.Match.BuildingHp[id] = 0.0;
s.Match.BuildingHp["rot_ancient"] = s.Match.Map.BuildingDef("rot_ancient").MaxHp * .18;
s.Rig.LookAt(s.Match.Map.CorePosition(1), 26f, true);
s.Sync();
yield return Wait(1.2f);
yield return Shot(s, "12-gegnerischer-ancient-kritisch");
s.Close();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3be86884884be4cdf8fc2b15e672e1f8

View file

@ -12,10 +12,12 @@ 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.
/// reference (assets/fonts, OFL). Unity's text engine has no weight axis; FreeType loads a
/// named instance through the face index (named instance &lt;&lt; 16), index 0 is the default
/// instance. The HUD uses Cantarell Regular (3) and Cinzel in its default instance (Regular
/// 400), the weights G3 really shows (see LuneFace); Cantarell Bold (4) stays for the U3A probe.
/// The atlases hold every character the HUD texts use, so the player never needs the font
/// files. Existing atlases are kept (no churn); delete one to rebuild it.
/// Unity -batchmode -projectPath . -executeMethod Luneraria.EditorTools.LuneUiAssetsBuilder.Build -logFile -
/// </summary>
public static class LuneUiAssetsBuilder
@ -33,7 +35,7 @@ namespace Luneraria.EditorTools
}
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.Display = Font("Cinzel-VF.ttf", 0, "Cinzel-Regular");
assets.Theme = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>("Assets/Luneraria/UI/LuneRuntimeTheme.tss");
EditorUtility.SetDirty(assets);
AssetDatabase.SaveAssets();
@ -57,8 +59,9 @@ namespace Luneraria.EditorTools
static FontAsset Font(string file, int namedInstance, string name)
{
string path = Fonts + name + ".asset";
if (AssetDatabase.LoadAssetAtPath<FontAsset>(path) != null)
AssetDatabase.DeleteAsset(path);
FontAsset existing = AssetDatabase.LoadAssetAtPath<FontAsset>(path);
if (existing != null)
return existing;
string source = Path.GetFullPath(Fonts + file);
FontAsset font = FontAsset.CreateFontAsset(source, namedInstance << 16, 64, 8, GlyphRenderMode.SDFAA, 1024, 1024);
if (font == null)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 84c5b4f661d34482e8f3a990d043245e
guid: b5cdf5fef401b44509e1257c6021c15b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000

View file

@ -15,7 +15,10 @@ namespace Luneraria.UI
/// clicks and hotkeys move the camera; a left click in the field selects an own unit. During
/// planning the own draft army stands in its formation and the own deployment areas light up.
/// Offline, planning waits for the human; pause and tempo are local. Nothing here decides a rule.
/// Runs after the match view (execution order 200 after 100), so name tags follow the figures
/// of the same frame.
/// </summary>
[DefaultExecutionOrder(200)]
public sealed class LuneGame : MonoBehaviour
{
public LuneMatchDriver Driver;
@ -37,11 +40,16 @@ namespace Luneraria.UI
Camera _camera;
LuneMatchRunner _runner;
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
double _updateMs;
double _lateMs;
LuneMatchRunner.Stage _lastStage = (LuneMatchRunner.Stage)(-1);
int _laneCycle;
bool _pausedBySettings;
VisualElement _leave;
readonly List<(Renderer renderer, int team, string lane)> _pads = new List<(Renderer, int, string)>();
readonly List<(GameObject ring, int team)> _grottoRings = new List<(GameObject, int)>();
string _deployment = "";
MaterialPropertyBlock _block;
void Start()
@ -63,7 +71,14 @@ namespace Luneraria.UI
Rig.KeysBlocked = () => Hud != null && Hud.Settings.Open;
Rig.ResetRequested = () => ResetCamera();
}
ResetCamera(true);
StartCamera(true);
// The benchmark counts the HUD scripts: its Tick plus this controller's frame work.
LuneBenchmark.UiMs = () => (Hud != null ? Hud.LastTickMs : 0.0) + _updateMs + _lateMs;
}
void OnDestroy()
{
LuneBenchmark.UiMs = null;
}
/// <summary>(Re)builds the HUD for the driver's current match and wires its events.</summary>
@ -157,7 +172,7 @@ namespace Luneraria.UI
Driver.Restart();
View.Preview = null;
Bind();
ResetCamera(true);
StartCamera(true);
}
// ---------------------------------------------------------------- camera targets
@ -219,6 +234,17 @@ namespace Luneraria.UI
return true;
}
/// <summary>The first view of a match (Godot _ready): the own lane at planning distance, a bot match from above.</summary>
public void StartCamera(bool instant = true)
{
if (Rig == null)
return;
if (Human)
FocusLane(Match.Players[Seat].Lane, instant);
else
Rig.Overview(instant);
}
public void ResetCamera(bool instant = false)
{
if (Rig == null)
@ -228,26 +254,36 @@ namespace Luneraria.UI
}
// ---------------------------------------------------------------- planning preview and deployment areas
/// <summary>
/// The formation areas (Deployment_team_lane) and grotto entrance rings (GrottoEntry_team) of
/// the scene. They are built inactive, like Godot's hidden pads, so the search includes
/// inactive objects.
/// </summary>
void FindPads()
{
_pads.Clear();
foreach (Renderer renderer in FindObjectsByType<Renderer>(FindObjectsSortMode.None))
_grottoRings.Clear();
foreach (Renderer renderer in FindObjectsByType<Renderer>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
string name = renderer.gameObject.name;
if (!name.StartsWith("Deployment_", StringComparison.Ordinal))
continue;
string[] parts = name.Split('_');
if (parts.Length >= 3 && int.TryParse(parts[1], out int team))
string[] parts = renderer.gameObject.name.Split('_');
if (parts.Length >= 3 && parts[0] == "Deployment" && int.TryParse(parts[1], out int team))
_pads.Add((renderer, team, parts[2]));
else if (parts.Length == 2 && parts[0] == "GrottoEntry" && int.TryParse(parts[1], out int side))
_grottoRings.Add((renderer.gameObject, side));
}
_deployment = "";
}
/// <summary>Planning shows the own formation areas; lanes that hold own units light up (Godot show_deployment).</summary>
/// <summary>
/// Planning shows the own formation areas, lanes that hold own units light up, and the own
/// grotto entrance while the grotto is open (Godot show_deployment). Checked every frame,
/// applied only when something changed.
/// </summary>
void UpdateDeployment()
{
bool planning = Match.Phase == Phase.Planning && Human;
var used = new Dictionary<string, int>();
if (Human)
if (planning)
{
Draft draft = Match.Players[Seat].Draft;
foreach (RosterEntry entry in draft.Roster)
@ -255,24 +291,36 @@ namespace Luneraria.UI
if (draft.Hero != null)
used[draft.Hero.Lane] = used.GetValueOrDefault(draft.Hero.Lane, 0) + 1;
}
bool grotto = planning && Match.GrottoAvailable();
string signature = planning + "|" + OwnTeam + "|" + grotto + "|" + string.Join(",", LuneMap.Lanes.Select(lane => used.GetValueOrDefault(lane, 0) > 0 ? lane : ""));
if (signature == _deployment)
return;
_deployment = signature;
_block ??= new MaterialPropertyBlock();
foreach (var pad in _pads)
{
if (!Human)
continue;
bool visible = planning && pad.team == OwnTeam;
if (pad.renderer.enabled != visible)
pad.renderer.enabled = visible;
if (pad.renderer.gameObject.activeSelf != visible)
pad.renderer.gameObject.SetActive(visible);
if (!visible)
continue;
pad.renderer.GetPropertyBlock(_block);
_block.SetFloat("_Highlight", used.GetValueOrDefault(pad.lane, 0) > 0 ? 1f : 0f);
pad.renderer.SetPropertyBlock(_block);
}
foreach (var entry in _grottoRings)
{
bool visible = grotto && entry.team == OwnTeam;
if (entry.ring.activeSelf != visible)
entry.ring.SetActive(visible);
}
}
/// <summary>The human player's current draft army in its formation (Godot _rebuild_preview).</summary>
void RebuildPreview()
/// <summary>
/// The human player's current draft army in its formation (Godot _rebuild_preview); after
/// every own command, and by fixtures that change the draft directly (g3_views).
/// </summary>
public void RebuildPreview()
{
UpdateDeployment();
if (Match.Phase != Phase.Planning || !Human)
@ -308,6 +356,13 @@ namespace Luneraria.UI
// ---------------------------------------------------------------- frame
void Update()
{
_clock.Restart();
FrameUpdate();
_updateMs = _clock.Elapsed.TotalMilliseconds;
}
void FrameUpdate()
{
if (Driver.Runner != _runner)
{
@ -316,10 +371,21 @@ namespace Luneraria.UI
Bind();
}
TrackStage();
UpdateDeployment();
HandleKeys();
HandleClicks();
if (Rig != null && _camera != null)
}
/// <summary>After the match view moved the figures: name tags and the minimap's camera frame.</summary>
void LateUpdate()
{
if (Hud == null || _camera == null)
return;
_clock.Restart();
Hud.FieldTags.Sync(View.Units, _camera, Hud);
if (Rig != null)
Hud.Minimap.SetCamera(Rig.Focus, Rig.Zoom, Rig.Footprint(_camera));
_lateMs = _clock.Elapsed.TotalMilliseconds;
}
void TrackStage()

View file

@ -431,12 +431,15 @@ namespace Luneraria.UI
{
style.width = 30f;
style.height = 30f;
style.paddingLeft = 5f;
style.paddingRight = 5f;
style.paddingTop = 5f;
style.paddingBottom = 5f;
// Godot: the glyph fills the slot with 5 px insets at any slot size (46 in the hero window).
_glyph = LuneUi.Glyph("", Color.white, 20f, this);
_glyph.style.flexGrow = 1f;
_glyph.style.position = Position.Absolute;
_glyph.style.left = 5f;
_glyph.style.top = 5f;
_glyph.style.right = 5f;
_glyph.style.bottom = 5f;
_glyph.style.width = StyleKeyword.Auto;
_glyph.style.height = StyleKeyword.Auto;
return;
}
style.minHeight = 74f;

View file

@ -138,7 +138,7 @@ namespace Luneraria.UI
{
Normal = M("e8c47e", "b98a4a", H("fff0cc"), 1.5f, 7f, 16f, 8f), Hover = M("f6d898", "cc9c56", Color.white, 1.5f, 7f, 16f, 8f),
Pressed = M("b98a4a", "d5aa66", H("fff0cc"), 2f, 7f, 16f, 8f), Disabled = M("4a4234", "3a3328", H("6a5c46"), 1f, 7f, 16f, 8f),
TextVariation = "HudValue", TextSize = 16f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("9c8f78"),
TextVariation = "HudPrimary", TextSize = 16f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("9c8f78"),
};
/// <summary>Godot PrimaryButton of the menu theme (Bereit, Neue Partie).</summary>
@ -146,7 +146,7 @@ namespace Luneraria.UI
{
Normal = M("e8c47e", "b98a4a", H("fff0cc"), 1.5f, 9f, 30f, 15f), Hover = M("f6d898", "cc9c56", Color.white, 1.5f, 9f, 30f, 15f),
Pressed = M("b98a4a", "d5aa66", H("fff0cc"), 2f, 9f, 30f, 15f), Disabled = M("5a4c38", "463a2a", H("7d6a4c"), 1.5f, 9f, 30f, 15f),
TextVariation = "HudName", TextSize = 22f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("2a2218"),
TextVariation = "PrimaryButton", TextSize = 22f, Text = LuneUiTheme.Ink, TextHover = LuneUiTheme.Ink, TextPressed = LuneUiTheme.Ink, TextDisabled = H("2a2218"),
};
/// <summary>Godot Button of the menu theme (secondary).</summary>

View file

@ -0,0 +1,121 @@
using System.Collections.Generic;
using Luneraria.Core;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Name tags of the named figures in the field (port of the Label3D tags of
/// lune_unit_view.gd): heroes with their level in hero gold, attack champions and the boss in
/// their team's light colour. A deployed boss says whom it fights for ("SCHLUNDKRONE · für
/// Blau"), G3's answer to the colour closeness of its crystals and Blau's armies. The tags lie
/// in the HUD panel behind every region, centred above the figure's head, at a fixed size on
/// screen like Godot's fixed_size tags, and never take the mouse.
/// </summary>
public sealed class LuneFieldTags : VisualElement
{
/// <summary>World team colours of lune_unit_view.gd (TEAM_COLORS) and HERO_GOLD.</summary>
static readonly Color[] TeamColors = { LuneUiTheme.Hex("5aa8ff"), LuneUiTheme.Hex("ff6a55"), LuneUiTheme.Hex("b37cff") };
static readonly Color HeroGold = LuneUiTheme.Hex("f2c96b");
/// <summary>Logical size; Godot tags measure about 18 px at 1080p and a tenth wider than Cantarell at 16.</summary>
public const float Size = 17.5f;
readonly Dictionary<LuneUnitView, Label> _tags = new Dictionary<LuneUnitView, Label>();
readonly List<LuneUnitView> _gone = new List<LuneUnitView>();
readonly HashSet<LuneUnitView> _seen = new HashSet<LuneUnitView>();
public LuneFieldTags()
{
name = "field-tags";
pickingMode = PickingMode.Ignore;
style.position = Position.Absolute;
style.left = 0f;
style.top = 0f;
style.right = 0f;
style.bottom = 0f;
}
/// <summary>Visible tags by text (tests).</summary>
public IEnumerable<string> Texts
{
get
{
foreach (Label label in _tags.Values)
{
if (LuneUi.Visible(label))
yield return label.text;
}
}
}
public static bool Named(LuneUnit unit)
{
return unit.Kind == "hero" || unit.Kind == "champion" || unit.Kind == "boss";
}
/// <summary>Godot: hero "Name · Stufe n", deployed boss "NAME · für Blau/Rot", otherwise the name.</summary>
public static string TextFor(LuneUnit unit)
{
string title = unit.Def.DisplayName;
if (unit.Kind == "hero")
return title + " · Stufe " + unit.Def.Level;
if (unit.Kind == "boss" && unit.Team <= 1)
return title.ToUpperInvariant() + " · für " + (unit.Team == 0 ? "Blau" : "Rot");
return title;
}
public static Color ColorFor(LuneUnit unit)
{
return unit.Kind == "hero" ? HeroGold : LuneUiTheme.Lightened(TeamColors[Mathf.Clamp(unit.Team, 0, 2)], .3f);
}
/// <summary>Places a tag over every living named figure the camera sees; the others hide.</summary>
public void Sync(IEnumerable<LuneUnitView> views, Camera camera, LuneHud hud)
{
_seen.Clear();
Vector2 target = hud.TargetSize;
foreach (LuneUnitView view in views)
{
LuneUnit unit = view.Unit;
if (unit == null || !Named(unit) || !unit.Alive || !view.gameObject.activeInHierarchy)
continue;
Vector3 screen = camera.WorldToScreenPoint(view.TagPoint);
if (screen.z <= 0f)
continue;
if (!_tags.TryGetValue(view, out Label label))
{
label = LuneUi.Label("", "HudText", this, Size);
label.style.position = Position.Absolute;
label.style.translate = new Translate(Length.Percent(-50f), Length.Percent(-50f));
// Godot's tag has a heavy dark outline; a dense shadow keeps the strokes whole (see LuneUi.SetColor).
label.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 3f, color = new Color(0f, 0f, 0f, .95f) };
_tags[view] = label;
}
string text = TextFor(unit);
if (label.text != text)
label.text = text;
label.style.color = ColorFor(unit);
Vector2 at = hud.PixelToPanel(new Vector2(screen.x, target.y - screen.y));
label.style.left = Mathf.Round(at.x);
label.style.top = Mathf.Round(at.y);
LuneUi.Show(label, true);
_seen.Add(view);
}
_gone.Clear();
foreach (KeyValuePair<LuneUnitView, Label> pair in _tags)
{
if (pair.Key == null)
_gone.Add(pair.Key);
else if (!_seen.Contains(pair.Key))
LuneUi.Show(pair.Value, false);
}
foreach (LuneUnitView view in _gone)
{
_tags[view].RemoveFromHierarchy();
_tags.Remove(view);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00e663adafe8943d78136f26fd1fa8c0

View file

@ -414,8 +414,8 @@ namespace Luneraria.UI
if (hero.Fresh && model.Planning)
{
int refund = hero.Invested + hero.Items.Sum(id => LuneContent.Items[id].Cost);
// Godot: a plain button in the column fills its width.
var cancel = new LuneButton("Held zurückgeben +" + refund, LuneButtons.HudButton, left);
cancel.style.alignSelf = Align.FlexStart;
cancel.Disabled = !canAct;
cancel.Clicked += () => CommandRequested?.Invoke(Command.HeroCancel());
}

View file

@ -65,6 +65,8 @@ namespace Luneraria.UI
public VisualElement Root { get; private set; }
public float UiScale { get; private set; } = 1f;
public LuneMinimap Minimap { get; private set; }
/// <summary>Name tags of heroes, champions and the boss in the field, behind every region.</summary>
public LuneFieldTags FieldTags { get; private set; }
public LuneTooltip Tooltip { get; private set; }
public LuneHeroCard HeroCard { get; private set; }
public LuneHeroDetail HeroDetail { get; private set; }
@ -217,6 +219,8 @@ namespace Luneraria.UI
_hud.style.right = 0f;
_hud.style.bottom = 0f;
Root.Add(_hud);
FieldTags = new LuneFieldTags();
_hud.Add(FieldTags);
BuildTop();
BuildSystem();
BuildLeft();
@ -234,6 +238,12 @@ namespace Luneraria.UI
_hud.Add(Tooltip);
_hud.RegisterCallback<PointerMoveEvent>(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
_hud.RegisterCallback<PointerDownEvent>(evt => Tooltip.Pointer = evt.position, TrickleDown.TrickleDown);
// Banner, status line and right column follow the real heights of the top panel, the
// bottom bar and the system row (Godot _layout on resize); before the first layout
// Layout() uses estimates.
_top.RegisterCallback<GeometryChangedEvent>(_ => Layout());
_bottom.RegisterCallback<GeometryChangedEvent>(_ => Layout());
_system.RegisterCallback<GeometryChangedEvent>(_ => Layout());
LunePreferences.Changed -= Fit;
LunePreferences.Changed += Fit;
Fit();
@ -699,12 +709,17 @@ namespace Luneraria.UI
}
// ---------------------------------------------------------------- per frame
/// <summary>Main-thread cost of the last frame's Tick (milliseconds; benchmark).</summary>
public double LastTickMs { get; private set; }
readonly System.Diagnostics.Stopwatch _tickClock = new System.Diagnostics.Stopwatch();
void Update()
{
if (Match == null)
return;
float delta = Time.unscaledDeltaTime;
Tick(delta);
_tickClock.Restart();
Tick(Time.unscaledDeltaTime);
LastTickMs = _tickClock.Elapsed.TotalMilliseconds;
}
/// <summary>One HUD frame (Godot _process); tests call it with simulated time.</summary>

View file

@ -56,10 +56,12 @@ namespace Luneraria.UI
return image;
}
/// <summary>PNG, or JPEG (quality 90) for a .jpg file.</summary>
public static void Save(Texture2D image, string file)
{
Directory.CreateDirectory(Path.GetDirectoryName(file));
File.WriteAllBytes(file, image.EncodeToPNG());
bool jpeg = file.EndsWith(".jpg", System.StringComparison.OrdinalIgnoreCase);
File.WriteAllBytes(file, jpeg ? image.EncodeToJPG(90) : image.EncodeToPNG());
}
}
}

View file

@ -6,14 +6,20 @@ using TextElement = UnityEngine.UIElements.TextElement;
namespace Luneraria.UI
{
/// <summary>Weights of the HUD fonts (Godot FontVariation weights of lune_ui_theme.gd).</summary>
/// <summary>
/// Font roles of the HUD as lune_ui_theme.gd declares them: Cantarell 560, Cantarell 700–760
/// and Cinzel 700. Godot never applied those weights: its FontVariation used the String key
/// "wght", which Godot ignores (measured in 4.7.2: the same advances at 400, 560 and 700; only
/// the integer tag changes them). G3 therefore shows both fonts in their default instance,
/// Regular 400, and so does the port. The roles stay apart so that U5 can give them weights.
/// </summary>
public enum LuneFace
{
/// <summary>Cantarell 560: Regular with a thin outline in the text colour.</summary>
/// <summary>Cantarell (declared 560).</summary>
Text,
/// <summary>Cantarell 700–760.</summary>
/// <summary>Cantarell (declared 700–760).</summary>
Bold,
/// <summary>Cinzel 700.</summary>
/// <summary>Cinzel (declared 700).</summary>
Display,
}
@ -55,12 +61,14 @@ namespace Luneraria.UI
{ "HudBadge", new LuneTextStyle(LuneFace.Bold, 14, LuneUiTheme.Text, 1f) },
{ "HudLane", new LuneTextStyle(LuneFace.Display, 16, Title, 1f) },
{ "HudValue", new LuneTextStyle(LuneFace.Bold, 17, LuneUiTheme.Bright) },
{ "HudPrimary", new LuneTextStyle(LuneFace.Bold, 16, LuneUiTheme.Ink, 1f) },
{ "HudGold", new LuneTextStyle(LuneFace.Bold, 24, LuneUiTheme.Gold, 0f, 4f) },
{ "HudName", new LuneTextStyle(LuneFace.Display, 18, Title, 1f) },
{ "HudTitle", new LuneTextStyle(LuneFace.Display, 24, Title, 1f, 4f) },
{ "HudWave", new LuneTextStyle(LuneFace.Display, 30, Title, 3f, 4f) },
// Menu theme (game-over panel, options).
{ "TitleLabel", new LuneTextStyle(LuneFace.Display, 78, Title, 2f, 6f) },
{ "PrimaryButton", new LuneTextStyle(LuneFace.Display, 22, LuneUiTheme.Ink, 2f) },
{ "HeadingLabel", new LuneTextStyle(LuneFace.Display, 34, Title, 1f) },
{ "SectionLabel", new LuneTextStyle(LuneFace.Display, 16, LuneUiTheme.BronzeLight, 3f) },
{ "BodyLabel", new LuneTextStyle(LuneFace.Text, 20, LuneUiTheme.Parchment) },
@ -69,14 +77,21 @@ namespace Luneraria.UI
{ "CardFactLabel", new LuneTextStyle(LuneFace.Text, 17, LuneUiTheme.Hex("d8cfbc")) },
};
/// <summary>Outline that thickens Cantarell Regular to the G3 text weight 560.</summary>
public const float MediumOutline = .25f;
/// <summary>The atlas of a role: Cinzel Regular for Display, Cantarell Regular otherwise (see LuneFace).</summary>
public static FontAsset Font(LuneFace face)
{
if (Assets == null)
return null;
return face == LuneFace.Display ? Assets.Display : face == LuneFace.Bold ? Assets.TextBold : Assets.Text;
return face == LuneFace.Display ? Assets.Display : Assets.Text;
}
/// <summary>
/// Godot's spacing_glyph (pixels per glyph) as UI Toolkit letter spacing, which counts in
/// hundredths of the font size (measured: 10 widens each glyph by 0.1 em).
/// </summary>
public static float LetterSpacing(float pixels, float fontSize)
{
return fontSize > 0f ? pixels * 100f / fontSize : 0f;
}
/// <summary>Applies a text style; size 0 keeps the style's size.</summary>
@ -87,13 +102,18 @@ namespace Luneraria.UI
FontAsset font = Font(style.Face);
if (font != null)
element.style.unityFontDefinition = FontDefinition.FromSDFFont(font);
element.style.fontSize = size > 0f ? Mathf.Max(LuneUiTheme.MinFont, size) : style.Size;
element.style.letterSpacing = style.Spacing;
float fontSize = size > 0f ? Mathf.Max(LuneUiTheme.MinFont, size) : style.Size;
element.style.fontSize = fontSize;
element.style.letterSpacing = LetterSpacing(style.Spacing, fontSize);
element.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f, color = new Color(0f, 0f, 0f, .55f) };
SetColor(element, style.Color, style);
}
/// <summary>Text colour; keeps the weight outline (560) or the dark title outline in step.</summary>
/// <summary>
/// Text colour. Titles, the wave and gold get Godot's dark rim (outline_size, black .55) as
/// a soft shadow: a TextCore outline straddles the glyph edge and eats the thin strokes of
/// the regular faces, Godot's outline only grows outwards.
/// </summary>
public static void SetColor(TextElement element, Color color, LuneTextStyle style = null)
{
element.style.color = color;
@ -102,20 +122,9 @@ namespace Luneraria.UI
if (style == null)
return;
element.userData = style;
if (style.Outline > 0f)
{
element.style.unityTextOutlineWidth = style.Outline * .25f;
element.style.unityTextOutlineColor = new Color(0f, 0f, 0f, .55f);
}
else if (style.Face == LuneFace.Text)
{
element.style.unityTextOutlineWidth = MediumOutline;
element.style.unityTextOutlineColor = color;
}
else
{
element.style.unityTextOutlineWidth = 0f;
}
if (style.Outline > 0f)
element.style.textShadow = new TextShadow { offset = new Vector2(0f, 1f), blurRadius = 1f + style.Outline * .5f, color = new Color(0f, 0f, 0f, .7f) };
}
public static Label Label(string text, string variation = "HudText", VisualElement parent = null, float size = 0f)

View file

@ -13,6 +13,8 @@ namespace Luneraria.View
/// "-luneBenchmark &lt;out.json&gt;": runs the match of seed 9 from round 21 (the golden fixture
/// high_wave_21, the most units) at normal speed, VSync off, and measures two windows once the
/// armies meet: the whole map (20 s) and a close view of the Mid lane (10 s). Then it quits.
/// Since U3 the scene carries the HUD (watching seat 1 of the bot match); its own scripts'
/// cost per frame comes through UiMs (layout and drawing of the panel count in the threads).
/// </summary>
[DefaultExecutionOrder(-100)]
public sealed class LuneBenchmark : MonoBehaviour
@ -30,6 +32,7 @@ namespace Luneraria.View
public readonly List<double> FrameMs = new List<double>();
public double Simulation;
public double Views;
public double Ui;
public int Units;
public int GcStart;
public int GcEnd;
@ -70,6 +73,9 @@ namespace Luneraria.View
/// <summary>True in a benchmark run: the game controller keeps the bot match (no human seat).</summary>
public static bool Running { get; private set; }
/// <summary>Main-thread cost of the HUD scripts in the last frame (set by the game controller, U3).</summary>
public static Func<double> UiMs;
void Start()
{
Driver.HumanSlot = -1;
@ -114,6 +120,7 @@ namespace Luneraria.View
}
window.Simulation += Driver.LastAdvanceMs;
window.Views += View.LastSyncMs;
window.Ui += UiMs != null ? UiMs() : 0.0;
if (match.Combat != null && window.FrameMs.Count % 30 == 1)
window.Units = Math.Max(window.Units, CountAlive(match.Combat));
_elapsed += Time.unscaledDeltaTime;
@ -167,9 +174,9 @@ namespace Luneraria.View
double[] sorted = window.FrameMs.OrderBy(v => v).ToArray();
double total = window.FrameMs.Sum();
text.AppendFormat(CultureInfo.InvariantCulture,
" {{\"view\": \"{0}\", \"frames\": {1}, \"fps\": {2:0.0}, \"frame_ms_avg\": {3:0.00}, \"frame_ms_median\": {4:0.00}, \"frame_ms_p95\": {5:0.00}, \"frame_ms_p99\": {6:0.00}, \"frame_ms_max\": {7:0.00}, \"frames_over_16_7_ms\": {8}, \"of_them_with_simulation_step\": {9}, \"gc_collections\": {10}, \"simulation_ms_per_frame\": {11:0.000}, \"views_ms_per_frame\": {12:0.000}, \"units_alive_max\": {13}}}{14}\n",
" {{\"view\": \"{0}\", \"frames\": {1}, \"fps\": {2:0.0}, \"frame_ms_avg\": {3:0.00}, \"frame_ms_median\": {4:0.00}, \"frame_ms_p95\": {5:0.00}, \"frame_ms_p99\": {6:0.00}, \"frame_ms_max\": {7:0.00}, \"frames_over_16_7_ms\": {8}, \"of_them_with_simulation_step\": {9}, \"gc_collections\": {10}, \"simulation_ms_per_frame\": {11:0.000}, \"views_ms_per_frame\": {12:0.000}, \"ui_ms_per_frame\": {15:0.000}, \"units_alive_max\": {13}}}{14}\n",
window.Label, sorted.Length, sorted.Length / (total / 1000.0), total / sorted.Length, sorted[sorted.Length / 2], sorted[(int)(sorted.Length * .95)], sorted[(int)(sorted.Length * .99)], sorted[sorted.Length - 1],
window.SlowFrames, window.SlowWithStep, window.GcEnd - window.GcStart, window.Simulation / sorted.Length, window.Views / sorted.Length, window.Units, i < _windows.Count - 1 ? "," : "");
window.SlowFrames, window.SlowWithStep, window.GcEnd - window.GcStart, window.Simulation / sorted.Length, window.Views / sorted.Length, window.Units, i < _windows.Count - 1 ? "," : "", window.Ui / sorted.Length);
text.AppendFormat(CultureInfo.InvariantCulture, " // timings {0}: main thread {1}, render thread {2}, gpu {3}, present wait {4}\n", window.Label,
Describe(window.MainThreadMs), Describe(window.RenderThreadMs), Describe(window.GpuMs), Describe(window.PresentWaitMs));
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using Luneraria.Core;
using UnityEngine;
@ -19,6 +20,8 @@ namespace Luneraria.View
public int StartRound = 1;
public int ExtraGold;
public string Profile = "";
/// <summary>Optional Bünde of the six seats (fixtures like Godot's start option "bunds"); empty seats follow the seed.</summary>
[System.NonSerialized] public string[] Bunds;
/// <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>
@ -37,7 +40,8 @@ namespace Luneraria.View
public void Restart()
{
Runner = new LuneMatchRunner(new MatchOptions { Seed = Seed, HumanSlot = HumanSlot }, StartRound, ExtraGold, Profile);
var options = new MatchOptions { Seed = Seed, HumanSlot = HumanSlot, Bunds = Bunds != null ? new List<string>(Bunds) : new List<string>() };
Runner = new LuneMatchRunner(options, StartRound, ExtraGold, Profile);
if (RecordTrace)
Runner.Trace = new LuneTrace();
SpeedIndex = 0;

View file

@ -6,8 +6,9 @@ namespace Luneraria.View
{
/// <summary>
/// Shows the driver's match (U2C): unit views per combat, building looks from the authoritative
/// HP, tower bolts from the towers' attack events. Runs after the driver in the same frame and
/// only reads the core.
/// HP with the Ancients' health bars, tower bolts from the towers' attack events, and the
/// Schlundkrone resting in its grotto while the grotto is open (U3, from lune_grotto_view.gd).
/// Runs after the driver in the same frame and only reads the core.
/// </summary>
[DefaultExecutionOrder(100)]
public sealed class LuneMatchView : MonoBehaviour
@ -28,7 +29,23 @@ namespace Luneraria.View
LuneCombat _combat;
Transform _unitRoot;
bool _synced;
GameObject _restingBoss;
readonly Dictionary<string, AncientBar> _ancientBars = new Dictionary<string, AncientBar>();
/// <summary>Health bar over an Ancient (lune_ancient_view.gd _build_bar: 3.4 m wide, 11.2 m up).</summary>
sealed class AncientBar
{
public Transform Root;
public Transform Fill;
}
public const float AncientBarWidth = 3.4f;
public const float AncientBarHeight = 11.2f;
/// <summary>Godot BOSS_LOCAL and view scale of the resting boss on its dais.</summary>
public const float RestingBossScale = 1.7f;
/// <summary>The resting Schlundkrone (tests); null until the grotto was first open.</summary>
public GameObject RestingBoss { get { return _restingBoss; } }
sealed class Bolt
{
public LineRenderer Line;
@ -96,6 +113,7 @@ namespace Luneraria.View
}
SyncBuildings(match);
SyncBolts(dt);
SyncRestingBoss(match);
_synced = true;
LastSyncMs = _clock.Elapsed.TotalMilliseconds;
}
@ -112,6 +130,8 @@ namespace Luneraria.View
if (_combat != null && _combat.Buildings.TryGetValue(definition.Id, out unit) && fighting)
hp = unit.Hp;
view.SetHealth((float)hp, (float)definition.MaxHp, _synced);
if (view.Tier == "core")
SyncAncientBar(view, (float)hp, (float)definition.MaxHp);
if (unit != null && unit.Kind == "tower" && _combat != null)
TowerAttack(view, unit);
}
@ -134,6 +154,88 @@ namespace Luneraria.View
_bolts.Add(new Bolt { Line = line, Target = target, From = view.Muzzle });
}
/// <summary>The bar shows while the Ancient is damaged and standing, always facing the camera.</summary>
void SyncAncientBar(LuneBuildingView view, float hp, float maxHp)
{
float fraction = Mathf.Clamp01(hp / Mathf.Max(1f, maxHp));
bool visible = hp > 0f && fraction < .999f;
_ancientBars.TryGetValue(view.BuildingId, out AncientBar bar);
if (bar == null)
{
if (!visible || Library == null || Library.Quad == null)
return;
bar = new AncientBar { Root = new GameObject("AncientBar").transform };
bar.Root.SetParent(view.transform, false);
bar.Root.localPosition = new Vector3(0f, AncientBarHeight, 0f);
Transform back = Quad(bar.Root, "Back", Library.BarBack);
back.localScale = new Vector3(AncientBarWidth + .1f, .26f, 1f);
bar.Fill = Quad(bar.Root, "Fill", Library.AncientBarFill[Mathf.Clamp(view.Team, 0, 1)]);
_ancientBars[view.BuildingId] = bar;
}
if (bar.Root.gameObject.activeSelf != visible)
bar.Root.gameObject.SetActive(visible);
if (!visible)
return;
bar.Fill.localScale = new Vector3(AncientBarWidth * Mathf.Max(.001f, fraction), .17f, 1f);
bar.Fill.localPosition = new Vector3(-AncientBarWidth * .5f * (1f - fraction), 0f, -.001f);
if (ViewCamera != null)
bar.Root.rotation = ViewCamera.transform.rotation;
}
Transform Quad(Transform parent, string label, Material material)
{
var go = new GameObject(label);
go.transform.SetParent(parent, false);
go.AddComponent<MeshFilter>().sharedMesh = Library.Quad;
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = material;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
renderer.receiveShadows = false;
return go.transform;
}
/// <summary>
/// The Schlundkrone rests on its dais while the grotto is open and no boss unit stands there
/// (lune_grotto_view.gd: state AVAILABLE without a fighter): the figure the fight uses,
/// neutral, facing the arena and the two stairs, in its idle clip. The grotto's light
/// states (runes, crystals, motes) are not ported yet.
/// </summary>
void SyncRestingBoss(LuneMatch match)
{
LuneUnit fighter = match.Phase == Phase.Combat && match.Combat != null ? match.Combat.GrottoBoss : null;
bool visible = match.Boss.Claim == null && match.GrottoAvailable() && fighter == null;
if (visible && _restingBoss == null)
_restingBoss = MakeRestingBoss(match.Map);
if (_restingBoss != null && _restingBoss.activeSelf != visible)
_restingBoss.SetActive(visible);
}
GameObject MakeRestingBoss(LuneMap map)
{
LuneUnitLibrary.UnitModel model = Library != null ? Library.Find(Library.BossModel) : null;
if (model == null || model.Prefab == null)
return null;
var root = new GameObject("RestingBoss");
root.transform.SetParent(transform, false);
Vec2 at = map.GrottoBossPoint();
root.transform.position = LuneSpace.World(at, (Library.Ground != null ? Library.Ground.StandHeight(at) : 0f) + .03f);
root.transform.rotation = LuneSpace.Facing(System.Math.Atan2(LuneMap.Axis.X, LuneMap.Axis.Y));
GameObject figure = Instantiate(model.Prefab, root.transform, false);
figure.name = "Model";
figure.SetActive(true);
figure.transform.localScale = Vector3.one * RestingBossScale;
Library.Materials.Dress(figure, 2);
var animator = figure.GetComponentInChildren<Animator>();
if (animator != null && model.Controller != null)
{
animator.runtimeAnimatorController = model.Controller;
animator.applyRootMotion = false;
if (animator.HasState(0, Animator.StringToHash("idle")))
animator.Play("idle", 0, 0f);
}
return root;
}
void SyncBolts(float dt)
{
for (int i = _bolts.Count - 1; i >= 0; i--)

View file

@ -39,6 +39,12 @@ namespace Luneraria.View
public Material BarBack;
public Material[] BarFill = new Material[3];
public Material[] Bolt = new Material[3];
/// <summary>Wide owner ring of a deployed boss (lune_unit_view.gd, G3): round for Blau, diamond for Rot.</summary>
public Mesh OwnerRingRound;
public Mesh OwnerRingDiamond;
public Material[] OwnerRing = new Material[2];
/// <summary>Fill of the Ancients' health bars (team light colour, lune_ancient_view.gd).</summary>
public Material[] AncientBarFill = new Material[2];
Dictionary<string, UnitModel> _index;
readonly Dictionary<GameObject, GameObject> _combined = new Dictionary<GameObject, GameObject>();

View file

@ -7,7 +7,8 @@ namespace Luneraria.View
/// <summary>
/// Visual twin of one LuneUnit (port of scripts/luneraria/view/lune_unit_view.gd). Reads the
/// authoritative state and never writes back: position between the last two fixed steps,
/// facing, clips from real combat events (attack and ability serials), team ring, health bar.
/// facing, clips from real combat events (attack and ability serials), team ring, health bar,
/// and for a deployed boss the wide owner ring of its team (G3: round for Blau, diamond for Rot).
/// </summary>
public sealed class LuneUnitView : MonoBehaviour
{
@ -19,6 +20,7 @@ namespace Luneraria.View
bool _skeletal;
float _height = 1.6f;
Transform _ring;
Transform _ownerRing;
Transform _bars;
Transform _fill;
int _lastAttack;
@ -60,6 +62,12 @@ namespace Luneraria.View
unit.Kind == "hero" ? library.HeroRing : library.TeamRing[team]);
_ring.localScale = new Vector3(scale, 1f, scale);
_ring.localPosition = new Vector3(0f, .05f, 0f);
if (unit.Kind == "boss" && unit.Team <= 1 && library.OwnerRingRound != null)
{
_ownerRing = Marker("OwnerRing", unit.Team == 0 ? library.OwnerRingRound : library.OwnerRingDiamond, library.OwnerRing[unit.Team]);
_ownerRing.localScale = new Vector3(scale, 1f, scale);
_ownerRing.localPosition = new Vector3(0f, .07f, 0f);
}
_bars = new GameObject("Bars").transform;
_bars.SetParent(transform, false);
_bars.localPosition = new Vector3(0f, _height + .25f, 0f);
@ -98,6 +106,9 @@ namespace Luneraria.View
/// <summary>Where tower bolts hit this figure (upper body).</summary>
public Vector3 AimPoint { get { return transform.position + Vector3.up * _height * .55f; } }
/// <summary>Anchor of the name tag above the head (Godot tag.position.y = height + .55).</summary>
public Vector3 TagPoint { get { return transform.position + Vector3.up * (_height + .55f); } }
public void Sync(float alpha, float dt, Transform camera)
{
_time += dt;
@ -124,6 +135,8 @@ namespace Luneraria.View
if (camera != null)
_bars.rotation = camera.rotation;
_ring.gameObject.SetActive(Unit.Alive);
if (_ownerRing != null)
_ownerRing.gameObject.SetActive(Unit.Alive);
// Rigid figures have no death clip; skeletal ones stay down in their death pose.
if (!_skeletal)
_model.gameObject.SetActive(Unit.Alive);

View file

@ -41,6 +41,7 @@ Die Szene ändert kein Verhalten. Der Host treibt den Kern im festen Takt; die A
| Wert | Warum kein Regelunterschied |
| --- | --- |
| Blickrichtung (`LuneUnit.Facing`) | Der Kern schreibt sie nur (Bewegungsrichtung ab 1 mm Schritt); keine Regel liest sie. Die Darstellung darf sie glätten. |
| Schriftgewichte (U3D) | Das Theme der Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet sie aber nie an: `SaumwachtTheme.font` schreibt `variation_opentype = {"wght": …}` mit dem String-Schlüssel „wght“, den Godot 4.7.2 ignoriert. G3 zeigt Regular 400, der Port ebenso. Die Rollen bleiben getrennt, damit U5 echte Gewichte vergeben kann ([U3-UI.md](U3-UI.md#theme-symbole-schriften)). |
## Technische Repräsentation

View file

@ -55,6 +55,18 @@ Hinweis: Die Projektdateien des .NET-Harness (`tools/dotnet/*/*.csproj`) fehlten
| Offen | Draw Calls messen (U5); Windows-/Linux-Player (Build-Module) |
| Commit | `luneraria/u2`: Batch-Import `3ca38a8` (Modelle direkt in Git) |
## U3 bis U5
## U3 · G3-HUD portieren
U3 (G3-HUD portieren) ist der nächste Schritt; U4 und U5 sind noch nicht begonnen.
| Punkt | Ergebnis |
| --- | --- |
| Tests | EditMode 23/23 (Kern 402 Prüfungen, Sichtmodell, Bildraten-Logik). PlayMode 13/13: Verify A–I **718 Prüfungen, 0 Fehler** (Port von `verify_g3` 715/715 plus Teil I), Szene bei echten Bildraten, Vertical Function Test, Capture, UI-Toolkit-Probe. Bildwerkzeuge und Spieltest sind `Explicit`. Protokolle `logs/u3/`, Summen `tools/out/hud-verify-*.txt` |
| Parität | Bot-Partie Seed 2 `DCE95C96556F3A1A` (12.690 Schritte), Seed 9 ab Runde 21 `7B8E43AEFC0D60C5` (4.195 Schritte), beide unverändert gleich dem Kern |
| Bild | `screenshots/u3/motive-1920x1080/` (14 G3-Motive), `pruefung/` (Teile B–F), `aufloesungen/` (1152×720 … 2560×1440), `r1-*.png`/`r2-*.png` (Spieltest) |
| Leistung | macOS-Player mit HUD, 116 Einheiten: 169 fps (Übersicht) und 218 fps (Mid nah). HUD-Skripte **0,090 bzw. 0,078 ms je Bild**; Simulation 0,09/0,04 ms, Ansichten 0,31/0,33 ms. Ausreißer beim Present (p95 10–13 ms) im Rahmen der U2D-Streuung. Rohdaten `logs/u3/player-benchmark-macos.json` |
| Bekannte Unterschiede | Godots deklarierte Schriftgewichte werden in der Referenz nie angewendet (String-Schlüssel „wght“); Port zeigt wie G3 Regular 400. Kein Hauptmenü, kein Grottenlicht. Details [U3-UI.md](U3-UI.md) und [KNOWN-BEHAVIOR-DIFFERENCES.md](KNOWN-BEHAVIOR-DIFFERENCES.md) |
| Offen | Hauptmenü und Netzwerk (U4); Schriftgewichte, Grottenlicht und weitere Darstellung (U5); Windows-/Linux-Player (Build-Module) |
| Commits | `luneraria/u3`: U3A `4dee9b1`, U3B/U3C `2b86c0d`, U3D (dieser Stand) |
## U4 und U5
U4 (Netzwerk) und U5 (Visuelle Neuausrichtung) sind noch nicht begonnen.

View file

@ -1,6 +1,6 @@
# Port-Status · Champions of Luneraria in Unity
Stand: 1. Oktober 2026.
Stand: 2. Oktober 2026.
Referenz ist der Godot-Stand `luneraria/g3` (`1b540f2`) in `../saumwacht-godot`; er wird nicht mehr verändert. Engine: Unity 6.3 LTS (6000.3.20f1), Renderer URP. HDRP ist ausgeschlossen, bis ein Vergleich mit einer echten Szene etwas anderes nahelegt.
@ -11,8 +11,8 @@ Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattfo
| U1 · C#-Simulation | **fertig** (Referenzmeilenstein, `luneraria/u1` = `4d6ed17`): 402/402 Prüfungen, Golden-Parität mit Godot | [U1-RULES.md](U1-RULES.md) |
| U1.5 · Unity-Integrationsprüfung | **fertig**: Kern im Unity-Editor 402/402 (Release und Debug), Golden bitgleich nach expliziter float32-Rundung in `Vec2`, Editor-Benchmark | [U1.5-UNITY.md](U1.5-UNITY.md) |
| U2 · Unity-Spielwelt | **fertig**: U2A Blender→FBX-Pipeline (9 Assets geprüft), U2B G1-Karte mit Materialsystem, U2C fester Takt und Ansichten (bildratenunabhängig nachgewiesen), U2D vollständige Bot-Partien sichtbar und gleich dem Kern; danach Batch-Import aller 86 Modelle, Einheiten mit eigenen Modellen | [U2-WORLD.md](U2-WORLD.md) |
| U3 · G3-HUD portieren | nächster Schritt | [U3-UI.md](U3-UI.md) |
| U4 · Netzwerk | nicht begonnen; M4-Umfang ist erfasst | [U4-NETWORK.md](U4-NETWORK.md) |
| U3 · G3-HUD portieren | **fertig** (`luneraria/u3`): UI Toolkit, Sichtmodell mit Privatsphäre, menschlicher Sitz, alle HUD-Bereiche, Eingabe und Befehle, Prüfung 718/0, 14 G3-Motive, Spieltest, Leistung mit HUD | [U3-UI.md](U3-UI.md) |
| U4 · Netzwerk | nächster Schritt; M4-Umfang ist erfasst | [U4-NETWORK.md](U4-NETWORK.md) |
| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nicht begonnen | [U5-VISUAL.md](U5-VISUAL.md) |
QA je Meilenstein: [PORT-QA.md](PORT-QA.md).
@ -36,6 +36,7 @@ Bekannte Verhaltensunterschiede, auch die bewusst übernommenen Referenzfehler:
- **Prozedurale Welt gebacken (U2B):** Gelände und Vegetation der G1-Karte hängen an Godots Rauschen und Zufallsgenerator; ein Godot-Werkzeug backt die Welt aus dem Referenzgenerator, Unity baut daraus die Szene. U5 bekommt eine neue Weltquelle.
- **Koordinaten:** Unity = (−x, Höhe, y) der Kartenpunkte; FBX-Pipeline, Welt und Kamera nutzen dieselbe Umrechnung, das Bild entspricht Godots.
- **Host-Schleife (U2C):** fester Takt mit Akkumulator; die Bildrate bestimmt nur die Zahl der Schritte je Bild. Nachweis über Fingerabdrücke der Partie.
- **Schriftgewichte wie in G3 (U3D):** Die Referenz deklariert Gewichte, wendet sie aber nie an (String-Schlüssel „wght“ wird von Godot ignoriert). Der Port zeigt wie G3 Regular 400. Die Stilrollen bleiben getrennt, damit U5 echte Gewichte vergeben kann.
- **Modelle direkt in Git** (1. Oktober 2026): Auch die Modelle des Batch-Imports liegen wie die U2A-Modelle im Repository, ohne Git LFS. Keine Datei ist größer als 40 MB; das Repository wächst auf etwa 240 MB.
- **Projektort außerhalb von iCloud Drive** (1. Oktober 2026): Der Arbeitsordner `TowerDefense` zieht von `~/Documents/Projekte` nach `~/Developer`. iCloud Drive synchronisierte `~/Documents` und legte Konfliktkopien an, die einen Player unbrauchbar machten (U2-WORLD.md, Batch-Import). Der Schutz in `LunePlayerBuild` bleibt.

View file

@ -7,7 +7,7 @@ Stand: 1. Oktober 2026.
| 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 | **fertig** |
| U3C | Eingabe und Befehle: Tasten, Kamera-Ziele, Auswahl im Feld, Bereit-Sperre, Planungsvorschau | **fertig** |
| U3D | Prüfung (Teile A–H von `verify_g3`), Spieltest über die echte Oberfläche, Bilder, Leistung | in Arbeit |
| U3D | Prüfung (Teile A–H von `verify_g3`), Spieltest über die echte Oberfläche, Bilder, Leistung | **fertig** |
Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/luneraria/G3-HUD.md`, Commit `1b540f2`). Es wird übertragen, nicht neu erfunden.
@ -85,10 +85,10 @@ Eine Abweichung: Im öffentlichen Digest (Testhilfe) steht beim Boss die öffent
- `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.
- Unitys Textsystem hat keine Gewichtsachse. FreeType lädt die benannten Instanzen der variablen Fonts über den Face-Index (Instanz ≪ 16).
- In Cantarell fehlt „★“ (Godot nahm eine Ersatzschrift). Das HUD zeichnet den Stern als Symbol.
- **Gewichte (U3D korrigiert):** Das Theme der Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet sie aber nie an. `SaumwachtTheme.font` schreibt `variation_opentype = {"wght": weight}` mit dem *String*-Schlüssel „wght“; Godot 4.7.2 ignoriert den (gemessen: gleiche Fortschritte bei 400, 560 und 700; nur der Integer-Tag ändert sie). G3 zeigt beide Fonts in ihrer Grundinstanz Regular 400, und so auch der Port. Die Rollen (`LuneFace`) bleiben getrennt, damit U5 echte Gewichte vergeben kann.
- **Laufweite und Konturen (U3D):** Godots `spacing_glyph` (Pixel je Glyph) wird als UI-Toolkit-`letterSpacing` umgerechnet (Hundertstel der Schriftgröße). Titel, Welle und Gold bekommen Godots dunklen Saum (`outline_size`) als weichen Schatten statt einer TextCore-Kontur — die Kontur liegt auf beiden Seiten der Glyphenkante und frisst die dünnen Striche der Regular-Schnitte, Godots Outline wächst nur nach außen.
### Prüfung U3A
@ -149,3 +149,98 @@ Die Szenen- und Bibliotheksbauer arbeiten jetzt idempotent:
- Vorher erzeugte jeder Szenenbau neue interne IDs in 15 Dateien, obwohl der Inhalt gleich blieb.
- Die Szene selbst entsteht weiter bei jedem Bau neu; Unity vergibt dabei neue Objekt-IDs.
- Zwei Bauten hintereinander ergeben inhaltlich dieselbe Szene. Ersetzt man die IDs durch Platzhalter, sind alle 2840 Objekteinträge gleich; nur ihre Reihenfolge in der Datei ändert sich, weil Unity nach ID sortiert.
## U3D · Prüfung und Abnahme
### Prüfung (Port von `verify_g3`, Teile A–I)
`HudVerifyTests` zählt jede Prüfung und schreibt die Summen nach `tools/out/hud-verify-*.txt`. Die Referenz `verify_g3` der Godot-Seite zählt 715 Prüfungen; der Port hat denselben Umfang plus Teil I (Feld um das HUD, U3-Zugabe).
| Teil | Inhalt | Prüfungen | Fehler |
| --- | --- | ---: | ---: |
| A | Layout 1152×720 … 2560×1440 und Ultrawide, UI-Skalen 90–130 % | 585 | 0 |
| B | Partiestatus: Ancients, Türme, Lanes, Wellen mit zwei und drei Ziffern, nur eigenes Gold | 21 | 0 |
| C | Held, Items, Talente, normale Einheiten (ohne Itemslots), Lane-Befehle mit Grottenwarnung | 23 | 0 |
| D | Angriffschampions (zeitlich), Bossgrotte in allen Zuständen, Lane des Wählenden, Banner | 16 | 0 |
| E | Bereit-Sperre, Bots, Privatsphäre, abgebrochener Menschensitz | 17 | 0 |
| F | Eingabe: Minimap, Lane-Klicks, Tasten, Feldklicks, kein Klick-Durchgriff, Tooltips | 18 | 0 |
| G | Ereignisse, kritische Warnungen, Bewegung reduzieren | 14 | 0 |
| H | Aktualisierungsraten, Porträt-Cache | 5 | 0 |
| I | Feld (U3-Zugabe): Aufstellungsflächen, Grotteneingang, ruhender Boss, Namensschilder, Eigentümer-Ring, Ancient-Balken | 19 | 0 |
| **Summe** | | **718** | **0** |
Dazu EditMode 23/23 (Kern 402 Prüfungen, Sichtmodell, Bildraten-Logik) und PlayMode 13/13 (Szene bei echten Bildraten, Vertical Function Test, Capture, Verify A–I, UI-Toolkit-Probe). Die Bildwerkzeuge `HudViewsTests` und der Spieltest `HudPlaytest` sind `Explicit` und laufen nur gezielt. Fingerabdrücke der Bot-Partien unverändert: Seed 2 `DCE95C96556F3A1A`, Seed 9 ab Runde 21 `7B8E43AEFC0D60C5`.
### Bilder
| Ordner | Inhalt |
| --- | --- |
| `screenshots/u3/motive-1920x1080/` | die 14 G3-Motive (`HudViewsTests`, Port von `g3_views.gd`): Planung, Held, Items, Einheit, Champion, Boss verfügbar/angekündigt, Kampf Top/Mid/Bottom, eigener und gegnerischer Ancient kritisch, Turm zerstört, große Kampfansicht |
| `screenshots/u3/pruefung/` | Schlüsselbilder der Teile B–F (Status, Held, Items, Einheit, Champions, Boss, Tooltip) |
| `screenshots/u3/aufloesungen/` | Kampf bei 1152×720, 1280×720, 2560×1080, 2560×1440 (Teil A) |
| `screenshots/u3/r1-*.png`, `r2-*.png` | Spieltest über die echte Oberfläche: Lane-Tooltip, Kampfbeginn, Pause, Karten-Tooltip, Kampf Bottom |
Die 14 Motive liegen auch als JPEG bei 1280×720, 2560×1080 und 2560×1440 (Repository-Größe). Vergleichsbilder der Referenz: `../saumwacht-godot/docs/luneraria/screenshots/`.
### Spieltest über die echte Oberfläche
`HudPlaytest` (Port von `g3_playtest.gd`) treibt die echte Partie nur über Eingaben: Klicks an Pixelkoordinaten von 1920×1080, Tasten, Zeigerbewegungen für Tooltips. Ein Tester schreibt Befehlsstapel nach `/tmp/lune_play/cmd-N.txt` und bekommt danach einen Screenshot zurück; der Protokoll aller Befehle mit Runde und Phase steht in `logs/u3/playtest-log.txt`.
Geprüft in einem Lauf über drei Runden: Lane-Tooltip in der Planung, Bereit und Kampfbeginn, Pause im Kampf (P), Karten-Tooltip, Tastenkauf (4), Lane-Wechsel per F3/F4 und Kamera nach Bottom. Der Port hat noch kein Hauptmenü; der Spieltest startet deshalb in der Partie der G1-Szene.
### Leistung
**Player** (macOS, Mono, Release; Apple M5, Metal; 1920 × 1080 im Fenster, VSync aus): Seed 9 in Runde 21, Spieltempo 1×, gemessen ab 10 s Kampf mit `LuneBenchmark`. Die Szene trägt seit U3 das HUD (Sitz 1 der Bot-Partie); `ui_ms_per_frame` zählt die HUD-Skripte (Tick des Panels plus Spielcontroller).
| Ansicht | Einheiten | fps (Mittel) | Bildzeit Median | p95 | Sim | Views | **UI** | Hauptthread (Mittel/p95) |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| ganze Karte (Zoom 64) | 116 | 169 | 4,60 ms | 13,05 ms | 0,094 ms | 0,309 ms | **0,090 ms** | 2,77 / 3,94 ms |
| Mid-Lane nah (Zoom 22) | 63 | 218 | 3,90 ms | 9,60 ms | 0,044 ms | 0,332 ms | **0,078 ms** | 2,35 / 3,06 ms |
Das HUD kostet unter 0,1 ms je Bild und liegt damit unter Simulation und Ansichten. Gegenüber den U2D-Läufen ohne HUD (158–160 bzw. 205–207 fps) ist die Bildrate im Rahmen der Laufstreuung; die Ausreißer beim Present (p95 10–13 ms) sind kürzer als in den früheren U2D-Spannen (18–26 ms), ohne dass sich daran etwas geändert hätte — das bleibt Laufstreuung und offene U5-Frage.
Rohdaten: `logs/u3/player-benchmark-macos.json`. Befehle siehe [U2-WORLD.md](U2-WORLD.md#leistung) (Build `LunePlayerBuild.BuildMac`, Lauf mit `-luneBenchmark`).
### Feld um das HUD (Zugabe aus dem Motivvergleich)
Der Vergleich der 14 Motive mit der Referenz brachte Darstellungen hervor, die zum G3-Bild gehören, aber noch nicht im Port standen:
- **Namensschilder** (`LuneFieldTags`): Helden mit Stufe in Heldengold, Angriffschampions und der Boss in der hellen Teamfarbe. Ein eingesetzter Boss nennt, für wen er kämpft („SCHLUNDKRONE · für Blau“) — G3s Antwort auf die Farbnähe seiner Kristalle und Blaus Armeen. Feste Bildschirmgröße wie Godots `fixed_size`-Schilder, hinter allen HUD-Bereichen, ohne Maus.
- **Eigentümer-Ring** am eingesetzten Boss in der Teamfarbe.
- **Ancient-Balken** über den Ancients, solange sie beschädigt stehen (3,4 m breit, 11,2 m hoch, kameragerichtet).
- **Ruhende Schlundkrone** auf dem Podest, solange die Grotte offen ist und kein Kämpfer dort steht (`lune_grotto_view.gd`), neutral, in ihrem Idle-Clip. Die Lichtzustände der Grotte (Runen, Kristalle, Funken) sind noch nicht übertragen.
- **Grotteneingang** der eigenen Seite als Ring, solange die Grotte offen ist.
- **Aufstellungsflächen** als eigene Objekte (vormals nur Renderer-Sichtbarkeit), beleuchtet, wenn die Lane eigene Einheiten hat.
### Befunde und Korrekturen
| Befund | Maßnahme |
| --- | --- |
| Godot wendet die deklarierten Schriftgewichte nie an (String-Schlüssel „wght“ wird ignoriert) | Port zeigt wie G3 Regular 400; `Cinzel-Bold` ersetzt durch `Cinzel-Regular`; Rollen bleiben für U5 |
| TextCore-Kontur frisst die dünnen Striche der Regular-Schnitte | Godots dunkler Saum als weicher Schatten; Laufweite aus `spacing_glyph` umgerechnet |
| Layout der rechten Spalte und des Banners nutzte geschätzte Höhen | Folgt den echten Höhen von Top-Panel, Bottom-Leiste und Systemzeile (`GeometryChangedEvent`) |
| Kamera startete in jeder Partie in derselben Ansicht | Start wie Godot `_ready`: eigene Lane in Planungsdistanz, Bot-Partie von oben |
| Eigentümer-Ringe und Ancient-Balken fehlten in den Motiven | `OwnerRingRound`/`OwnerRingDiamond`, `AncientBarFill_*`, Balken und ruhender Boss in der Ansicht |
### Abnahme U3
| Vorgabe | Stand |
| --- | --- |
| G3-HUD übertragen (Ancients, Wellen, Gold, Lanes, Türme, Team, Einheitenkarten, Armee, Held mit Items und Talenten, Angriffschampions, nächste Welle, Bossstatus, Minimap, Bereit, Tooltips, Ereignisse, Optionen, Partieende) | erfüllt (U3A–C) |
| Sichtmodell mit denselben Privatsphäre-Regeln wie `LuneHudModel` | erfüllt (U3A) |
| Eingabe und Befehle wie in G3, Befehle über dieselbe Schnittstelle wie die Bots | erfüllt (U3C) |
| Offene G3-Punkte: Champion-Etikett, Typ-Symbole, Armeewert, Helden auf der Minimap | erfüllt (U3B) |
| Prüfung Teile A–H von `verify_g3` | erfüllt, 718 Prüfungen mit Teil I, 0 Fehler |
| Spieltest über die echte Oberfläche | erfüllt (Runden 1–3, siehe oben) |
| Bilder der 14 Motive und der Auflösungen | erfüllt |
| Leistung gemessen, einschließlich HUD-Kosten | erfüllt (`ui_ms_per_frame` 0,08–0,09) |
| Bot-Partien und Fingerabdrücke unverändert | erfüllt |
| Bekannte Unterschiede dokumentiert | erfüllt (siehe unten und [KNOWN-BEHAVIOR-DIFFERENCES.md](KNOWN-BEHAVIOR-DIFFERENCES.md)) |
### Bekannte Grenzen
- **Kein Hauptmenü** im Port; „Menü“ beendet das Spiel nach Rückfrage. Das Menü mit Netzwerk-Host/Join gehört zu U4.
- **Grottenlicht** (Runen, Kristalle, Funken) fehlt; nur die ruhende Schlundkrone steht dort.
- **Geplante Helden** behalten ihren reservierten Befehlsplatz, solange keiner dieser Helden spielbar ist.
- **Windows- und Linux-Player** weiterhin an die fehlenden Build-Module gebunden ([U1.5-UNITY.md](U1.5-UNITY.md)).
- **Schriftgewichte** wie in G3 absichtlich Regular 400; echte Gewichte sind eine U5-Entscheidung.

View file

@ -0,0 +1,14 @@
{
"build": "OSXPlayer player, 6000.3.20f1, release",
"device": "Mac17,2; Apple M5; Apple M5",
"graphics_api": "Metal",
"resolution": "1920x1080",
"vsync": 0,
"match": "seed 9 from round 21 (fixture high_wave_21), speed 1x, measured after 10 s of combat",
"windows": [
{"view": "overview (zoom 64)", "frames": 3384, "fps": 169.2, "frame_ms_avg": 5.91, "frame_ms_median": 4.60, "frame_ms_p95": 13.05, "frame_ms_p99": 20.17, "frame_ms_max": 24.97, "frames_over_16_7_ms": 74, "of_them_with_simulation_step": 3, "gc_collections": 28, "simulation_ms_per_frame": 0.094, "views_ms_per_frame": 0.309, "ui_ms_per_frame": 0.090, "units_alive_max": 116},
// timings overview (zoom 64): main thread avg 2.77 / median 2.75 / p95 3.94 ms, render thread avg 3.22 / median 3.23 / p95 3.59 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 3.14 / median 1.45 / p95 10.49 ms
{"view": "mid lane close (zoom 22)", "frames": 2180, "fps": 218.0, "frame_ms_avg": 4.59, "frame_ms_median": 3.90, "frame_ms_p95": 9.60, "frame_ms_p99": 14.09, "frame_ms_max": 20.30, "frames_over_16_7_ms": 3, "of_them_with_simulation_step": 0, "gc_collections": 10, "simulation_ms_per_frame": 0.044, "views_ms_per_frame": 0.332, "ui_ms_per_frame": 0.078, "units_alive_max": 63}
// timings mid lane close (zoom 22): main thread avg 2.35 / median 2.40 / p95 3.06 ms, render thread avg 2.88 / median 2.85 / p95 3.32 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 2.24 / median 1.46 / p95 7.33 ms
]
}

View file

@ -0,0 +1,31 @@
16.1 s #1 [round=1 phase=PLANUNG] click 993 917
16.1 s #1 [round=1 phase=PLANUNG] wait 0.6
16.7 s #1 [round=1 phase=PLANUNG] move 190 205
16.7 s #1 [round=1 phase=PLANUNG] wait 0.8
17.5 s #1 [round=1 phase=PLANUNG] shot r1-lane-tooltip
90.0 s #2 [round=1 phase=PLANUNG] move 1000 450
90.0 s #2 [round=1 phase=PLANUNG] key SPACE
90.2 s #2 [round=1 phase=KAMPF] wait 1.5
91.7 s #2 [round=1 phase=KAMPF] shot r1-kampfbeginn
92.4 s #2 [round=1 phase=KAMPF] click 1720 33
92.4 s #2 [round=1 phase=KAMPF] key F3
92.4 s #2 [round=1 phase=KAMPF] wait 6
115.9 s #3 [round=2 phase=PLANUNG] key P
115.9 s #3 [round=2 phase=PLANUNG] wait 0.6
116.5 s #3 [round=2 phase=PLANUNG] shot r1-pause
117.1 s #3 [round=2 phase=PLANUNG] key P
117.1 s #3 [round=2 phase=PLANUNG] wait 14
150.8 s #4 [round=2 phase=PLANUNG] move 1000 450
150.8 s #4 [round=2 phase=PLANUNG] key 4
150.8 s #4 [round=2 phase=PLANUNG] wait 0.4
151.2 s #4 [round=2 phase=PLANUNG] move 424 975
151.2 s #4 [round=2 phase=PLANUNG] wait 0.8
152.0 s #4 [round=2 phase=PLANUNG] shot r2-karte-tooltip
171.0 s #5 [round=2 phase=PLANUNG] move 1000 450
171.0 s #5 [round=2 phase=PLANUNG] key SPACE
171.1 s #5 [round=2 phase=KAMPF] wait 2
173.1 s #5 [round=2 phase=KAMPF] key F4
173.1 s #5 [round=2 phase=KAMPF] wait 3
176.1 s #5 [round=2 phase=KAMPF] shot r2-kampf-bottom
176.2 s #5 [round=2 phase=KAMPF] wait 18
210.6 s #6 [round=3 phase=PLANUNG] wait 15

Binary file not shown.

Before

Width:  |  Height:  |  Size: 997 KiB

After

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB