feat(D.2b): retail 3-slice vital bars + headless mockup verifier
Render each vital bar as a horizontal 3-slice from the real retail RenderSurface sprites (authoritative ids from the vitals LayoutDesc 0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a stretched glassy-gradient middle, and a fixed-width right-cap. The empty back track draws full width; the coloured front fill grows from the left to the value (the track owns the right end, so the fill omits its own right-cap). Replaces the flat single-sprite Alphablend overlay that read as the old UI - this is the bordered gradient look from the retail screenshot (red HP / gold stamina / blue mana). UiMeter gains the six 9-slice ids (BackLeft/Tile/Right + FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the backleft/backtile/backright/frontleft/fronttile/frontright attrs; vitals.xml carries the 18 per-vital ids. The temporary ACDREAM_BAR_PROVEOUT component grid is removed. Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite that assembles the bars with the SAME DrawHBar logic, so the sprite assembly can be verified by eye (Read the PNG) without launching the client + server - the fast UI-iteration loop the user asked for. export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84630517e3
commit
1453ff7da2
7 changed files with 242 additions and 26 deletions
|
|
@ -9,6 +9,14 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
|
||||
<!-- render-vitals-mockup: SurfaceDecoder (Core) + ImageSharp for a headless
|
||||
PNG composite of the retail vital bars, so the 3-slice assembly can be
|
||||
verified without launching the client. -->
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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 <dat-directory> [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 <dat-directory> <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.
|
||||
|
|
|
|||
129
src/AcDream.Cli/VitalsMockup.cs
Normal file
129
src/AcDream.Cli/VitalsMockup.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>UiMeter.DrawHBar</c> 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.
|
||||
/// </summary>
|
||||
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<Rgba32>(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;
|
||||
}
|
||||
|
||||
/// <summary>Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle,
|
||||
/// optional native-width right-cap; caps clamped so a narrow bar never overdraws.</summary>
|
||||
private static void DrawHBar(
|
||||
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> tile, Image<Rgba32> 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<Rgba32> canvas, Image<Rgba32> 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<Rgba32> Load(DatCollection dats, uint id)
|
||||
{
|
||||
var rs = dats.Get<RenderSurface>(id);
|
||||
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image<Rgba32>(1, 1); }
|
||||
var dt = SurfaceDecoder.DecodeRenderSurface(rs);
|
||||
return Image.LoadPixelData<Rgba32>(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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue