From 38855e7a7bdb00f07914aa0baf741e1e58f91259 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 13:39:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20DatWidgetFactory=20=E2=80=94=20Ty?= =?UTF-8?q?pe=E2=86=92widget=20hybrid=20+=20meter=20slice=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hybrid factory mapping ElementInfo.Type to a behavioral widget or the UiDatElement generic fallback. Type 7 (UIElement_Meter) → UiMeter with back/front 3-slice ids populated from grandchild image elements; Type 12 (style prototypes / BaseElement stores) → null so the importer skips them; all other types → UiDatElement. Rect + anchors are set on every returned widget via ElementReader.ToAnchors. BuildMeter walks two levels of the element tree: the two Type-3 slice containers ordered by ReadOrder (back behind, front on top), then within each container the image children that carry a DirectState ("" key) ordered by X for left-cap/center-tile/right-cap. The expand-detail overlay (present in the front container with only named ShowDetail/ HideDetail states and no "" entry) is excluded by the TryGetValue("") filter automatically — no name-matching needed. Fill/Label providers are intentionally NOT set here; Task 6 (VitalsController) binds them to live stat data. 5 TDD tests: Type7→UiMeter, UnknownType→UiDatElement, Type12→null, rect+anchors propagation, and meter slice extraction with overlay exclusion. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 167 ++++++++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 112 ++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/AcDream.App/UI/Layout/DatWidgetFactory.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs new file mode 100644 index 00000000..e8791e44 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -0,0 +1,167 @@ +using System; +using System.Linq; + +namespace AcDream.App.UI.Layout; + +/// +/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim +/// algorithm ports); everything else (and unknown Types) falls back to +/// . +/// +/// +/// Type 12 (style prototype / BaseElement store) is never instantiated — +/// returns null and the importer skips it. +/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8. +/// +/// +/// +/// The meter's back/front 3-slice sprite ids live on grandchild image elements, +/// NOT on the meter element itself (format doc §11). +/// walks two layers down to extract them: the two Type-3 container children +/// ordered by (back behind = lower, front +/// on top = higher), then within each container the image children that carry +/// a DirectState ("" key) sprite, ordered by their X position to obtain +/// left-cap / center-tile / right-cap. +/// +/// +/// +/// The expand-detail overlay present in the front container carries ONLY named +/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the +/// TryGetValue("") filter in excludes it +/// automatically. +/// +/// +public static class DatWidgetFactory +{ + /// + /// Creates the for , sets its + /// rect (Left/Top/Width/Height) and Anchors, and returns it. + /// + /// Resolved, merged element snapshot from the LayoutDesc importer. + /// RenderSurface id → (GL tex handle, pixel width, pixel height). + /// Returns (0,0,0) when the texture is not yet uploaded. + /// Retail UI font for the meter's "cur/max" number overlay. + /// May be null pre-load — the meter falls back to the debug bitmap font. + /// The widget, or null for a Type-12 style prototype (caller skips it). + public static UiElement? Create(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + // Type 12 = zero-size style prototype / BaseElement store referenced by + // BaseLayoutId. These are property bags, never rendered. See format doc §8 + // ("style prototypes are Type 12 which must be skipped") and Correction 8. + if (info.Type == 12) return null; + + UiElement e = info.Type switch + { + 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter + _ => new UiDatElement(info, resolve), // generic fallback for all other types + }; + + // Propagate position + size (pixel-exact from the dat). + e.Left = info.X; + e.Top = info.Y; + e.Width = info.Width; + e.Height = info.Height; + + // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the + // UI layout engine uses for reflow. + e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); + + return e; + } + + // ── Meter ──────────────────────────────────────────────────────────────── + + /// + /// Builds a and populates its six 3-slice sprite ids by + /// reading the meter's grandchild image elements (format doc §11). + /// + /// + /// Structure the importer produces for each meter (UIElement_Meter): + /// + /// meter (Type 7) + /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind) + /// │ ├── left-cap image (DirectState "" → File = back-left sprite) + /// │ ├── center image (DirectState "" → File = back-tile sprite) + /// │ └── right-cap image (DirectState "" → File = back-right sprite) + /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top) + /// │ ├── left-cap image (→ front-left sprite) + /// │ ├── center image (→ front-tile sprite) + /// │ ├── right-cap image (→ front-right sprite) + /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) + /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6) + /// + /// + /// + /// + /// and are NOT set here. + /// They are bound to the live stat providers in Task 6 (VitalsController). + /// + /// + private static UiMeter BuildMeter(ElementInfo info, + Func resolve, UiDatFont? datFont) + { + var m = new UiMeter + { + SpriteResolve = resolve, + DatFont = datFont, + }; + + // The two 3-slice containers are Type-3 children of the meter element. + // ReadOrder determines draw order: the back track has a LOWER ReadOrder + // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top). + var containers = info.Children + .Where(c => c.Type == 3) + .OrderBy(c => c.ReadOrder) + .ToList(); + + if (containers.Count >= 1) + { + var (l, t, r) = SliceIds(containers[0]); + m.BackLeft = l; + m.BackTile = t; + m.BackRight = r; + } + + if (containers.Count >= 2) + { + var (l, t, r) = SliceIds(containers[1]); + m.FrontLeft = l; + m.FrontTile = t; + m.FrontRight = r; + } + + return m; + } + + /// + /// Returns the (left, tile, right) sprite ids for a 3-slice container, + /// extracting them from the container's image children that carry a DirectState + /// ("" key) with a non-zero file id, ordered left-to-right by their X position. + /// + /// + /// Children that carry ONLY named states (e.g. the expand-detail overlay with + /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically + /// because for "" returns + /// false. + /// + /// + private static (uint left, uint tile, uint right) SliceIds(ElementInfo container) + { + // Only children that have a non-zero DirectState image are slice candidates. + // The expand-detail overlay has NO DirectState entry, so it's excluded here. + var slices = container.Children + .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0) + .OrderBy(c => c.X) + .ToList(); + + static uint File(ElementInfo e) + => e.StateMedia.TryGetValue("", out var med) ? med.File : 0u; + + uint left = slices.Count > 0 ? File(slices[0]) : 0u; + uint tile = slices.Count > 1 ? File(slices[1]) : 0u; + uint right = slices.Count > 2 ? File(slices[2]) : 0u; + + return (left, tile, right); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs new file mode 100644 index 00000000..6a1ef9c1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +public class DatWidgetFactoryTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Type 7 → UiMeter ───────────────────────────────────────────── + + [Fact] + public void Type7_Meter_MakesUiMeter() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(e); + } + + // ── Test 3: Type 12 → null (style prototype, never rendered) ───────────── + + [Fact] + public void Type12_StylePrototype_ReturnsNull() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null); + Assert.Null(e); + } + + // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── + + /// + /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=2 should have + /// its rect + anchors copied onto the returned widget. + /// Left=1 (near-pin → AnchorEdges.Left), Top=1 (near-pin → AnchorEdges.Top), + /// Right=2 (far-pin → AnchorEdges.Right), Bottom=0 (no anchor → neither). + /// Combined: Left | Top | Right. + /// + [Fact] + public void RectAndAnchors_SetFromElementInfo() + { + var info = new ElementInfo + { + Type = 3, + X = 5, Y = 21, + Width = 150, Height = 16, + Left = 1, Top = 1, + Right = 2, Bottom = 0, + }; + var e = DatWidgetFactory.Create(info, NoTex, null)!; + Assert.Equal(5f, e.Left); + Assert.Equal(21f, e.Top); + Assert.Equal(150f, e.Width); + Assert.Equal(16f, e.Height); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); + } + + // ── Test 5: Meter slice extraction (the important one) ─────────────────── + + /// + /// A meter (Type 7) whose two Type-3 containers each carry 3 image children + /// (ordered by X, bearing a DirectState "" sprite), plus the front container + /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state — + /// that overlay must be excluded from the slice count. + /// + [Fact] + public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay() + { + // Slice ids sourced from format doc §11 — real health-bar ids. + const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u; + const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u; + const uint OverlayFile = 0x06007490u; + + // Back container (ReadOrder 0 — drawn first / behind) + var backChild = new ElementInfo { Type = 3, ReadOrder = 0 }; + backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } }); + backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } }); + backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } }); + + // Front container (ReadOrder 1 — drawn on top) + var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 }; + frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } }); + frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } }); + // Expand-detail overlay: named state only — NO DirectState "" — must be ignored. + frontChild.Children.Add(new ElementInfo + { + X = 0, + StateMedia = { ["ShowDetail"] = (OverlayFile, 3) } + }); + + var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 }; + meter.Children.Add(backChild); + meter.Children.Add(frontChild); + + var e = DatWidgetFactory.Create(meter, NoTex, null); + + var m = Assert.IsType(e); + Assert.Equal(BackL, m.BackLeft); + Assert.Equal(BackT, m.BackTile); + Assert.Equal(BackR, m.BackRight); + Assert.Equal(FrontL, m.FrontLeft); + Assert.Equal(FrontT, m.FrontTile); + Assert.Equal(FrontR, m.FrontRight); + } +}