379 lines
17 KiB
C#
379 lines
17 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
#if UNITY_EDITOR
|
||
using UnityEditor;
|
||
#endif
|
||
|
||
namespace SplashEdit.RuntimeCode
|
||
{
|
||
/// <summary>
|
||
/// Collects all PSXCanvas hierarchies in the scene, bakes RectTransform
|
||
/// coordinates into PS1 pixel space, and produces <see cref="PSXCanvasData"/>
|
||
/// arrays ready for binary serialization.
|
||
/// </summary>
|
||
public static class PSXUIExporter
|
||
{
|
||
/// <summary>
|
||
/// Collect all PSXCanvas components and their child UI elements,
|
||
/// converting RectTransform coordinates to PS1 pixel space.
|
||
/// Also collects and deduplicates custom fonts.
|
||
/// </summary>
|
||
/// <param name="resolution">Target PS1 resolution (e.g. 320×240).</param>
|
||
/// <param name="fonts">Output: collected custom font data (max 3).</param>
|
||
/// <returns>Array of canvas data ready for binary writing.</returns>
|
||
public static PSXCanvasData[] CollectCanvases(Vector2 resolution, out PSXFontData[] fonts)
|
||
{
|
||
// Collect and deduplicate all custom fonts used by text elements
|
||
List<PSXFontAsset> uniqueFonts = new List<PSXFontAsset>();
|
||
|
||
#if UNITY_EDITOR
|
||
PSXCanvas[] canvases = Object.FindObjectsByType<PSXCanvas>(FindObjectsSortMode.None);
|
||
#else
|
||
PSXCanvas[] canvases = Object.FindObjectsOfType<PSXCanvas>();
|
||
#endif
|
||
if (canvases == null || canvases.Length == 0)
|
||
{
|
||
fonts = new PSXFontData[0];
|
||
return new PSXCanvasData[0];
|
||
}
|
||
|
||
// First pass: collect unique fonts
|
||
foreach (PSXCanvas canvas in canvases)
|
||
{
|
||
PSXUIText[] texts = canvas.GetComponentsInChildren<PSXUIText>(true);
|
||
foreach (PSXUIText txt in texts)
|
||
{
|
||
PSXFontAsset font = txt.GetEffectiveFont();
|
||
if (font != null && !uniqueFonts.Contains(font) && uniqueFonts.Count < 3)
|
||
uniqueFonts.Add(font);
|
||
}
|
||
}
|
||
|
||
// Build font data with VRAM positions.
|
||
// Each font gets its own texture page to avoid V-coordinate overflow.
|
||
// Font textures go at x=960:
|
||
// Font 1: y=0 (page 15,0) - 256px available
|
||
// Font 2: y=256 (page 15,1) - 208px available (system font at y=464)
|
||
// Font 3: not supported (would need different VRAM column)
|
||
// System font: (960, 464) in page (15,1), occupies y=464-511.
|
||
List<PSXFontData> fontDataList = new List<PSXFontData>();
|
||
ushort[] fontPageStarts = { 0, 256 }; // one per texture page
|
||
int fontPageIndex = 0;
|
||
|
||
foreach (PSXFontAsset fa in uniqueFonts)
|
||
{
|
||
byte[] pixelData = fa.ConvertTo4BPP();
|
||
if (pixelData == null) continue;
|
||
|
||
// Read advance widths directly from the font asset.
|
||
// These were computed during bitmap generation from the exact same
|
||
// CharacterInfo used to render the glyphs - guaranteed to match.
|
||
byte[] advances = fa.AdvanceWidths;
|
||
if (advances == null || advances.Length < 96)
|
||
{
|
||
Debug.LogWarning($"PSXUIExporter: Font '{fa.name}' has no stored advance widths. Using cell width as fallback.");
|
||
advances = new byte[96];
|
||
for (int i = 0; i < 96; i++) advances[i] = (byte)fa.GlyphWidth;
|
||
}
|
||
|
||
ushort texH = (ushort)fa.TextureHeight;
|
||
|
||
if (fontPageIndex >= fontPageStarts.Length)
|
||
{
|
||
Debug.LogError($"PSXUIExporter: Max 2 custom fonts supported (need separate texture pages). Skipping '{fa.name}'.");
|
||
continue;
|
||
}
|
||
|
||
ushort vramY = fontPageStarts[fontPageIndex];
|
||
int maxHeight = (fontPageIndex == 1) ? 208 : 256; // page 1 shares with system font
|
||
if (texH > maxHeight)
|
||
{
|
||
Debug.LogWarning($"PSXUIExporter: Font '{fa.name}' texture ({texH}px) exceeds page limit ({maxHeight}px). May be clipped.");
|
||
}
|
||
|
||
fontDataList.Add(new PSXFontData
|
||
{
|
||
Source = fa,
|
||
GlyphWidth = (byte)fa.GlyphWidth,
|
||
GlyphHeight = (byte)fa.GlyphHeight,
|
||
VramX = 960,
|
||
VramY = vramY,
|
||
TextureHeight = texH,
|
||
PixelData = pixelData,
|
||
AdvanceWidths = advances
|
||
});
|
||
fontPageIndex++;
|
||
}
|
||
fonts = fontDataList.ToArray();
|
||
|
||
// Second pass: collect canvases with font index assignment
|
||
List<PSXCanvasData> result = new List<PSXCanvasData>();
|
||
|
||
foreach (PSXCanvas canvas in canvases)
|
||
{
|
||
Canvas unityCanvas = canvas.GetComponent<Canvas>();
|
||
if (unityCanvas == null) continue;
|
||
|
||
RectTransform canvasRect = canvas.GetComponent<RectTransform>();
|
||
float canvasW = canvasRect.rect.width;
|
||
float canvasH = canvasRect.rect.height;
|
||
if (canvasW <= 0) canvasW = resolution.x;
|
||
if (canvasH <= 0) canvasH = resolution.y;
|
||
|
||
float scaleX = resolution.x / canvasW;
|
||
float scaleY = resolution.y / canvasH;
|
||
|
||
List<PSXUIElementData> elements = new List<PSXUIElementData>();
|
||
|
||
CollectImages(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
|
||
CollectBoxes(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
|
||
CollectTexts(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements, uniqueFonts);
|
||
CollectProgressBars(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
|
||
|
||
string name = canvas.CanvasName ?? "canvas";
|
||
if (name.Length > 24) name = name.Substring(0, 24);
|
||
|
||
result.Add(new PSXCanvasData
|
||
{
|
||
Name = name,
|
||
StartVisible = canvas.StartVisible,
|
||
SortOrder = canvas.SortOrder,
|
||
Elements = elements.ToArray()
|
||
});
|
||
}
|
||
|
||
return result.ToArray();
|
||
}
|
||
|
||
// ─── Coordinate baking helpers ───
|
||
|
||
/// <summary>
|
||
/// Convert a RectTransform into PS1 pixel-space layout values.
|
||
/// Handles anchor-based positioning and Y inversion.
|
||
/// </summary>
|
||
private static void BakeLayout(
|
||
RectTransform rt, RectTransform canvasRect,
|
||
float scaleX, float scaleY, Vector2 resolution,
|
||
out short x, out short y, out short w, out short h,
|
||
out byte anchorMinX, out byte anchorMinY,
|
||
out byte anchorMaxX, out byte anchorMaxY)
|
||
{
|
||
// Anchor values in 8.8 fixed point (0-255 maps to 0.0-~1.0)
|
||
anchorMinX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMin.x * 255f), 0, 255);
|
||
anchorMinY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMax.y) * 255f), 0, 255); // Y invert
|
||
anchorMaxX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMax.x * 255f), 0, 255);
|
||
anchorMaxY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMin.y) * 255f), 0, 255); // Y invert
|
||
|
||
if (Mathf.Approximately(rt.anchorMin.x, rt.anchorMax.x) &&
|
||
Mathf.Approximately(rt.anchorMin.y, rt.anchorMax.y))
|
||
{
|
||
// Fixed-size element with single anchor point
|
||
// anchoredPosition is the offset from the anchor in canvas pixels
|
||
float px = rt.anchoredPosition.x * scaleX;
|
||
float py = -rt.anchoredPosition.y * scaleY; // Y invert
|
||
float pw = rt.rect.width * scaleX;
|
||
float ph = rt.rect.height * scaleY;
|
||
|
||
// Adjust for pivot (anchoredPosition is at the pivot point)
|
||
px -= rt.pivot.x * pw;
|
||
py -= (1f - rt.pivot.y) * ph; // pivot Y inverted
|
||
|
||
x = (short)Mathf.RoundToInt(px);
|
||
y = (short)Mathf.RoundToInt(py);
|
||
w = (short)Mathf.Max(1, Mathf.RoundToInt(pw));
|
||
h = (short)Mathf.Max(1, Mathf.RoundToInt(ph));
|
||
}
|
||
else
|
||
{
|
||
// Stretched element: offsets from anchored edges
|
||
// offsetMin = distance from anchorMin corner, offsetMax = distance from anchorMax corner
|
||
float leftOff = rt.offsetMin.x * scaleX;
|
||
float rightOff = rt.offsetMax.x * scaleX;
|
||
float topOff = -rt.offsetMax.y * scaleY; // Y invert
|
||
float bottomOff = -rt.offsetMin.y * scaleY; // Y invert
|
||
|
||
// For stretched elements, x/y store the offset from the anchor start,
|
||
// and w/h store the combined inset (negative = shrink)
|
||
x = (short)Mathf.RoundToInt(leftOff);
|
||
y = (short)Mathf.RoundToInt(topOff);
|
||
w = (short)Mathf.RoundToInt(rightOff - leftOff);
|
||
h = (short)Mathf.RoundToInt(bottomOff - topOff);
|
||
}
|
||
}
|
||
|
||
private static string TruncateName(string name, int maxLen = 24)
|
||
{
|
||
if (string.IsNullOrEmpty(name)) return "";
|
||
return name.Length > maxLen ? name.Substring(0, maxLen) : name;
|
||
}
|
||
|
||
// ─── Collectors ───
|
||
|
||
private static void CollectImages(
|
||
Transform root, RectTransform canvasRect,
|
||
float scaleX, float scaleY, Vector2 resolution,
|
||
List<PSXUIElementData> elements)
|
||
{
|
||
PSXUIImage[] images = root.GetComponentsInChildren<PSXUIImage>(true);
|
||
foreach (PSXUIImage img in images)
|
||
{
|
||
RectTransform rt = img.GetComponent<RectTransform>();
|
||
if (rt == null) continue;
|
||
|
||
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
|
||
out short x, out short y, out short w, out short h,
|
||
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
|
||
|
||
var data = new PSXUIElementData
|
||
{
|
||
Type = PSXUIElementType.Image,
|
||
StartVisible = img.StartVisible,
|
||
Name = TruncateName(img.ElementName),
|
||
X = x, Y = y, W = w, H = h,
|
||
AnchorMinX = amin_x, AnchorMinY = amin_y,
|
||
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
|
||
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.r * 255f), 0, 255),
|
||
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.g * 255f), 0, 255),
|
||
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.b * 255f), 0, 255),
|
||
};
|
||
|
||
// Image texture data is filled in after VRAM packing by
|
||
// FillImageTextureData() — see PSXSceneExporter integration
|
||
if (img.PackedTexture != null)
|
||
{
|
||
PSXTexture2D tex = img.PackedTexture;
|
||
int expander = 16 / (int)tex.BitDepth;
|
||
data.TexpageX = tex.TexpageX;
|
||
data.TexpageY = tex.TexpageY;
|
||
data.ClutX = (ushort)tex.ClutPackingX;
|
||
data.ClutY = (ushort)tex.ClutPackingY;
|
||
data.U0 = (byte)(tex.PackingX * expander);
|
||
data.V0 = (byte)tex.PackingY;
|
||
data.U1 = (byte)(tex.PackingX * expander + tex.Width * expander / ((int)tex.BitDepth / (int)PSXBPP.TEX_4BIT));
|
||
data.V1 = (byte)(tex.PackingY + tex.Height);
|
||
data.BitDepthIndex = tex.BitDepth switch
|
||
{
|
||
PSXBPP.TEX_4BIT => 0,
|
||
PSXBPP.TEX_8BIT => 1,
|
||
PSXBPP.TEX_16BIT => 2,
|
||
_ => 2
|
||
};
|
||
}
|
||
|
||
elements.Add(data);
|
||
}
|
||
}
|
||
|
||
private static void CollectBoxes(
|
||
Transform root, RectTransform canvasRect,
|
||
float scaleX, float scaleY, Vector2 resolution,
|
||
List<PSXUIElementData> elements)
|
||
{
|
||
PSXUIBox[] boxes = root.GetComponentsInChildren<PSXUIBox>(true);
|
||
foreach (PSXUIBox box in boxes)
|
||
{
|
||
RectTransform rt = box.GetComponent<RectTransform>();
|
||
if (rt == null) continue;
|
||
|
||
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
|
||
out short x, out short y, out short w, out short h,
|
||
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
|
||
|
||
elements.Add(new PSXUIElementData
|
||
{
|
||
Type = PSXUIElementType.Box,
|
||
StartVisible = box.StartVisible,
|
||
Name = TruncateName(box.ElementName),
|
||
X = x, Y = y, W = w, H = h,
|
||
AnchorMinX = amin_x, AnchorMinY = amin_y,
|
||
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
|
||
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.r * 255f), 0, 255),
|
||
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.g * 255f), 0, 255),
|
||
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.b * 255f), 0, 255),
|
||
});
|
||
}
|
||
}
|
||
|
||
private static void CollectTexts(
|
||
Transform root, RectTransform canvasRect,
|
||
float scaleX, float scaleY, Vector2 resolution,
|
||
List<PSXUIElementData> elements,
|
||
List<PSXFontAsset> uniqueFonts = null)
|
||
{
|
||
PSXUIText[] texts = root.GetComponentsInChildren<PSXUIText>(true);
|
||
foreach (PSXUIText txt in texts)
|
||
{
|
||
RectTransform rt = txt.GetComponent<RectTransform>();
|
||
if (rt == null) continue;
|
||
|
||
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
|
||
out short x, out short y, out short w, out short h,
|
||
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
|
||
|
||
string defaultText = txt.DefaultText ?? "";
|
||
if (defaultText.Length > 63) defaultText = defaultText.Substring(0, 63);
|
||
|
||
// Resolve font index: 0 = system font, 1+ = custom font
|
||
byte fontIndex = 0;
|
||
PSXFontAsset effectiveFont = txt.GetEffectiveFont();
|
||
if (effectiveFont != null && uniqueFonts != null)
|
||
{
|
||
int idx = uniqueFonts.IndexOf(effectiveFont);
|
||
if (idx >= 0) fontIndex = (byte)(idx + 1); // 1-based for custom fonts
|
||
}
|
||
|
||
elements.Add(new PSXUIElementData
|
||
{
|
||
Type = PSXUIElementType.Text,
|
||
StartVisible = txt.StartVisible,
|
||
Name = TruncateName(txt.ElementName),
|
||
X = x, Y = y, W = w, H = h,
|
||
AnchorMinX = amin_x, AnchorMinY = amin_y,
|
||
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
|
||
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.r * 255f), 0, 255),
|
||
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.g * 255f), 0, 255),
|
||
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.b * 255f), 0, 255),
|
||
DefaultText = defaultText,
|
||
FontIndex = fontIndex,
|
||
});
|
||
}
|
||
}
|
||
|
||
private static void CollectProgressBars(
|
||
Transform root, RectTransform canvasRect,
|
||
float scaleX, float scaleY, Vector2 resolution,
|
||
List<PSXUIElementData> elements)
|
||
{
|
||
PSXUIProgressBar[] bars = root.GetComponentsInChildren<PSXUIProgressBar>(true);
|
||
foreach (PSXUIProgressBar bar in bars)
|
||
{
|
||
RectTransform rt = bar.GetComponent<RectTransform>();
|
||
if (rt == null) continue;
|
||
|
||
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
|
||
out short x, out short y, out short w, out short h,
|
||
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
|
||
|
||
elements.Add(new PSXUIElementData
|
||
{
|
||
Type = PSXUIElementType.Progress,
|
||
StartVisible = bar.StartVisible,
|
||
Name = TruncateName(bar.ElementName),
|
||
X = x, Y = y, W = w, H = h,
|
||
AnchorMinX = amin_x, AnchorMinY = amin_y,
|
||
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
|
||
// Fill color goes into primary color (used for the fill bar)
|
||
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.r * 255f), 0, 255),
|
||
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.g * 255f), 0, 255),
|
||
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.b * 255f), 0, 255),
|
||
// Background color goes into progress-specific fields
|
||
BgR = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.r * 255f), 0, 255),
|
||
BgG = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.g * 255f), 0, 255),
|
||
BgB = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.b * 255f), 0, 255),
|
||
ProgressValue = (byte)bar.InitialValue,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|