diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 5c7baaa3..1132479b 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -67,8 +67,12 @@ public static class MarkupDocument Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, Anchors = Anchor((string?)el.Attribute("anchor")), SpriteResolve = resolve, - BackSpriteId = Hex((string?)el.Attribute("back")), - FrontSpriteId = Hex((string?)el.Attribute("front")), + BackLeft = Hex((string?)el.Attribute("backleft")), + BackTile = Hex((string?)el.Attribute("backtile")), + BackRight = Hex((string?)el.Attribute("backright")), + FrontLeft = Hex((string?)el.Attribute("frontleft")), + FrontTile = Hex((string?)el.Attribute("fronttile")), + FrontRight = Hex((string?)el.Attribute("frontright")), }); break; // future element kinds (label, button, image) added here diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 48911c14..de97aff4 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -25,12 +25,27 @@ public sealed class UiMeter : UiElement public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set - /// with Back/Front sprite ids, the bar draws the retail sprites instead of solid color. + /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color. public Func? SpriteResolve { get; set; } - /// Empty-track sprite (drawn full width). 0 = none. - public uint BackSpriteId { get; set; } - /// Colored-fill sprite (drawn cropped to the fill fraction). 0 = none. - public uint FrontSpriteId { get; set; } + + // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap, + // a stretched gradient middle, and a fixed-width right-cap. The "back" slice is + // the empty track (drawn full width); the "front" slice is the coloured fill + // (drawn from the left, grown to the fill fraction — the track owns the right + // end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc + // (0x21000014) via tools/dump-vitals-bars; 0 = none. + /// Empty-track left-cap RenderSurface id. + public uint BackLeft { get; set; } + /// Empty-track middle (stretched gradient) RenderSurface id. + public uint BackTile { get; set; } + /// Empty-track right-cap RenderSurface id. + public uint BackRight { get; set; } + /// Coloured-fill left-cap RenderSurface id. + public uint FrontLeft { get; set; } + /// Coloured-fill middle (stretched gradient) RenderSurface id. + public uint FrontTile { get; set; } + /// Coloured-fill right-cap RenderSurface id. + public uint FrontRight { get; set; } public UiMeter() { ClickThrough = true; } @@ -49,19 +64,13 @@ public sealed class UiMeter : UiElement float? pct = Fill(); float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f; - if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0)) + if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) { - // Retail bar: empty track full width, colored fill cropped to p (left→right). - if (BackSpriteId != 0) - { - var (bt, _, _) = resolve(BackSpriteId); - if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); - } - if (FrontSpriteId != 0 && pct is not null && p > 0f) - { - var (ft, _, _) = resolve(FrontSpriteId); - if (ft != 0) ctx.DrawSprite(ft, 0, 0, Width * p, Height, 0, 0, p, 1, Vector4.One); - } + // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap). + DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true); + // Coloured fill: grows from the left to the value, no right-cap of its own. + if (pct is not null && p > 0f) + DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false); } else { @@ -83,4 +92,35 @@ public sealed class UiMeter : UiElement ctx.DrawString(label, tx, ty, LabelColor); } } + + /// + /// Draws a horizontal 3-slice into x at + /// (,): a native-width left-cap, a stretched + /// middle, and (when ) a native-width right-cap. Caps + /// are clamped so a narrow bar never overdraws. A 0 id skips that slice. + /// + private static void DrawHBar( + UiRenderContext ctx, Func resolve, + uint leftId, uint tileId, uint rightId, + float x, float y, float w, float h, bool withRightCap) + { + if (w <= 0f) return; + var (lt, lw, _) = resolve(leftId); + var (tt, _, _) = resolve(tileId); + var (rt, rw, _) = resolve(rightId); + + float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f; + float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f; + + if (lt != 0 && lcap > 0f) + ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One); + + float midX = x + lcap; + float midW = w - lcap - rcap; + if (tt != 0 && midW > 0f) + ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One); + + if (rcap > 0f) + ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One); + } } diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml index 2f7292e5..ca7e665f 100644 --- a/src/AcDream.App/UI/assets/vitals.xml +++ b/src/AcDream.App/UI/assets/vitals.xml @@ -1,5 +1,8 @@ - - - + + + diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj index 7d30223e..e964e5cb 100644 --- a/src/AcDream.Cli/AcDream.Cli.csproj +++ b/src/AcDream.Cli/AcDream.Cli.csproj @@ -9,6 +9,14 @@ + + + + + + diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index c4ad9e71..1eef5eb1 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AcDream.Cli; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -18,6 +19,31 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars") return DumpVitalsBars(dvbDatDir); } +if (args.Length >= 1 && args[0] == "render-vitals-mockup") +{ + string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png"; + if (string.IsNullOrWhiteSpace(rvmDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup [out.png]"); + return 2; + } + return VitalsMockup.Render(rvmDatDir, rvmOut); +} + +if (args.Length >= 1 && args[0] == "export-ui-sprite") +{ + string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? eusId = args.ElementAtOrDefault(2); + string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png"; + if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId)) + { + Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <0xId> [out.png]"); + return 2; + } + return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut); +} + // Phase 0: open the four AC dat files and print how many of each asset type live in them. // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory // to compare against what a future renderer needs. diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs new file mode 100644 index 00000000..9d4dbe72 --- /dev/null +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -0,0 +1,129 @@ +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace AcDream.Cli; + +/// +/// Headless PNG preview of the retail vital bars. Loads the real RenderSurface +/// sprites from the dats and composites them with the SAME horizontal 3-slice +/// logic the in-client UiMeter.DrawHBar uses (fixed-width bevelled caps + +/// a stretched gradient middle; the empty "back" track full width, the coloured +/// "front" fill grown from the left to the value). This lets the bar assembly be +/// verified by eye without launching the client + connecting to the server. +/// Bar sprite ids come from the vitals LayoutDesc (0x21000014) via dump-vitals-bars. +/// +public static class VitalsMockup +{ + private readonly record struct Vital( + string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR); + + private static readonly Vital[] Vitals = + { + new("health", 0x06001141, 0x06001140, 0x0600113F, 0x06001131, 0x06001132, 0x06001133), + new("stamina", 0x06001147, 0x06001146, 0x06001145, 0x06001137, 0x06001138, 0x06001139), + new("mana", 0x06001144, 0x06001143, 0x06001142, 0x06001134, 0x06001135, 0x06001136), + }; + + private static readonly float[] Fills = { 1.0f, 0.6f, 0.25f }; + + private const int BarW = 200, BarH = 14, PadX = 10, PadY = 10, GapY = 10, ColGap = 16, Zoom = 3; + + public static int Render(string datDir, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + int cols = Fills.Length; + int canvasW = PadX * 2 + cols * BarW + (cols - 1) * ColGap; + int canvasH = PadY * 2 + Vitals.Length * BarH + (Vitals.Length - 1) * GapY; + + // Retail vitals window backdrop is a dark translucent panel; pick a neutral + // dark gray so the bevels + gradient read clearly. + using var canvas = new Image(canvasW, canvasH, new Rgba32(38, 38, 44, 255)); + + for (int vi = 0; vi < Vitals.Length; vi++) + { + var v = Vitals[vi]; + using var bl = Load(dats, v.BackL); + using var bt = Load(dats, v.BackT); + using var br = Load(dats, v.BackR); + using var fl = Load(dats, v.FrontL); + using var ft = Load(dats, v.FrontT); + using var fr = Load(dats, v.FrontR); + + Console.WriteLine($"{v.Name,-8} back[{bl.Width}x{bl.Height} {bt.Width}x{bt.Height} {br.Width}x{br.Height}] " + + $"front[{fl.Width}x{fl.Height} {ft.Width}x{ft.Height} {fr.Width}x{fr.Height}]"); + + int y = PadY + vi * (BarH + GapY); + for (int ci = 0; ci < Fills.Length; ci++) + { + int x = PadX + ci * (BarW + ColGap); + DrawHBar(canvas, bl, bt, br, x, y, BarW, BarH, withRightCap: true); + int fw = (int)(BarW * Fills[ci]); + if (fw > 0) + DrawHBar(canvas, fl, ft, fr, x, y, fw, BarH, withRightCap: false); + } + } + + canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor)); + canvas.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)"); + return 0; + } + + public static int ExportSprite(string datDir, string idText, string outPath) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; } + uint id = ParseHex(idText); + if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var img = Load(dats, id); + img.SaveAsPng(outPath); + Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); + return 0; + } + + /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle, + /// optional native-width right-cap; caps clamped so a narrow bar never overdraws. + private static void DrawHBar( + Image canvas, Image left, Image tile, Image right, + int x, int y, int w, int h, bool withRightCap) + { + if (w <= 0) return; + int rcap = withRightCap ? Math.Min(right.Width, w) : 0; + int lcap = Math.Min(left.Width, w - rcap); + + if (lcap > 0) Blit(canvas, left, x, y, lcap, h); + int midX = x + lcap, midW = w - lcap - rcap; + if (midW > 0) Blit(canvas, tile, midX, y, midW, h); + if (rcap > 0) Blit(canvas, right, x + w - rcap, y, rcap, h); + } + + private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh) + { + if (dw <= 0 || dh <= 0) return; + using var s = src.Clone(c => c.Resize(dw, dh)); + canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); + } + + private static Image Load(DatCollection dats, uint id) + { + var rs = dats.Get(id); + if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); } + var dt = SurfaceDecoder.DecodeRenderSurface(rs); + return Image.LoadPixelData(dt.Rgba8, dt.Width, dt.Height); + } + + private static uint ParseHex(string s) + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; + return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; + } +} diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index ed717bbd..d45aa374 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -58,15 +58,21 @@ public class MarkupDocumentTests } [Fact] - public void Build_ParsesBackFrontSpriteIds() + public void Build_ParsesNineSliceBarSpriteIds() { const string xml = "" + - "" + + "" + ""; var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); var meter = Assert.IsType(panel.Children[1]); - Assert.Equal(0x06005F3Cu, meter.BackSpriteId); - Assert.Equal(0x06005F3Du, meter.FrontSpriteId); + Assert.Equal(0x06001141u, meter.BackLeft); + Assert.Equal(0x06001140u, meter.BackTile); + Assert.Equal(0x0600113Fu, meter.BackRight); + Assert.Equal(0x06001131u, meter.FrontLeft); + Assert.Equal(0x06001132u, meter.FrontTile); + Assert.Equal(0x06001133u, meter.FrontRight); Assert.NotNull(meter.SpriteResolve); } }