using System.Collections.Generic; using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif namespace SplashEdit.RuntimeCode { /// /// Collects all PSXCanvas hierarchies in the scene, bakes RectTransform /// coordinates into PS1 pixel space, and produces /// arrays ready for binary serialization. /// public static class PSXUIExporter { /// /// Collect all PSXCanvas components and their child UI elements, /// converting RectTransform coordinates to PS1 pixel space. /// Also collects and deduplicates custom fonts. /// /// Target PS1 resolution (e.g. 320×240). /// Output: collected custom font data (max 3). /// Array of canvas data ready for binary writing. public static PSXCanvasData[] CollectCanvases(Vector2 resolution, out PSXFontData[] fonts) { // Collect and deduplicate all custom fonts used by text elements List uniqueFonts = new List(); #if UNITY_EDITOR PSXCanvas[] canvases = Object.FindObjectsByType(FindObjectsSortMode.None); #else PSXCanvas[] canvases = Object.FindObjectsOfType(); #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(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 fontDataList = new List(); 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 result = new List(); foreach (PSXCanvas canvas in canvases) { Canvas unityCanvas = canvas.GetComponent(); if (unityCanvas == null) continue; RectTransform canvasRect = canvas.GetComponent(); 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 elements = new List(); 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 ─── /// /// Convert a RectTransform into PS1 pixel-space layout values. /// Handles anchor-based positioning and Y inversion. /// 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 elements) { PSXUIImage[] images = root.GetComponentsInChildren(true); foreach (PSXUIImage img in images) { RectTransform rt = img.GetComponent(); 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; // Convert PackingX from VRAM halfwords to texture-pixel U coords. // 4bpp: 4 pixels per halfword, 8bpp: 2, 16bpp: 1 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; // Width is already in source pixels = texture-pixel units data.U1 = (byte)(tex.PackingX * expander + tex.Width); 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 elements) { PSXUIBox[] boxes = root.GetComponentsInChildren(true); foreach (PSXUIBox box in boxes) { RectTransform rt = box.GetComponent(); 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 elements, List uniqueFonts = null) { PSXUIText[] texts = root.GetComponentsInChildren(true); foreach (PSXUIText txt in texts) { RectTransform rt = txt.GetComponent(); 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 elements) { PSXUIProgressBar[] bars = root.GetComponentsInChildren(true); foreach (PSXUIProgressBar bar in bars) { RectTransform rt = bar.GetComponent(); 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, }); } } } }