feat(D.2b): DatWidgetFactory — Type→widget hybrid + meter slice extraction
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 <noreply@anthropic.com>
This commit is contained in:
parent
70dc391c41
commit
38855e7a7b
2 changed files with 279 additions and 0 deletions
167
src/AcDream.App/UI/Layout/DatWidgetFactory.cs
Normal file
167
src/AcDream.App/UI/Layout/DatWidgetFactory.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
||||||
|
/// algorithm ports); everything else (and unknown Types) falls back to
|
||||||
|
/// <see cref="UiDatElement"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Type 12 (style prototype / BaseElement store) is never instantiated —
|
||||||
|
/// <see cref="Create"/> returns null and the importer skips it.
|
||||||
|
/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
||||||
|
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
||||||
|
/// walks two layers down to extract them: the two Type-3 container children
|
||||||
|
/// ordered by <see cref="ElementInfo.ReadOrder"/> (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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The expand-detail overlay present in the front container carries ONLY named
|
||||||
|
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
||||||
|
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
||||||
|
/// automatically.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DatWidgetFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
||||||
|
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
||||||
|
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
||||||
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||||
|
/// <param name="datFont">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.</param>
|
||||||
|
/// <returns>The widget, or <c>null</c> for a Type-12 style prototype (caller skips it).</returns>
|
||||||
|
public static UiElement? Create(ElementInfo info,
|
||||||
|
Func<uint, (uint, int, int)> 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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="UiMeter"/> and populates its six 3-slice sprite ids by
|
||||||
|
/// reading the meter's grandchild image elements (format doc §11).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Structure the importer produces for each meter (UIElement_Meter):
|
||||||
|
/// <code>
|
||||||
|
/// 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)
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
||||||
|
/// They are bound to the live stat providers in Task 6 (VitalsController).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static UiMeter BuildMeter(ElementInfo info,
|
||||||
|
Func<uint, (uint, int, int)> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
||||||
|
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
||||||
|
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
||||||
|
/// false.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Normal file
112
tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
Normal file
|
|
@ -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<UiMeter>(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownType_FallsBackToGeneric()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
|
||||||
|
Assert.IsType<UiDatElement>(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 ───────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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) ───────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<UiMeter>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue