feat(ui): port retained widget foundations
This commit is contained in:
parent
44f9ec13d9
commit
d825572e31
44 changed files with 84813 additions and 292 deletions
|
|
@ -138,8 +138,6 @@ public static class CharacterStatController
|
|||
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
|
||||
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
|
||||
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
|
||||
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
|
||||
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
|
||||
|
||||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||
|
|
@ -338,6 +336,7 @@ public static class CharacterStatController
|
|||
if (datFont is not null) xpValue.DatFont = datFont;
|
||||
xpValue.ClickThrough = true;
|
||||
xpValue.RightAligned = true;
|
||||
xpValue.OneLine = true;
|
||||
xpValue.Padding = 0f; // avoid scroll clip
|
||||
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
|
||||
}
|
||||
|
|
@ -1053,12 +1052,20 @@ public static class CharacterStatController
|
|||
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
|
||||
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
|
||||
string btn1State = affordable1 ? StateNormal : StateGhosted;
|
||||
string btn10State = affordable10 ? StateNormal : StateGhosted;
|
||||
|
||||
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btn1State; }
|
||||
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btn10State; }
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.TrySetRetailState(affordable1
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
foreach (var b in allRaise10)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.TrySetRetailState(affordable10
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RefreshSkillRaiseButtons(
|
||||
|
|
@ -1079,12 +1086,12 @@ public static class CharacterStatController
|
|||
bool affordable = trained
|
||||
? cost > 0 && sheet.UnassignedXp >= cost
|
||||
: cost > 0 && sheet.SkillCredits >= cost;
|
||||
string state = affordable ? StateNormal : StateGhosted;
|
||||
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.ActiveState = state;
|
||||
b.TrySetRetailState(affordable
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
foreach (var b in allRaise10)
|
||||
|
|
@ -1094,7 +1101,9 @@ public static class CharacterStatController
|
|||
{
|
||||
long cost10 = selectedSkill.Raise10Cost;
|
||||
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
b.ActiveState = affordable10 ? StateNormal : StateGhosted;
|
||||
b.TrySetRetailState(affordable10
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1334,6 +1343,7 @@ public static class CharacterStatController
|
|||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
RightAligned = true,
|
||||
OneLine = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
var capturedProvider = valueProvider;
|
||||
|
|
@ -1435,6 +1445,7 @@ public static class CharacterStatController
|
|||
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
|
||||
titleEl.BackgroundSprite = 0;
|
||||
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
|
||||
titleEl.OneLine = true;
|
||||
}
|
||||
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
|
||||
// Fix C: the dat has a 20px font for the footer title. Let it drive.
|
||||
|
|
@ -1706,6 +1717,7 @@ public static class CharacterStatController
|
|||
// Non-null = controller explicit override.
|
||||
if (datFont is not null) t.DatFont = datFont;
|
||||
t.Centered = true;
|
||||
t.OneLine = true;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
|
|
@ -1829,6 +1841,7 @@ public static class CharacterStatController
|
|||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <para>
|
||||
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
|
||||
/// by the factory; this controller binds its live data provider in place. The input
|
||||
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
|
||||
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
|
||||
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
|
||||
/// (<c>0x10000016</c>) carries Editable property 0x16, so the factory builds it
|
||||
/// directly as <see cref="UiField"/> and this controller binds it in place. The
|
||||
/// scrollbar track (<c>0x10000012</c>) is
|
||||
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
|
||||
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
|
||||
/// and bound in place.
|
||||
|
|
@ -36,7 +36,7 @@ public sealed class ChatWindowController
|
|||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint MaxMinId = 0x1000046Fu;
|
||||
|
||||
|
|
@ -48,9 +48,6 @@ public sealed class ChatWindowController
|
|||
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
|
||||
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
|
||||
|
||||
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
|
||||
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
|
||||
|
||||
// Channel menu sprite ids (confirmed in chat element dump).
|
||||
private const uint MenuNormal = 0x06004D65u; // button face
|
||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||
|
|
@ -159,20 +156,16 @@ public sealed class ChatWindowController
|
|||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
// The transcript is built as a UiText by the factory (Type 12).
|
||||
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
|
||||
// it with a UiField. Read its rect from the raw ElementInfo tree first.
|
||||
var iInfo = FindInfo(rootInfo, InputId);
|
||||
|
||||
// Their parent panels must exist as real widgets in the layout tree.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
var inputBar = layout.FindElement(InputBarId);
|
||||
var input = layout.FindElement(InputId) as UiField;
|
||||
|
||||
if (iInfo is null || transcriptPanel is null || inputBar is null)
|
||||
if (input is null || transcriptPanel is null || inputBar is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
||||
$"(iInfo={iInfo is not null}, " +
|
||||
$"(input={input is not null}, " +
|
||||
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
|
|
@ -215,29 +208,19 @@ public sealed class ChatWindowController
|
|||
// caches layout for mouse selection and Ctrl+C.
|
||||
c.Transcript.Centered = false;
|
||||
c.Transcript.RightAligned = false;
|
||||
c.Transcript.OneLine = false;
|
||||
c.Transcript.Selectable = true;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
|
||||
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
|
||||
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
|
||||
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
|
||||
inParent.RemoveChild(inputPlaceholder);
|
||||
c.Input = new UiField
|
||||
{
|
||||
Left = iInfo.X,
|
||||
Top = iInfo.Y,
|
||||
Width = iInfo.Width,
|
||||
Height = iInfo.Height,
|
||||
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||
DatFont = datFont,
|
||||
Font = debugFont,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
|
||||
SpriteResolve = resolve,
|
||||
FocusFieldSprite = InputFocusField,
|
||||
};
|
||||
inputBar.AddChild(c.Input);
|
||||
// Editable/selectable/one-line semantics and state sprites came from the
|
||||
// imported property/state bags. The controller supplies runtime services only.
|
||||
c.Input = input;
|
||||
c.Input.DatFont = datFont;
|
||||
c.Input.Font = debugFont;
|
||||
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
|
|
@ -360,21 +343,6 @@ public sealed class ChatWindowController
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
|
||||
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
|
||||
/// </summary>
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
var found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
||||
|
|
|
|||
|
|
@ -10,11 +10,10 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <see cref="UiDatElement"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
||||
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
||||
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
||||
/// prototype elements (no state media, no controller binding) draw nothing because
|
||||
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||
/// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
|
||||
/// in place; other elements become display/selectable <see cref="UiText"/> widgets.
|
||||
/// Elements that carry their own DAT sprite media keep it as widget background art.
|
||||
/// Pure prototype elements draw nothing because text backgrounds default transparent.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -102,9 +101,31 @@ public static class DatWidgetFactory
|
|||
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
||||
|
||||
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
||||
// UI layout engine uses for reflow.
|
||||
// compatibility layout engine uses for programmatic overrides.
|
||||
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
||||
|
||||
// Imported descendants use the exact four-mode retail policy. Roots have no
|
||||
// design parent and intentionally remain on the compatibility path until a
|
||||
// window mount assigns its own outer-frame policy.
|
||||
if (info.HasOriginalParentSize)
|
||||
{
|
||||
e.LayoutPolicy = new UiLayoutPolicy(
|
||||
info.Left,
|
||||
info.Top,
|
||||
info.Right,
|
||||
info.Bottom,
|
||||
UiPixelRect.FromPositionAndSize(
|
||||
(int)info.X,
|
||||
(int)info.Y,
|
||||
(int)info.Width,
|
||||
(int)info.Height),
|
||||
UiPixelRect.FromPositionAndSize(
|
||||
0,
|
||||
0,
|
||||
(int)info.OriginalParentWidth,
|
||||
(int)info.OriginalParentHeight));
|
||||
}
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +274,8 @@ public static class DatWidgetFactory
|
|||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's
|
||||
/// <summary>Type-12 UIElement_Text: an editable field or colored-line text view,
|
||||
/// selected from the canonical property bag. The element's
|
||||
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
|
||||
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
|
||||
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
|
||||
|
|
@ -273,7 +295,7 @@ public static class DatWidgetFactory
|
|||
/// font; otherwise it is the shared global fallback. Controllers that call
|
||||
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
||||
/// still override this — the build-time value is just the starting point.</param>
|
||||
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
||||
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont = null)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
|
|
@ -281,6 +303,35 @@ public static class DatWidgetFactory
|
|||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? m.File : 0u;
|
||||
|
||||
bool editable = info.TryGetEffectiveBool(0x16u, out var editableValue)
|
||||
&& editableValue;
|
||||
bool selectable = info.TryGetEffectiveBool(0x27u, out var selectableValue)
|
||||
&& selectableValue;
|
||||
bool oneLine = info.TryGetEffectiveBool(0x20u, out var oneLineValue)
|
||||
&& oneLineValue;
|
||||
|
||||
if (editable)
|
||||
{
|
||||
uint focusSprite = info.StateMedia.TryGetValue("Normal_focussed", out var focus)
|
||||
? focus.File
|
||||
: 0u;
|
||||
var field = new UiField
|
||||
{
|
||||
ElementId = info.Id,
|
||||
DatFont = elementFont,
|
||||
SpriteResolve = resolve,
|
||||
BackgroundSprite = bg,
|
||||
FocusFieldSprite = focusSprite,
|
||||
Selectable = selectable,
|
||||
OneLine = oneLine,
|
||||
};
|
||||
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
|
||||
field.MaxCharacters = maxCharacters;
|
||||
if (info.FontColor.HasValue)
|
||||
field.TextColor = info.FontColor.Value;
|
||||
return field;
|
||||
}
|
||||
|
||||
// Apply horizontal + vertical justification from the dat at build time.
|
||||
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
||||
// afterward will override these — this is only the dat-driven default.
|
||||
|
|
@ -301,6 +352,8 @@ public static class DatWidgetFactory
|
|||
Centered = centered,
|
||||
RightAligned = rightAligned,
|
||||
VerticalJustify = vJustify,
|
||||
OneLine = oneLine,
|
||||
Selectable = selectable,
|
||||
// Seed the dat-driven font. When a font resolver was supplied and the element
|
||||
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
|
||||
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
|
|||
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
|
||||
/// operate on this type so they can be unit-tested without the dats or OpenGL.
|
||||
///
|
||||
/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
|
||||
/// updating the plan spec and downstream consumers.
|
||||
/// This is the canonical importer/factory seam. Add fields only with matching
|
||||
/// inheritance, fixture-serialization, and conformance coverage.
|
||||
/// </summary>
|
||||
public sealed class ElementInfo
|
||||
{
|
||||
|
|
@ -41,11 +41,20 @@ public sealed class ElementInfo
|
|||
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
|
||||
public float X, Y, Width, Height;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time parent size used by retail raw-edge reflow. Set by the DAT
|
||||
/// importer for descendants; imported roots deliberately leave this unset.
|
||||
/// Mounted base-layout children retain their original base parent size.
|
||||
/// </summary>
|
||||
public float OriginalParentWidth, OriginalParentHeight;
|
||||
public bool HasOriginalParentSize;
|
||||
|
||||
/// <summary>
|
||||
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
|
||||
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
|
||||
/// Values 0–4; map to <see cref="AnchorEdges"/> bit-flags via
|
||||
/// <see cref="ElementReader.ToAnchors"/>.
|
||||
/// Values 0–4. Imported elements preserve these in <see cref="UiLayoutPolicy"/>;
|
||||
/// <see cref="ElementReader.ToAnchors"/> is only the compatibility projection
|
||||
/// for older programmatic consumers.
|
||||
/// </summary>
|
||||
public uint Left, Top, Right, Bottom;
|
||||
|
||||
|
|
@ -58,6 +67,16 @@ public sealed class ElementInfo
|
|||
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
|
||||
public uint ZLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical state descriptors keyed by numeric retail state id. The unnamed
|
||||
/// DirectState uses <see cref="UiStateInfo.DirectStateId"/>. Unlike the legacy
|
||||
/// projections below, properties remain scoped to the state that defines them.
|
||||
/// </summary>
|
||||
public Dictionary<uint, UiStateInfo> States = new();
|
||||
|
||||
/// <summary>Numeric form of <see cref="DefaultStateName"/>; zero means unset.</summary>
|
||||
public uint DefaultStateId;
|
||||
|
||||
/// <summary>
|
||||
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
|
||||
|
|
@ -115,6 +134,82 @@ public sealed class ElementInfo
|
|||
/// Children come from the derived element's own tree, not the base element's.
|
||||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
||||
/// named state's key overrides DirectState by presence, including false/zero.
|
||||
/// When no state is supplied, the element's default, Normal, then DirectState
|
||||
/// is selected in that order.
|
||||
/// </summary>
|
||||
public bool TryGetEffectiveProperty(uint propertyId, out UiPropertyValue value, uint? stateId = null)
|
||||
{
|
||||
uint effectiveState = stateId ?? EffectiveDefaultStateId();
|
||||
|
||||
UiPropertyValue? resolved = null;
|
||||
if (States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
|
||||
&& direct.Properties.TryGetValue(propertyId, out var directValue))
|
||||
{
|
||||
resolved = directValue;
|
||||
}
|
||||
|
||||
if (effectiveState != UiStateInfo.DirectStateId
|
||||
&& States.TryGetValue(effectiveState, out var state)
|
||||
&& state.Properties.TryGetValue(propertyId, out var stateValue))
|
||||
{
|
||||
resolved = stateValue;
|
||||
}
|
||||
|
||||
value = resolved!;
|
||||
return resolved is not null;
|
||||
}
|
||||
|
||||
public bool TryGetEffectiveBool(uint propertyId, out bool value, uint? stateId = null)
|
||||
{
|
||||
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
|
||||
&& property.Kind == UiPropertyKind.Bool)
|
||||
{
|
||||
value = property.BoolValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetEffectiveInteger(uint propertyId, out int value, uint? stateId = null)
|
||||
{
|
||||
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
|
||||
&& property.Kind == UiPropertyKind.Integer)
|
||||
{
|
||||
value = property.IntegerValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetEffectiveFloat(uint propertyId, out float value, uint? stateId = null)
|
||||
{
|
||||
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
|
||||
&& property.Kind == UiPropertyKind.Float)
|
||||
{
|
||||
value = property.FloatValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
public uint EffectiveDefaultStateId()
|
||||
{
|
||||
if (DefaultStateId != 0 && States.ContainsKey(DefaultStateId))
|
||||
return DefaultStateId;
|
||||
if (States.ContainsKey(1u)) // UIStateId.Normal
|
||||
return 1u;
|
||||
return UiStateInfo.DirectStateId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -125,12 +220,10 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
|
||||
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
|
||||
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
|
||||
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
|
||||
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
|
||||
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
|
||||
/// <summary>Compatibility projection from raw retail modes to the legacy
|
||||
/// <see cref="AnchorEdges"/> flags. This projection cannot represent centered
|
||||
/// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use
|
||||
/// <see cref="UiLayoutPolicy"/>; call this only for legacy/programmatic paths.</summary>
|
||||
/// <param name="left">LeftEdge dat field value (0–4).</param>
|
||||
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||||
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||||
|
|
@ -180,12 +273,9 @@ public static class ElementReader
|
|||
var m = new ElementInfo
|
||||
{
|
||||
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
|
||||
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
|
||||
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
|
||||
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
|
||||
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
|
||||
// Width/Height, or explicit handling of Type 0 before the merge).
|
||||
// Type: derived wins if non-zero. Layout instances commonly carry Type 0
|
||||
// and inherit the registered widget type (for example Type 12 text) from
|
||||
// their base prototype; DatWidgetFactory then builds that behavioral type.
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X,
|
||||
Y = derived.Y,
|
||||
|
|
@ -193,7 +283,7 @@ public static class ElementReader
|
|||
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
|
||||
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
|
||||
// If a real zero-size derived element ever needs to override a non-zero base in
|
||||
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5).
|
||||
// switch Width/Height to nullable values and use presence-aware merging.
|
||||
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left,
|
||||
|
|
@ -202,6 +292,7 @@ public static class ElementReader
|
|||
Bottom = derived.Bottom,
|
||||
ReadOrder = derived.ReadOrder,
|
||||
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
|
||||
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
|
||||
// (the dat property was present and read); otherwise inherit the base prototype's value.
|
||||
|
|
@ -216,7 +307,7 @@ public static class ElementReader
|
|||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
// Defensive copy: prevent a later mutation of either the merged result or the input
|
||||
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
|
||||
// from corrupting the other. Safe because derived.Children is fully
|
||||
// populated by the recursive importer BEFORE Merge is called and never mutated after).
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
|
|
@ -227,6 +318,68 @@ public static class ElementReader
|
|||
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
|
||||
foreach (var kv in derived.StateCursors)
|
||||
m.StateCursors[kv.Key] = kv.Value;
|
||||
|
||||
m.States = new Dictionary<uint, UiStateInfo>();
|
||||
foreach (var (id, state) in base_.States)
|
||||
m.States[id] = state.Clone();
|
||||
foreach (var (id, state) in derived.States)
|
||||
{
|
||||
m.States[id] = m.States.TryGetValue(id, out var baseState)
|
||||
? UiStateInfo.Merge(baseState, state)
|
||||
: state.Clone();
|
||||
}
|
||||
|
||||
ApplyCanonicalLegacyProjection(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
internal static void ApplyCanonicalLegacyProjection(ElementInfo info)
|
||||
{
|
||||
if (info.TryGetEffectiveProperty(0x1Au, out var font)
|
||||
&& font.Kind == UiPropertyKind.Array
|
||||
&& font.ArrayValue.Count > 0
|
||||
&& font.ArrayValue[0].Kind == UiPropertyKind.DataId)
|
||||
{
|
||||
info.FontDid = checked((uint)font.ArrayValue[0].UnsignedValue);
|
||||
}
|
||||
|
||||
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
|
||||
&& horizontal.Kind == UiPropertyKind.Enum)
|
||||
{
|
||||
info.HJustify = horizontal.UnsignedValue switch
|
||||
{
|
||||
0u => HJustify.Left,
|
||||
3u or 5u => HJustify.Right,
|
||||
_ => HJustify.Center,
|
||||
};
|
||||
}
|
||||
|
||||
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
|
||||
&& vertical.Kind == UiPropertyKind.Enum)
|
||||
{
|
||||
info.VJustify = vertical.UnsignedValue switch
|
||||
{
|
||||
2u => VJustify.Top,
|
||||
4u => VJustify.Bottom,
|
||||
_ => VJustify.Center,
|
||||
};
|
||||
}
|
||||
|
||||
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
|
||||
{
|
||||
UiPropertyValue? colorValue = color.Kind == UiPropertyKind.Color
|
||||
? color
|
||||
: color.Kind == UiPropertyKind.Array
|
||||
&& color.ArrayValue.Count > 0
|
||||
&& color.ArrayValue[0].Kind == UiPropertyKind.Color
|
||||
? color.ArrayValue[0]
|
||||
: null;
|
||||
if (colorValue is not null)
|
||||
{
|
||||
var c = colorValue.ColorValue;
|
||||
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
|
||||
info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,6 +463,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
if (host is UiText t)
|
||||
{
|
||||
t.Centered = true;
|
||||
t.OneLine = true;
|
||||
t.DatFont = datFont;
|
||||
t.ClickThrough = true;
|
||||
t.AcceptsFocus = false;
|
||||
|
|
@ -483,7 +484,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true, DatFont = datFont, ClickThrough = true,
|
||||
Centered = true, OneLine = true, DatFont = datFont, ClickThrough = true,
|
||||
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -203,9 +203,19 @@ public static class LayoutImporter
|
|||
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
|
||||
}
|
||||
|
||||
return tops.Count == 1
|
||||
? tops[0]
|
||||
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||||
if (tops.Count == 1)
|
||||
return tops[0];
|
||||
|
||||
foreach (var top in tops)
|
||||
SetOriginalParentSize(top, ld.Width, ld.Height);
|
||||
return new ElementInfo
|
||||
{
|
||||
Id = 0,
|
||||
Type = 3,
|
||||
Width = ld.Width,
|
||||
Height = ld.Height,
|
||||
Children = tops,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -290,7 +300,11 @@ public static class LayoutImporter
|
|||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
{
|
||||
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
|
||||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||||
|
|
@ -313,42 +327,9 @@ public static class LayoutImporter
|
|||
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
|
||||
result.ZLevel = self.ZLevel;
|
||||
|
||||
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
|
||||
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
|
||||
// anchored elements must be resized to fill the slot. Without this step, the
|
||||
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
|
||||
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
|
||||
// within it with Bottom anchor stays at its design size too (list box at 160px
|
||||
// instead of the correct 398px).
|
||||
//
|
||||
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
|
||||
// new parent height down the whole tree, updating each Bottom-anchored child's
|
||||
// rect by maintaining its bottom margin. We replicate that cascade here at import
|
||||
// time on the base-children subtree so the anchor-capture during the first render
|
||||
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
|
||||
// margin.
|
||||
//
|
||||
// Safe guard: only fire when the slot has explicit size AND the base children are
|
||||
// smaller (i.e., this is the "slot bigger than design" case).
|
||||
// Inventory/paperdoll slots are unaffected because their slot H already matches the
|
||||
// sub-layout design H, so no cascade occurs.
|
||||
if (result.Width > 0 && result.Height > 0)
|
||||
{
|
||||
foreach (var child in baseChildren)
|
||||
{
|
||||
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
|
||||
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||||
if (child.X == 0 && child.Y == 0
|
||||
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
|
||||
&& child.Width <= result.Width
|
||||
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
|
||||
{
|
||||
// Cascade UpdateForParentSizeChange down the subtree: maintain each
|
||||
// Bottom-anchored element's bottom margin while growing the parent height.
|
||||
CascadeHeight(child, child.Height, result.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mounted descendants retain the base layout's design parent size. The runtime
|
||||
// UiLayoutPolicy therefore performs the retail parent-size update from the correct
|
||||
// baseline when this slot is larger than the mounted layout.
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -378,17 +359,19 @@ public static class LayoutImporter
|
|||
Bottom = d.BottomEdge,
|
||||
ReadOrder = d.ReadOrder,
|
||||
ZLevel = d.ZLevel,
|
||||
DefaultStateId = (uint)d.DefaultState,
|
||||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||||
};
|
||||
|
||||
// DirectState (unnamed, key "").
|
||||
if (d.StateDesc is not null)
|
||||
ReadState(d.StateDesc, "", info);
|
||||
ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info);
|
||||
|
||||
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
|
||||
foreach (var s in d.States)
|
||||
ReadState(s.Value, s.Key.ToString(), info);
|
||||
ReadState(s.Value, (uint)s.Key, s.Key.ToString(), info);
|
||||
|
||||
ElementReader.ApplyCanonicalLegacyProjection(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
|
|
@ -398,14 +381,23 @@ public static class LayoutImporter
|
|||
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||
/// </summary>
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
private static void ReadState(StateDesc sd, uint stateId, string name, ElementInfo info)
|
||||
{
|
||||
var state = new UiStateInfo
|
||||
{
|
||||
Id = stateId,
|
||||
Name = name,
|
||||
PassToChildren = sd.PassToChildren,
|
||||
IncorporationFlags = (uint)sd.IncorporationFlags,
|
||||
};
|
||||
|
||||
bool imageRead = false;
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (!imageRead && m is MediaDescImage img && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
|
||||
imageRead = true;
|
||||
}
|
||||
|
||||
|
|
@ -415,9 +407,17 @@ public static class LayoutImporter
|
|||
cursor.File,
|
||||
checked((int)cursor.XHotspot),
|
||||
checked((int)cursor.YHotspot));
|
||||
state.Cursor = info.StateCursors[name];
|
||||
}
|
||||
}
|
||||
|
||||
if (sd.Properties is not null)
|
||||
{
|
||||
foreach (var (propertyId, property) in sd.Properties)
|
||||
state.Properties.Values[propertyId] = ConvertProperty(property);
|
||||
}
|
||||
info.States[stateId] = state;
|
||||
|
||||
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
|
||||
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
|
||||
if (info.FontDid == 0 && sd.Properties is not null
|
||||
|
|
@ -476,6 +476,82 @@ public static class LayoutImporter
|
|||
}
|
||||
}
|
||||
|
||||
internal static UiPropertyValue ConvertProperty(BaseProperty property)
|
||||
{
|
||||
var value = new UiPropertyValue { MasterPropertyId = property.MasterPropertyId };
|
||||
switch (property)
|
||||
{
|
||||
case EnumBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Enum;
|
||||
value.UnsignedValue = p.Value;
|
||||
break;
|
||||
case BoolBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Bool;
|
||||
value.BoolValue = p.Value;
|
||||
break;
|
||||
case DataIdBaseProperty p:
|
||||
value.Kind = UiPropertyKind.DataId;
|
||||
value.UnsignedValue = p.Value;
|
||||
break;
|
||||
case FloatBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Float;
|
||||
value.FloatValue = p.Value;
|
||||
break;
|
||||
case IntegerBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Integer;
|
||||
value.IntegerValue = p.Value;
|
||||
break;
|
||||
case StringInfoBaseProperty p:
|
||||
value.Kind = UiPropertyKind.StringInfo;
|
||||
value.StringInfoValue = new UiStringInfoValue(
|
||||
p.Value.Token,
|
||||
p.Value.StringId,
|
||||
p.Value.TableId.DataId,
|
||||
(byte)p.Value.Override,
|
||||
p.Value.English,
|
||||
p.Value.Comment);
|
||||
break;
|
||||
case ColorBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Color;
|
||||
value.ColorValue = new UiColorValue(
|
||||
p.Value.Blue,
|
||||
p.Value.Green,
|
||||
p.Value.Red,
|
||||
p.Value.Alpha);
|
||||
break;
|
||||
case ArrayBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Array;
|
||||
foreach (var item in p.Value)
|
||||
value.ArrayValue.Add(ConvertProperty(item));
|
||||
break;
|
||||
case StructBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Struct;
|
||||
foreach (var (key, item) in p.Value)
|
||||
value.StructValue[key] = ConvertProperty(item);
|
||||
break;
|
||||
case VectorBaseProperty p:
|
||||
value.Kind = UiPropertyKind.Vector;
|
||||
value.VectorValue = p.Value;
|
||||
break;
|
||||
case Bitfield32BaseProperty p:
|
||||
value.Kind = UiPropertyKind.Bitfield32;
|
||||
value.UnsignedValue = p.Value;
|
||||
break;
|
||||
case Bitfield64BaseProperty p:
|
||||
value.Kind = UiPropertyKind.Bitfield64;
|
||||
value.UnsignedValue = p.Value;
|
||||
break;
|
||||
case InstanceIdBaseProperty p:
|
||||
value.Kind = UiPropertyKind.InstanceId;
|
||||
value.UnsignedValue = p.Value;
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported UI base-property type {property.GetType().FullName}.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Prototype detection helpers ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -534,59 +610,12 @@ public static class LayoutImporter
|
|||
return null;
|
||||
}
|
||||
|
||||
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
|
||||
// ── Raw-edge layout provenance ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
|
||||
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
|
||||
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
|
||||
/// retail runtime.
|
||||
///
|
||||
/// <para>Three anchor cases for the vertical axis:
|
||||
/// <list type="bullet">
|
||||
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
|
||||
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
|
||||
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
|
||||
/// </list>
|
||||
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
|
||||
///
|
||||
/// <para>This is called once at import time on base-children that are mounted into a
|
||||
/// slot taller than the sub-layout's design height.</para>
|
||||
/// </summary>
|
||||
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
|
||||
private static void SetOriginalParentSize(ElementInfo child, float width, float height)
|
||||
{
|
||||
float oldH = el.Height;
|
||||
float newH = el.Height;
|
||||
float oldY = el.Y;
|
||||
|
||||
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
|
||||
|
||||
bool hasTop = (anchors & AnchorEdges.Top) != 0;
|
||||
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
|
||||
|
||||
if (hasTop && hasBottom)
|
||||
{
|
||||
// Stretch: maintain both top and bottom margins.
|
||||
// amT = el.Y (pinned to top, unchanged)
|
||||
// amB = oldParentH - (el.Y + el.H)
|
||||
float amB = oldParentH - (el.Y + el.Height);
|
||||
newH = newParentH - el.Y - amB;
|
||||
if (newH < 0f) newH = 0f;
|
||||
el.Height = newH;
|
||||
}
|
||||
else if (hasBottom && !hasTop)
|
||||
{
|
||||
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
|
||||
// amB = oldParentH - (el.Y + el.H)
|
||||
float amB = oldParentH - (el.Y + el.Height);
|
||||
float newY = newParentH - amB - el.Height;
|
||||
el.Y = newY;
|
||||
// Height unchanged; newH = oldH for recursion.
|
||||
}
|
||||
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
|
||||
|
||||
// Recurse into children with the element's new height as their parent.
|
||||
foreach (var child in el.Children)
|
||||
CascadeHeight(child, oldH, newH);
|
||||
child.OriginalParentWidth = width;
|
||||
child.OriginalParentHeight = height;
|
||||
child.HasOriginalParentSize = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ public sealed class RadarController
|
|||
if (_dragButton is not null)
|
||||
_dragButton.Visible = !snapshot.UiLocked;
|
||||
if (_lockButton is not null)
|
||||
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
|
||||
_lockButton.TrySetRetailState(
|
||||
snapshot.UiLocked ? RetailUiStateIds.LockedUi : RetailUiStateIds.UnlockedUi);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +196,7 @@ public sealed class RadarController
|
|||
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
|
||||
// strip and no duplicate text node is needed.
|
||||
importedText.Centered = true;
|
||||
importedText.OneLine = true;
|
||||
importedText.Padding = 0f;
|
||||
importedText.DatFont = datFont ?? importedText.DatFont;
|
||||
importedText.ClickThrough = true;
|
||||
|
|
@ -214,6 +216,7 @@ public sealed class RadarController
|
|||
Height = container.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
Padding = 0f,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ public sealed class SelectedObjectController
|
|||
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
AcceptsFocus = false,
|
||||
|
|
@ -215,7 +216,7 @@ public sealed class SelectedObjectController
|
|||
if (guid is null)
|
||||
{
|
||||
// Deselect: clear the overlay flash immediately too.
|
||||
SetOverlayState("");
|
||||
SetOverlayState(UiStateInfo.DirectStateId);
|
||||
_flashRemaining = 0;
|
||||
return;
|
||||
}
|
||||
|
|
@ -227,7 +228,9 @@ public sealed class SelectedObjectController
|
|||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected");
|
||||
SetOverlayState(_stackSize(g) > 1u
|
||||
? RetailUiStateIds.StackedItemSelected
|
||||
: RetailUiStateIds.ObjectSelected);
|
||||
_flashRemaining = FlashSeconds;
|
||||
|
||||
// ── 4. Health: query, and show the meter only if real health is already known.
|
||||
|
|
@ -258,11 +261,11 @@ public sealed class SelectedObjectController
|
|||
if (_flashRemaining <= 0) return;
|
||||
_flashRemaining -= deltaSeconds;
|
||||
if (_flashRemaining <= 0)
|
||||
SetOverlayState(""); // flash done → overlay back to blank
|
||||
SetOverlayState(UiStateInfo.DirectStateId); // flash done → overlay back to blank
|
||||
}
|
||||
|
||||
private void SetOverlayState(string state)
|
||||
private void SetOverlayState(uint state)
|
||||
{
|
||||
if (_overlay is not null) _overlay.ActiveState = state;
|
||||
_overlay?.TrySetRetailState(state);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
private const uint InventoryButtonId = 0x100001B1u;
|
||||
private const string ButtonClosedState = "Normal";
|
||||
private const string ButtonOpenState = "Highlight";
|
||||
|
||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||
|
|
@ -247,7 +245,9 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
private static void SetButtonOpen(UiButton? button, bool open)
|
||||
{
|
||||
if (button is not null)
|
||||
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
|
||||
button.TrySetRetailState(open
|
||||
? UiButtonStateMachine.Highlight
|
||||
: UiButtonStateMachine.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
public sealed class UiDatElement : UiElement, IUiDatStateful
|
||||
{
|
||||
// DrawModeType enum values from DatReaderWriter.Enums.
|
||||
// See docs/research/2026-06-15-layoutdesc-format.md §6.
|
||||
|
|
@ -51,8 +51,50 @@ public sealed class UiDatElement : UiElement
|
|||
/// Falls back to DirectState if the named state is absent.</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public uint ActiveRetailStateId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(ActiveState))
|
||||
return UiStateInfo.DirectStateId;
|
||||
foreach (var (id, state) in _info.States)
|
||||
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
|
||||
return id;
|
||||
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
|
||||
? standard
|
||||
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ActiveCursorStateName => ActiveState;
|
||||
|
||||
public bool TrySetRetailState(uint stateId)
|
||||
{
|
||||
if (stateId == UiStateInfo.DirectStateId)
|
||||
{
|
||||
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
|
||||
return false;
|
||||
ActiveState = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_info.States.TryGetValue(stateId, out var state))
|
||||
{
|
||||
ActiveState = state.Name;
|
||||
return true;
|
||||
}
|
||||
|
||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
stateName = RetailUiStateIds.StateName(stateId);
|
||||
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
|
||||
{
|
||||
ActiveState = stateName;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
|
|
|
|||
152
src/AcDream.App/UI/Layout/UiPropertyBag.cs
Normal file
152
src/AcDream.App/UI/Layout/UiPropertyBag.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Dat property value kinds carried by a retail <c>StateDesc</c>. The enum mirrors
|
||||
/// <c>BasePropertyType</c> without exposing DatReaderWriter types to the retained UI.
|
||||
/// </summary>
|
||||
public enum UiPropertyKind : byte
|
||||
{
|
||||
Enum,
|
||||
Bool,
|
||||
DataId,
|
||||
Float,
|
||||
Integer,
|
||||
StringInfo,
|
||||
Color,
|
||||
Array,
|
||||
Struct,
|
||||
Vector,
|
||||
Bitfield32,
|
||||
Bitfield64,
|
||||
InstanceId,
|
||||
}
|
||||
|
||||
/// <summary>Exact byte representation of a retail ARGB property value.</summary>
|
||||
public readonly record struct UiColorValue(byte Blue, byte Green, byte Red, byte Alpha);
|
||||
|
||||
/// <summary>Dat-independent snapshot of a retail <c>StringInfo</c>.</summary>
|
||||
public readonly record struct UiStringInfoValue(
|
||||
byte Token,
|
||||
uint StringId,
|
||||
uint TableId,
|
||||
byte Override,
|
||||
byte English,
|
||||
byte Comment);
|
||||
|
||||
/// <summary>
|
||||
/// Lossless, serializable representation of one supported retail base property.
|
||||
/// Only the field selected by <see cref="Kind"/> is meaningful. Arrays and structs
|
||||
/// recursively preserve their members and each member's master-property id.
|
||||
/// </summary>
|
||||
public sealed class UiPropertyValue
|
||||
{
|
||||
public UiPropertyKind Kind;
|
||||
public uint MasterPropertyId;
|
||||
public ulong UnsignedValue;
|
||||
public int IntegerValue;
|
||||
public float FloatValue;
|
||||
public bool BoolValue;
|
||||
public UiStringInfoValue StringInfoValue;
|
||||
public UiColorValue ColorValue;
|
||||
public Vector3 VectorValue;
|
||||
public List<UiPropertyValue> ArrayValue = new();
|
||||
public Dictionary<uint, UiPropertyValue> StructValue = new();
|
||||
|
||||
public UiPropertyValue Clone()
|
||||
{
|
||||
var clone = new UiPropertyValue
|
||||
{
|
||||
Kind = Kind,
|
||||
MasterPropertyId = MasterPropertyId,
|
||||
UnsignedValue = UnsignedValue,
|
||||
IntegerValue = IntegerValue,
|
||||
FloatValue = FloatValue,
|
||||
BoolValue = BoolValue,
|
||||
StringInfoValue = StringInfoValue,
|
||||
ColorValue = ColorValue,
|
||||
VectorValue = VectorValue,
|
||||
};
|
||||
|
||||
foreach (var item in ArrayValue)
|
||||
clone.ArrayValue.Add(item.Clone());
|
||||
foreach (var (key, value) in StructValue)
|
||||
clone.StructValue[key] = value.Clone();
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State-scoped retail properties. Presence of a key is authoritative: an explicit
|
||||
/// false, zero, centered justification, or empty collection still overrides a base
|
||||
/// value during inheritance.
|
||||
/// </summary>
|
||||
public sealed class UiPropertyBag
|
||||
{
|
||||
public Dictionary<uint, UiPropertyValue> Values = new();
|
||||
|
||||
public bool TryGetValue(uint id, out UiPropertyValue value)
|
||||
=> Values.TryGetValue(id, out value!);
|
||||
|
||||
public UiPropertyBag Clone()
|
||||
{
|
||||
var clone = new UiPropertyBag();
|
||||
foreach (var (key, value) in Values)
|
||||
clone.Values[key] = value.Clone();
|
||||
return clone;
|
||||
}
|
||||
|
||||
public static UiPropertyBag Merge(UiPropertyBag baseProperties, UiPropertyBag derivedProperties)
|
||||
{
|
||||
var merged = baseProperties.Clone();
|
||||
foreach (var (key, value) in derivedProperties.Values)
|
||||
merged.Values[key] = value.Clone();
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Primary render-surface media for a retail UI state.</summary>
|
||||
public readonly record struct UiImageMedia(uint File, int DrawMode);
|
||||
|
||||
/// <summary>
|
||||
/// Dat-independent state descriptor. DirectState uses
|
||||
/// <see cref="DirectStateId"/> so it cannot collide with <c>UIStateId.Undef == 0</c>.
|
||||
/// </summary>
|
||||
public sealed class UiStateInfo
|
||||
{
|
||||
public const uint DirectStateId = uint.MaxValue;
|
||||
|
||||
public uint Id;
|
||||
public string Name = "";
|
||||
public bool PassToChildren;
|
||||
public uint IncorporationFlags;
|
||||
public UiImageMedia? Image;
|
||||
public UiCursorMedia? Cursor;
|
||||
public UiPropertyBag Properties = new();
|
||||
|
||||
public UiStateInfo Clone()
|
||||
=> new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
PassToChildren = PassToChildren,
|
||||
IncorporationFlags = IncorporationFlags,
|
||||
Image = Image,
|
||||
Cursor = Cursor,
|
||||
Properties = Properties.Clone(),
|
||||
};
|
||||
|
||||
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
|
||||
=> new()
|
||||
{
|
||||
Id = derivedState.Id,
|
||||
Name = derivedState.Name,
|
||||
PassToChildren = derivedState.PassToChildren,
|
||||
IncorporationFlags = derivedState.IncorporationFlags,
|
||||
Image = derivedState.Image ?? baseState.Image,
|
||||
Cursor = derivedState.Cursor ?? baseState.Cursor,
|
||||
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
|
||||
};
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ public static class VitalsController
|
|||
public const uint Stamina = 0x100000EC;
|
||||
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
|
||||
public const uint Mana = 0x100000EE;
|
||||
public const uint HealthText = 0x100000EB;
|
||||
public const uint StaminaText = 0x100000ED;
|
||||
public const uint ManaText = 0x100000EF;
|
||||
|
||||
/// <summary>
|
||||
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
|
||||
|
|
@ -50,16 +53,16 @@ public static class VitalsController
|
|||
Func<string> staminaText,
|
||||
Func<string> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
BindMeter(layout, Health, HealthText, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, StaminaText, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, ManaText, manaPct, manaText);
|
||||
}
|
||||
|
||||
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
|
||||
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private static void BindMeter(
|
||||
ImportedLayout layout, uint id,
|
||||
ImportedLayout layout, uint id, uint textId,
|
||||
Func<float> pct,
|
||||
Func<string> text)
|
||||
{
|
||||
|
|
@ -68,31 +71,22 @@ public static class VitalsController
|
|||
|
||||
m.Fill = () => pct();
|
||||
|
||||
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
|
||||
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
|
||||
// decoration) that fills + stretches with the meter, and stop the meter drawing its
|
||||
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
|
||||
// so the numbers stay pixel-identical (locked by the visual gate).
|
||||
// Retail gmVitalsUI binds the authored UIElement_Text over each meter. The
|
||||
// importer already built and registered that node; do not synthesize a duplicate.
|
||||
m.Label = () => null;
|
||||
if (layout.FindElement(textId) is not UiText number) return;
|
||||
|
||||
var number = new UiText
|
||||
number.Centered = true;
|
||||
number.RightAligned = false;
|
||||
number.OneLine = true;
|
||||
number.Selectable = false;
|
||||
number.DatFont ??= m.DatFont;
|
||||
number.LinesProvider = () =>
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
DatFont = m.DatFont, // the same dat font the meter used for its label
|
||||
ClickThrough = true, // decoration: no focus / selection / drag
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
},
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
};
|
||||
m.AddChild(number);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue