From 8914ba35cc9c51ada79e2ba160b97e1478d551b5 Mon Sep 17 00:00:00 2001 From: Jan Racek Date: Wed, 25 Mar 2026 12:25:48 +0100 Subject: [PATCH] Broken UI system --- Editor/Inspectors/PSXUIEditors.cs | 446 +++++++++++++++++++++++++ Editor/Inspectors/PSXUIEditors.cs.meta | 2 + Editor/PSXCutsceneEditor.cs | 122 ++++++- Editor/VramEditorWindow.cs | 75 +++++ Runtime/PSXCanvas.cs | 121 +++++++ Runtime/PSXCanvas.cs.meta | 2 + Runtime/PSXCanvasData.cs | 85 +++++ Runtime/PSXCanvasData.cs.meta | 2 + Runtime/PSXCutsceneExporter.cs | 67 +++- Runtime/PSXCutsceneTrack.cs | 14 +- Runtime/PSXFontAsset.cs | 323 ++++++++++++++++++ Runtime/PSXFontAsset.cs.meta | 2 + Runtime/PSXSceneExporter.cs | 30 +- Runtime/PSXSceneWriter.cs | 253 +++++++++++++- Runtime/PSXTrackType.cs | 6 + Runtime/PSXUIBox.cs | 33 ++ Runtime/PSXUIBox.cs.meta | 2 + Runtime/PSXUIElementType.cs | 14 + Runtime/PSXUIElementType.cs.meta | 2 + Runtime/PSXUIExporter.cs | 346 +++++++++++++++++++ Runtime/PSXUIExporter.cs.meta | 2 + Runtime/PSXUIImage.cs | 51 +++ Runtime/PSXUIImage.cs.meta | 2 + Runtime/PSXUIProgressBar.cs | 46 +++ Runtime/PSXUIProgressBar.cs.meta | 2 + Runtime/PSXUIText.cs | 60 ++++ Runtime/PSXUIText.cs.meta | 2 + Runtime/TexturePacker.cs | 7 +- 28 files changed, 2094 insertions(+), 25 deletions(-) create mode 100644 Editor/Inspectors/PSXUIEditors.cs create mode 100644 Editor/Inspectors/PSXUIEditors.cs.meta create mode 100644 Runtime/PSXCanvas.cs create mode 100644 Runtime/PSXCanvas.cs.meta create mode 100644 Runtime/PSXCanvasData.cs create mode 100644 Runtime/PSXCanvasData.cs.meta create mode 100644 Runtime/PSXFontAsset.cs create mode 100644 Runtime/PSXFontAsset.cs.meta create mode 100644 Runtime/PSXUIBox.cs create mode 100644 Runtime/PSXUIBox.cs.meta create mode 100644 Runtime/PSXUIElementType.cs create mode 100644 Runtime/PSXUIElementType.cs.meta create mode 100644 Runtime/PSXUIExporter.cs create mode 100644 Runtime/PSXUIExporter.cs.meta create mode 100644 Runtime/PSXUIImage.cs create mode 100644 Runtime/PSXUIImage.cs.meta create mode 100644 Runtime/PSXUIProgressBar.cs create mode 100644 Runtime/PSXUIProgressBar.cs.meta create mode 100644 Runtime/PSXUIText.cs create mode 100644 Runtime/PSXUIText.cs.meta diff --git a/Editor/Inspectors/PSXUIEditors.cs b/Editor/Inspectors/PSXUIEditors.cs new file mode 100644 index 0000000..168dfb8 --- /dev/null +++ b/Editor/Inspectors/PSXUIEditors.cs @@ -0,0 +1,446 @@ +using UnityEngine; +using UnityEditor; +using SplashEdit.RuntimeCode; + +namespace SplashEdit.EditorCode +{ + // ─── Scene Preview Gizmos ─── + // These draw filled rectangles in the Scene view for WYSIWYG UI preview. + + public static class PSXUIGizmos + { + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected)] + static void DrawBoxGizmo(PSXUIBox box, GizmoType gizmoType) + { + RectTransform rt = box.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + Color fill = box.BoxColor; + fill.a = (gizmoType & GizmoType.Selected) != 0 ? 0.8f : 0.5f; + Color border = Color.white; + border.a = (gizmoType & GizmoType.Selected) != 0 ? 1f : 0.4f; + Handles.DrawSolidRectangleWithOutline(corners, fill, border); + } + + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected)] + static void DrawImageGizmo(PSXUIImage image, GizmoType gizmoType) + { + RectTransform rt = image.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + + bool selected = (gizmoType & GizmoType.Selected) != 0; + + // Draw texture preview if available + if (image.SourceTexture != null) + { + Color tint = image.TintColor; + tint.a = selected ? 0.9f : 0.6f; + Handles.DrawSolidRectangleWithOutline(corners, tint * 0.3f, tint); + + // Draw texture in GUI overlay + Handles.BeginGUI(); + Vector2 min = HandleUtility.WorldToGUIPoint(corners[0]); + Vector2 max = HandleUtility.WorldToGUIPoint(corners[2]); + Rect screenRect = new Rect( + Mathf.Min(min.x, max.x), Mathf.Min(min.y, max.y), + Mathf.Abs(max.x - min.x), Mathf.Abs(max.y - min.y)); + if (screenRect.width > 2 && screenRect.height > 2) + { + GUI.color = new Color(tint.r, tint.g, tint.b, selected ? 0.9f : 0.5f); + GUI.DrawTexture(screenRect, image.SourceTexture, ScaleMode.StretchToFill); + GUI.color = Color.white; + } + Handles.EndGUI(); + } + else + { + Color fill = new Color(0.4f, 0.4f, 0.8f, selected ? 0.4f : 0.2f); + Handles.DrawSolidRectangleWithOutline(corners, fill, Color.cyan); + } + } + + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected)] + static void DrawTextGizmo(PSXUIText text, GizmoType gizmoType) + { + RectTransform rt = text.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + + bool selected = (gizmoType & GizmoType.Selected) != 0; + Color border = text.TextColor; + border.a = selected ? 1f : 0.5f; + Color fill = new Color(0, 0, 0, selected ? 0.3f : 0.1f); + Handles.DrawSolidRectangleWithOutline(corners, fill, border); + + // Draw text preview at actual PSX glyph size. + // On the PS1, chainprintf renders each glyph top-left aligned at the + // element's position — no centering. Mimic that here. + string label = string.IsNullOrEmpty(text.DefaultText) ? "[empty]" : text.DefaultText; + + PSXFontAsset font = text.GetEffectiveFont(); + int glyphW = font != null ? font.GlyphWidth : 8; + int glyphH = font != null ? font.GlyphHeight : 16; + + Handles.BeginGUI(); + // Convert top-left corner (corners[1] in Unity is top-left after GetWorldCorners) + Vector2 topLeft = HandleUtility.WorldToGUIPoint(corners[1]); + Vector2 botRight = HandleUtility.WorldToGUIPoint(corners[3]); + + // Calculate pixel scale: how many screen pixels represent one PSX pixel + float rectWorldW = Vector3.Distance(corners[0], corners[3]); + float rectScreenW = Mathf.Abs(botRight.x - topLeft.x); + float rectW = rt.rect.width; + float psxPixelScale = (rectW > 0.01f) ? rectScreenW / rectW : 1f; + + float charScreenW = glyphW * psxPixelScale; + float charScreenH = glyphH * psxPixelScale; + int fontSize = Mathf.Clamp(Mathf.RoundToInt(charScreenH * 0.75f), 6, 72); + + GUIStyle style = new GUIStyle(EditorStyles.label); + style.normal.textColor = text.TextColor; + style.alignment = TextAnchor.UpperLeft; + style.fontSize = fontSize; + style.wordWrap = false; + style.clipping = TextClipping.Clip; + style.padding = new RectOffset(0, 0, 0, 0); + + // Render position = top-left of the rect (matching PSX chainprintf behavior) + float guiW = Mathf.Abs(botRight.x - topLeft.x); + float guiH = Mathf.Abs(botRight.y - topLeft.y); + Rect guiRect = new Rect( + Mathf.Min(topLeft.x, botRight.x), + Mathf.Min(topLeft.y, botRight.y), + guiW, guiH); + + GUI.color = new Color(text.TextColor.r, text.TextColor.g, text.TextColor.b, selected ? 1f : 0.7f); + GUI.Label(guiRect, label, style); + GUI.color = Color.white; + Handles.EndGUI(); + } + + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected)] + static void DrawProgressBarGizmo(PSXUIProgressBar bar, GizmoType gizmoType) + { + RectTransform rt = bar.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + + bool selected = (gizmoType & GizmoType.Selected) != 0; + + // Background + Color bgColor = bar.BackgroundColor; + bgColor.a = selected ? 0.8f : 0.5f; + Handles.DrawSolidRectangleWithOutline(corners, bgColor, Color.white * (selected ? 1f : 0.4f)); + + // Fill portion + float t = bar.InitialValue / 100f; + if (t > 0.001f) + { + Vector3[] fillCorners = new Vector3[4]; + fillCorners[0] = corners[0]; // bottom-left + fillCorners[1] = corners[1]; // top-left + fillCorners[2] = Vector3.Lerp(corners[1], corners[2], t); // top-right (partial) + fillCorners[3] = Vector3.Lerp(corners[0], corners[3], t); // bottom-right (partial) + Color fillColor = bar.FillColor; + fillColor.a = selected ? 0.9f : 0.6f; + Handles.DrawSolidRectangleWithOutline(fillCorners, fillColor, Color.clear); + } + } + + [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected)] + static void DrawCanvasGizmo(PSXCanvas canvas, GizmoType gizmoType) + { + RectTransform rt = canvas.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + bool selected = (gizmoType & GizmoType.Selected) != 0; + Color border = selected ? Color.yellow : new Color(1, 1, 0, 0.3f); + Handles.DrawSolidRectangleWithOutline(corners, Color.clear, border); + + // Label + if (selected) + { + Vector2 res = PSXCanvas.PSXResolution; + Vector3 topMid = (corners[1] + corners[2]) * 0.5f; + string label = $"PSX Canvas: {canvas.CanvasName} ({res.x}x{res.y})"; + GUIStyle style = new GUIStyle(EditorStyles.boldLabel); + style.normal.textColor = Color.yellow; + Handles.Label(topMid, label, style); + } + } + } + /// + /// Custom inspector for PSXCanvas component. + /// Shows canvas name, visibility, sort order, font, and a summary of child elements. + /// + [CustomEditor(typeof(PSXCanvas))] + public class PSXCanvasEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + Vector2 res = PSXCanvas.PSXResolution; + EditorGUILayout.LabelField($"PSX Canvas ({res.x}x{res.y})", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("canvasName"), new GUIContent("Canvas Name", + "Name used from Lua: UI.FindCanvas(\"name\"). Max 24 chars.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible"), new GUIContent("Start Visible", + "Whether the canvas is visible when the scene loads.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("sortOrder"), new GUIContent("Sort Order", + "Render priority (0 = back, 255 = front).")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("defaultFont"), new GUIContent("Default Font", + "Default custom font for text elements. If empty, uses built-in system font (8x16).")); + + EditorGUILayout.Space(4); + + // Force Canvas configuration button + if (GUILayout.Button($"Reset Canvas to {res.x}x{res.y}")) + { + PSXCanvas.InvalidateResolutionCache(); + ((PSXCanvas)target).ConfigureCanvas(); + } + + EditorGUILayout.Space(4); + + // Element summary + PSXCanvas canvas = (PSXCanvas)target; + int imageCount = canvas.GetComponentsInChildren(true).Length; + int boxCount = canvas.GetComponentsInChildren(true).Length; + int textCount = canvas.GetComponentsInChildren(true).Length; + int progressCount = canvas.GetComponentsInChildren(true).Length; + int total = imageCount + boxCount + textCount + progressCount; + + EditorGUILayout.HelpBox( + $"Elements: {total} total\n" + + $" Images: {imageCount} | Boxes: {boxCount}\n" + + $" Texts: {textCount} | Progress Bars: {progressCount}", + total > 128 ? MessageType.Warning : MessageType.Info); + + if (total > 128) + EditorGUILayout.HelpBox("PS1 UI system supports max 128 elements total across all canvases.", MessageType.Error); + + serializedObject.ApplyModifiedProperties(); + } + } + + /// + /// Custom inspector for PSXUIImage component. + /// + [CustomEditor(typeof(PSXUIImage))] + public class PSXUIImageEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("PSX UI Image", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName"), new GUIContent("Element Name", + "Name used from Lua: UI.FindElement(canvas, \"name\"). Max 24 chars.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("sourceTexture"), new GUIContent("Source Texture", + "Texture to quantize and pack into VRAM.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("bitDepth"), new GUIContent("Bit Depth", + "VRAM storage depth. 4-bit = 16 colors, 8-bit = 256 colors, 16-bit = direct color.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("tintColor"), new GUIContent("Tint Color", + "Color multiply applied to the image (white = no tint).")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible")); + + // Texture size warning + PSXUIImage img = (PSXUIImage)target; + if (img.SourceTexture != null) + { + if (img.SourceTexture.width > 256 || img.SourceTexture.height > 256) + EditorGUILayout.HelpBox("Texture exceeds 256×256. It will be resized during export.", MessageType.Warning); + } + + serializedObject.ApplyModifiedProperties(); + } + } + + /// + /// Custom inspector for PSXUIBox component. + /// + [CustomEditor(typeof(PSXUIBox))] + public class PSXUIBoxEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("PSX UI Box", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("boxColor"), new GUIContent("Box Color", + "Solid fill color rendered as a FastFill primitive.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible")); + + serializedObject.ApplyModifiedProperties(); + } + } + + /// + /// Custom inspector for PSXUIText component. + /// + [CustomEditor(typeof(PSXUIText))] + public class PSXUITextEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("PSX UI Text", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("defaultText"), new GUIContent("Default Text", + "Initial text content. Max 63 chars. Change at runtime via UI.SetText().")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("textColor"), new GUIContent("Text Color", + "Text render color.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("fontOverride"), new GUIContent("Font Override", + "Custom font for this text element. If empty, uses the canvas default font or built-in system font (8x16).")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible")); + + // Character count + PSXUIText txt = (PSXUIText)target; + if (!string.IsNullOrEmpty(txt.DefaultText) && txt.DefaultText.Length > 63) + EditorGUILayout.HelpBox("Text exceeds 63 characters and will be truncated.", MessageType.Warning); + + // Font info + PSXFontAsset font = txt.GetEffectiveFont(); + if (font != null) + { + EditorGUILayout.HelpBox( + $"Font: {font.name} ({font.GlyphWidth}x{font.GlyphHeight} glyphs)", + MessageType.Info); + } + else + { + EditorGUILayout.HelpBox("Using built-in system font (8x16 glyphs).", MessageType.Info); + } + + serializedObject.ApplyModifiedProperties(); + } + } + + /// + /// Custom inspector for PSXUIProgressBar component. + /// + [CustomEditor(typeof(PSXUIProgressBar))] + public class PSXUIProgressBarEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("PSX UI Progress Bar", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("backgroundColor"), new GUIContent("Background Color", + "Color shown behind the fill bar.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("fillColor"), new GUIContent("Fill Color", + "Color of the progress fill.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("initialValue"), new GUIContent("Initial Value", + "Starting progress (0-100). Change via UI.SetProgress().")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible")); + + // Preview bar + Rect r = EditorGUILayout.GetControlRect(false, 16); + PSXUIProgressBar bar = (PSXUIProgressBar)target; + EditorGUI.DrawRect(r, bar.BackgroundColor); + Rect fill = r; + fill.width *= bar.InitialValue / 100f; + EditorGUI.DrawRect(fill, bar.FillColor); + + serializedObject.ApplyModifiedProperties(); + } + } + + /// + /// Custom inspector for PSXFontAsset ScriptableObject. + /// Shows font metrics, auto-conversion from TTF/OTF, and a preview of the glyph layout. + /// + [CustomEditor(typeof(PSXFontAsset))] + public class PSXFontAssetEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("PSX Font Asset", EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + // Source font (TTF/OTF) + EditorGUILayout.LabelField("Auto-Convert from Font", EditorStyles.miniBoldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("sourceFont"), new GUIContent("Source Font (TTF/OTF)", + "Assign a Unity Font (TrueType/OpenType). Click 'Generate Bitmap' to rasterize it.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("fontSize"), new GUIContent("Font Size", + "Pixel height for rasterization. Determines glyph cell height.")); + + PSXFontAsset font = (PSXFontAsset)target; + if (font.SourceFont != null) + { + if (GUILayout.Button("Generate Bitmap from Font")) + { + font.GenerateBitmapFromFont(); + } + + if (font.FontTexture == null) + EditorGUILayout.HelpBox("Click 'Generate Bitmap' to create the font texture.", MessageType.Info); + } + + EditorGUILayout.Space(8); + + // Manual bitmap + EditorGUILayout.LabelField("Manual Bitmap Source", EditorStyles.miniBoldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("fontTexture"), new GUIContent("Font Texture", + "256px wide bitmap. Glyphs in ASCII order from 0x20 (space). " + + "Transparent = background, opaque = foreground.")); + + EditorGUILayout.Space(4); + + // Glyph metrics + EditorGUILayout.LabelField("Glyph Metrics", EditorStyles.miniBoldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphWidth"), new GUIContent("Glyph Width", + "Width of each glyph cell in pixels.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphHeight"), new GUIContent("Glyph Height", + "Height of each glyph cell in pixels.")); + + EditorGUILayout.Space(4); + + int glyphsPerRow = font.GlyphsPerRow; + int rowCount = font.RowCount; + int totalH = font.TextureHeight; + int vramBytes = totalH * 128; // 128 bytes per row at 4bpp 256px + + EditorGUILayout.HelpBox( + $"Layout: {glyphsPerRow} glyphs/row, {rowCount} rows\n" + + $"Texture: 256 x {totalH} pixels (4bpp)\n" + + $"VRAM: {vramBytes} bytes ({vramBytes / 1024f:F1} KB)\n" + + $"Glyph size: {font.GlyphWidth} x {font.GlyphHeight}", + MessageType.Info); + + if (font.FontTexture != null) + { + if (font.FontTexture.width != 256) + EditorGUILayout.HelpBox($"Font texture must be 256 pixels wide (currently {font.FontTexture.width}).", MessageType.Error); + + // Show preview + Rect previewRect = EditorGUILayout.GetControlRect(false, 64); + GUI.DrawTexture(previewRect, font.FontTexture, ScaleMode.ScaleToFit); + } + + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Editor/Inspectors/PSXUIEditors.cs.meta b/Editor/Inspectors/PSXUIEditors.cs.meta new file mode 100644 index 0000000..ada6031 --- /dev/null +++ b/Editor/Inspectors/PSXUIEditors.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 385b3916e29dc0e48b2866851d1fc1a9 \ No newline at end of file diff --git a/Editor/PSXCutsceneEditor.cs b/Editor/PSXCutsceneEditor.cs index 7bf0419..d3eda35 100644 --- a/Editor/PSXCutsceneEditor.cs +++ b/Editor/PSXCutsceneEditor.cs @@ -87,6 +87,8 @@ namespace SplashEdit.RuntimeCode // Collect scene references for validation var exporterNames = new HashSet(); var audioNames = new HashSet(); + var canvasNames = new HashSet(); + var elementNames = new Dictionary>(); // canvas → element names var exporters = Object.FindObjectsByType(FindObjectsSortMode.None); foreach (var e in exporters) exporterNames.Add(e.gameObject.name); @@ -94,6 +96,26 @@ namespace SplashEdit.RuntimeCode foreach (var a in audioSources) if (!string.IsNullOrEmpty(a.ClipName)) audioNames.Add(a.ClipName); + var canvases = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (var c in canvases) + { + string cName = c.CanvasName ?? ""; + if (!string.IsNullOrEmpty(cName)) + { + canvasNames.Add(cName); + if (!elementNames.ContainsKey(cName)) + elementNames[cName] = new HashSet(); + // Gather all UI element names under this canvas + foreach (var box in c.GetComponentsInChildren()) + if (!string.IsNullOrEmpty(box.ElementName)) elementNames[cName].Add(box.ElementName); + foreach (var txt in c.GetComponentsInChildren()) + if (!string.IsNullOrEmpty(txt.ElementName)) elementNames[cName].Add(txt.ElementName); + foreach (var bar in c.GetComponentsInChildren()) + if (!string.IsNullOrEmpty(bar.ElementName)) elementNames[cName].Add(bar.ElementName); + foreach (var img in c.GetComponentsInChildren()) + if (!string.IsNullOrEmpty(img.ElementName)) elementNames[cName].Add(img.ElementName); + } + } // ── Tracks ── EditorGUILayout.Space(8); @@ -114,13 +136,39 @@ namespace SplashEdit.RuntimeCode EditorGUILayout.EndHorizontal(); bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; - EditorGUI.BeginDisabledGroup(isCameraTrack); - track.ObjectName = EditorGUILayout.TextField("Object Name", isCameraTrack ? "(camera)" : track.ObjectName); - EditorGUI.EndDisabledGroup(); + bool isUITrack = track.IsUITrack; + bool isUIElementTrack = track.IsUIElementTrack; - // Validation - if (!isCameraTrack && !string.IsNullOrEmpty(track.ObjectName) && !exporterNames.Contains(track.ObjectName)) - EditorGUILayout.HelpBox($"No PSXObjectExporter found for '{track.ObjectName}' in scene.", MessageType.Error); + if (isCameraTrack) + { + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.TextField("Target", "(camera)"); + EditorGUI.EndDisabledGroup(); + } + else if (isUITrack) + { + track.UICanvasName = EditorGUILayout.TextField("Canvas Name", track.UICanvasName); + if (!string.IsNullOrEmpty(track.UICanvasName) && !canvasNames.Contains(track.UICanvasName)) + EditorGUILayout.HelpBox($"No PSXCanvas with name '{track.UICanvasName}' in scene.", MessageType.Error); + + if (isUIElementTrack) + { + track.UIElementName = EditorGUILayout.TextField("Element Name", track.UIElementName); + if (!string.IsNullOrEmpty(track.UICanvasName) && !string.IsNullOrEmpty(track.UIElementName)) + { + if (elementNames.TryGetValue(track.UICanvasName, out var elNames) && !elNames.Contains(track.UIElementName)) + EditorGUILayout.HelpBox($"No UI element '{track.UIElementName}' found under canvas '{track.UICanvasName}'.", MessageType.Error); + } + } + } + else + { + track.ObjectName = EditorGUILayout.TextField("Object Name", track.ObjectName); + + // Validation + if (!string.IsNullOrEmpty(track.ObjectName) && !exporterNames.Contains(track.ObjectName)) + EditorGUILayout.HelpBox($"No PSXObjectExporter found for '{track.ObjectName}' in scene.", MessageType.Error); + } // ── Keyframes ── if (track.Keyframes == null) track.Keyframes = new List(); @@ -152,7 +200,7 @@ namespace SplashEdit.RuntimeCode else Debug.LogWarning("No active Scene View."); } } - else if (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY) + else if (!isUITrack && (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY)) { if (GUILayout.Button("From Sel.", GUILayout.Width(70))) { @@ -173,13 +221,46 @@ namespace SplashEdit.RuntimeCode switch (track.TrackType) { case PSXTrackType.ObjectActive: - bool active = EditorGUILayout.Toggle("Active", kf.Value.x > 0.5f); + case PSXTrackType.UICanvasVisible: + case PSXTrackType.UIElementVisible: + { + string label = track.TrackType == PSXTrackType.ObjectActive ? "Active" : "Visible"; + bool active = EditorGUILayout.Toggle(label, kf.Value.x > 0.5f); kf.Value = new Vector3(active ? 1f : 0f, 0, 0); break; + } case PSXTrackType.ObjectRotationY: + { float yRot = EditorGUILayout.FloatField("Y\u00b0", kf.Value.y); kf.Value = new Vector3(0, yRot, 0); break; + } + case PSXTrackType.UIProgress: + { + float progress = EditorGUILayout.Slider("Progress %", kf.Value.x, 0f, 100f); + kf.Value = new Vector3(progress, 0, 0); + break; + } + case PSXTrackType.UIPosition: + { + Vector2 pos = EditorGUILayout.Vector2Field("Position (px)", new Vector2(kf.Value.x, kf.Value.y)); + kf.Value = new Vector3(pos.x, pos.y, 0); + break; + } + case PSXTrackType.UIColor: + { + // Show as RGB 0-255 integers + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("R", GUILayout.Width(14)); + float r = EditorGUILayout.IntField(Mathf.Clamp(Mathf.RoundToInt(kf.Value.x), 0, 255), GUILayout.Width(40)); + EditorGUILayout.LabelField("G", GUILayout.Width(14)); + float g = EditorGUILayout.IntField(Mathf.Clamp(Mathf.RoundToInt(kf.Value.y), 0, 255), GUILayout.Width(40)); + EditorGUILayout.LabelField("B", GUILayout.Width(14)); + float b = EditorGUILayout.IntField(Mathf.Clamp(Mathf.RoundToInt(kf.Value.z), 0, 255), GUILayout.Width(40)); + EditorGUILayout.EndHorizontal(); + kf.Value = new Vector3(r, g, b); + break; + } default: kf.Value = EditorGUILayout.Vector3Field("Value", kf.Value); break; @@ -216,7 +297,7 @@ namespace SplashEdit.RuntimeCode track.Keyframes.Add(new PSXKeyframe { Frame = frame, Value = val }); } } - else if (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY) + else if (!isUITrack && (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY)) { if (GUILayout.Button("+ from Selected", GUILayout.Width(120))) { @@ -511,6 +592,15 @@ namespace SplashEdit.RuntimeCode if (_savedObjectActive.ContainsKey(track.ObjectName ?? "")) initialVal = new Vector3(_savedObjectActive[track.ObjectName] ? 1f : 0f, 0, 0); break; + // UI tracks: initial values stay zero (no scene preview state to capture) + case PSXTrackType.UICanvasVisible: + case PSXTrackType.UIElementVisible: + initialVal = new Vector3(1f, 0, 0); // assume visible by default + break; + case PSXTrackType.UIProgress: + case PSXTrackType.UIPosition: + case PSXTrackType.UIColor: + break; // zero is fine } Vector3 val = EvaluateTrack(track, frame, initialVal); @@ -541,6 +631,13 @@ namespace SplashEdit.RuntimeCode if (go != null) go.SetActive(val.x > 0.5f); break; } + // UI tracks: no scene preview, values are applied on PS1 only + case PSXTrackType.UICanvasVisible: + case PSXTrackType.UIElementVisible: + case PSXTrackType.UIProgress: + case PSXTrackType.UIPosition: + case PSXTrackType.UIColor: + break; } } } @@ -582,10 +679,11 @@ namespace SplashEdit.RuntimeCode if (track.Keyframes == null || track.Keyframes.Count == 0) return Vector3.zero; - // ObjectActive always uses step interpolation regardless of InterpMode - if (track.TrackType == PSXTrackType.ObjectActive) + // Step interpolation tracks: ObjectActive, UICanvasVisible, UIElementVisible + if (track.TrackType == PSXTrackType.ObjectActive || + track.TrackType == PSXTrackType.UICanvasVisible || + track.TrackType == PSXTrackType.UIElementVisible) { - // Use initial state if before first keyframe if (track.Keyframes.Count > 0 && track.Keyframes[0].Frame > 0 && frame < track.Keyframes[0].Frame) return initialValue; return EvaluateStep(track.Keyframes, frame); diff --git a/Editor/VramEditorWindow.cs b/Editor/VramEditorWindow.cs index 813495f..ea77bd8 100644 --- a/Editor/VramEditorWindow.cs +++ b/Editor/VramEditorWindow.cs @@ -26,6 +26,7 @@ namespace SplashEdit.EditorCode private Color bufferColor2 = new Color(0, 1, 0, 0.5f); private Color prohibitedColor = new Color(1, 0, 0, 0.3f); private PSXData _psxData; + private PSXFontData[] _cachedFonts; private static readonly Vector2[] resolutions = { @@ -144,6 +145,62 @@ namespace SplashEdit.EditorCode vramImage.SetPixel(x, VramHeight - y - 1, packed.vramPixels[x, y].GetUnityColor()); } } + + // Overlay custom font textures into the VRAM preview. + // Fonts live at x=960 (4bpp = 64 VRAM hwords wide), stacking from y=0. + PSXFontData[] fonts; + PSXUIExporter.CollectCanvases(selectedResolution, out fonts); + _cachedFonts = fonts; + if (fonts != null && fonts.Length > 0) + { + foreach (var font in fonts) + { + if (font.PixelData == null || font.PixelData.Length == 0) continue; + + int vramX = font.VramX; + int vramY = font.VramY; + int texH = font.TextureHeight; + int bytesPerRow = 256 / 2; // 4bpp: 2 pixels per byte, 256 pixels wide = 128 bytes/row + + // Each byte holds two 4bpp pixels. In VRAM, 4 4bpp pixels = 1 16-bit hword. + // So 256 4bpp pixels = 64 VRAM hwords. + for (int y = 0; y < texH && (vramY + y) < VramHeight; y++) + { + for (int x = 0; x < 64 && (vramX + x) < VramWidth; x++) + { + // Read 4 4bpp pixels from this VRAM hword position + int byteIdx = y * bytesPerRow + x * 2; + if (byteIdx + 1 >= font.PixelData.Length) continue; + byte b0 = font.PixelData[byteIdx]; + byte b1 = font.PixelData[byteIdx + 1]; + // Each byte: low nibble = first pixel, high nibble = second + // 4 pixels per hword: b0 low, b0 high, b1 low, b1 high + bool anyOpaque = ((b0 & 0x0F) | (b0 >> 4) | (b1 & 0x0F) | (b1 >> 4)) != 0; + + if (anyOpaque) + { + int px = vramX + x; + int py = VramHeight - 1 - (vramY + y); + if (px < VramWidth && py >= 0) + vramImage.SetPixel(px, py, new Color(0.8f, 0.8f, 1f)); + } + } + } + } + } + + // Also show system font area (960, 464)-(1023, 511) = 64x48 + for (int y = 464; y < 512 && y < VramHeight; y++) + { + for (int x = 960; x < 1024 && x < VramWidth; x++) + { + int py = VramHeight - 1 - y; + Color existing = vramImage.GetPixel(x, py); + if (existing.r < 0.01f && existing.g < 0.01f && existing.b < 0.01f) + vramImage.SetPixel(x, py, new Color(0.3f, 0.3f, 0.5f)); + } + } + vramImage.Apply(); // Prompt the user to select a file location and save the VRAM data. @@ -297,6 +354,24 @@ namespace SplashEdit.EditorCode EditorGUI.DrawRect(areaRect, prohibitedColor); } + // Draw font region overlays. + if (_cachedFonts != null) + { + Color fontColor = new Color(0.2f, 0.4f, 0.9f, 0.25f); + foreach (var font in _cachedFonts) + { + if (font.PixelData == null || font.PixelData.Length == 0) continue; + Rect fontRect = new Rect(vramRect.x + font.VramX, vramRect.y + font.VramY, 64, font.TextureHeight); + EditorGUI.DrawRect(fontRect, fontColor); + GUI.Label(new Rect(fontRect.x + 2, fontRect.y + 2, 60, 16), "Font", EditorStyles.miniLabel); + } + + // System font overlay + Rect sysFontRect = new Rect(vramRect.x + 960, vramRect.y + 464, 64, 48); + EditorGUI.DrawRect(sysFontRect, new Color(0.4f, 0.2f, 0.9f, 0.25f)); + GUI.Label(new Rect(sysFontRect.x + 2, sysFontRect.y + 2, 60, 16), "SysFont", EditorStyles.miniLabel); + } + GUILayout.EndHorizontal(); } } diff --git a/Runtime/PSXCanvas.cs b/Runtime/PSXCanvas.cs new file mode 100644 index 0000000..ace3c88 --- /dev/null +++ b/Runtime/PSXCanvas.cs @@ -0,0 +1,121 @@ +using UnityEngine; +using UnityEngine.UI; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace SplashEdit.RuntimeCode +{ + /// + /// Marks a Unity Canvas as a PSX UI canvas for splashpack export. + /// Attach to a GameObject that also has a Unity Canvas component. + /// Children with PSXUIImage / PSXUIBox / PSXUIText / PSXUIProgressBar + /// components will be exported as UI elements in this canvas. + /// Auto-configures the Canvas to the PSX resolution from PSXData settings. + /// + [RequireComponent(typeof(Canvas))] + [DisallowMultipleComponent] + [ExecuteAlways] + [AddComponentMenu("PSX/UI/PSX Canvas")] + public class PSXCanvas : MonoBehaviour + { + [Tooltip("Name used to reference this canvas from Lua (max 24 chars). Must be unique per scene.")] + [SerializeField] private string canvasName = "canvas"; + + [Tooltip("Whether this canvas is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + [Tooltip("Render order (0 = back, higher = front). Canvases render back-to-front.")] + [Range(0, 255)] + [SerializeField] private int sortOrder = 0; + + [Tooltip("Optional custom font for text elements in this canvas. If null, uses the built-in system font (8x16).")] + [SerializeField] private PSXFontAsset defaultFont; + + /// Canvas name for Lua access. Truncated to 24 chars on export. + public string CanvasName => canvasName; + + /// Initial visibility flag written into the splashpack. + public bool StartVisible => startVisible; + + /// Sort order in 0-255 range. + public byte SortOrder => (byte)Mathf.Clamp(sortOrder, 0, 255); + + /// Default font for text elements. Null = system font. + public PSXFontAsset DefaultFont => defaultFont; + + /// + /// PSX target resolution read from the PSXData asset. Falls back to 320x240. + /// Cached per domain reload for efficiency. + /// + public static Vector2 PSXResolution + { + get + { + if (!s_resolutionCached) + { + s_cachedResolution = LoadResolutionFromProject(); + s_resolutionCached = true; + } + return s_cachedResolution; + } + } + + private static Vector2 s_cachedResolution = new Vector2(320, 240); + private static bool s_resolutionCached = false; + + /// Invalidate the cached resolution (call when PSXData changes). + public static void InvalidateResolutionCache() + { + s_resolutionCached = false; + } + + private static Vector2 LoadResolutionFromProject() + { +#if UNITY_EDITOR + var data = AssetDatabase.LoadAssetAtPath("Assets/PSXData.asset"); + if (data != null) + return data.OutputResolution; +#endif + return new Vector2(320, 240); + } + + private void Reset() + { + InvalidateResolutionCache(); + ConfigureCanvas(); + } + + private void OnEnable() + { + ConfigureCanvas(); + } + +#if UNITY_EDITOR + private void OnValidate() + { + // Delay to avoid modifying in OnValidate directly + UnityEditor.EditorApplication.delayCall += ConfigureCanvas; + } +#endif + + /// + /// Force the Canvas + CanvasScaler to match the PSX resolution from project settings. + /// + public void ConfigureCanvas() + { + if (this == null) return; + + Vector2 res = PSXResolution; + + Canvas canvas = GetComponent(); + if (canvas != null) + { + canvas.renderMode = RenderMode.WorldSpace; + } + + RectTransform rt = GetComponent(); + rt.sizeDelta = new Vector2(res.x, res.y); + } + } +} diff --git a/Runtime/PSXCanvas.cs.meta b/Runtime/PSXCanvas.cs.meta new file mode 100644 index 0000000..3933bc5 --- /dev/null +++ b/Runtime/PSXCanvas.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc481397cd94e03409e462478df09d58 \ No newline at end of file diff --git a/Runtime/PSXCanvasData.cs b/Runtime/PSXCanvasData.cs new file mode 100644 index 0000000..b9110bf --- /dev/null +++ b/Runtime/PSXCanvasData.cs @@ -0,0 +1,85 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// Pre-computed data for one UI canvas and its elements, + /// ready for binary serialization by . + /// Populated by during the export pipeline. + /// + public struct PSXCanvasData + { + /// Canvas name (max 24 chars, truncated on export). + public string Name; + + /// Initial visibility flag. + public bool StartVisible; + + /// Sort order (0 = back, 255 = front). + public byte SortOrder; + + /// Exported elements belonging to this canvas. + public PSXUIElementData[] Elements; + } + + /// + /// Pre-computed data for one UI element, ready for binary serialization. + /// Matches the 48-byte on-disk element record parsed by uisystem.cpp. + /// + public struct PSXUIElementData + { + // Identity + public PSXUIElementType Type; + public bool StartVisible; + public string Name; // max 24 chars + + // Layout (PS1 pixel coords, already Y-inverted) + public short X, Y, W, H; + + // Anchors (8.8 fixed-point: 0=0.0, 128=0.5, 255≈1.0) + public byte AnchorMinX, AnchorMinY; + public byte AnchorMaxX, AnchorMaxY; + + // Primary color (RGB) + public byte ColorR, ColorG, ColorB; + + // Type-specific: Image + public byte TexpageX, TexpageY; + public ushort ClutX, ClutY; + public byte U0, V0, U1, V1; + public byte BitDepthIndex; // 0=4bit, 1=8bit, 2=16bit + + // Type-specific: Progress + public byte BgR, BgG, BgB; + public byte ProgressValue; + + // Type-specific: Text + public string DefaultText; // max 63 chars + public byte FontIndex; // 0 = system font, 1+ = custom font + } + + /// + /// Export data for a custom font to be embedded in the splashpack. + /// + public struct PSXFontData + { + /// Source font asset (for identification/dedup). + public PSXFontAsset Source; + + /// Glyph cell width in pixels. + public byte GlyphWidth; + + /// Glyph cell height in pixels. + public byte GlyphHeight; + + /// VRAM X position for upload (16-bit pixel units). + public ushort VramX; + + /// VRAM Y position for upload (16-bit pixel units). + public ushort VramY; + + /// Texture height in pixels (width is always 256 in 4bpp = 64 VRAM hwords). + public ushort TextureHeight; + + /// Packed 4bpp pixel data ready for VRAM upload. + public byte[] PixelData; + } +} diff --git a/Runtime/PSXCanvasData.cs.meta b/Runtime/PSXCanvasData.cs.meta new file mode 100644 index 0000000..fca5ede --- /dev/null +++ b/Runtime/PSXCanvasData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f4142a9858eccd04eac0b826af8f3621 \ No newline at end of file diff --git a/Runtime/PSXCutsceneExporter.cs b/Runtime/PSXCutsceneExporter.cs index 846f156..85d1466 100644 --- a/Runtime/PSXCutsceneExporter.cs +++ b/Runtime/PSXCutsceneExporter.cs @@ -151,9 +151,7 @@ namespace SplashEdit.RuntimeCode for (int ti = 0; ti < trackCount; ti++) { PSXCutsceneTrack track = clip.Tracks[ti]; - bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; - string objName = isCameraTrack ? "" : (track.ObjectName ?? ""); - if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN); + string objName = GetTrackTargetName(track); int kfCount = Mathf.Min(track.Keyframes?.Count ?? 0, MAX_KEYFRAMES); @@ -228,6 +226,39 @@ namespace SplashEdit.RuntimeCode writer.Write((short)0); break; } + case PSXTrackType.UICanvasVisible: + case PSXTrackType.UIElementVisible: + { + // Step: values[0] = 0 or 1 + writer.Write((short)(kf.Value.x > 0.5f ? 1 : 0)); + writer.Write((short)0); + writer.Write((short)0); + break; + } + case PSXTrackType.UIProgress: + { + // values[0] = progress 0-100 as int16 + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(kf.Value.x), 0, 100)); + writer.Write((short)0); + writer.Write((short)0); + break; + } + case PSXTrackType.UIPosition: + { + // values[0] = x, values[1] = y (PSX screen coordinates, raw int16) + writer.Write((short)Mathf.RoundToInt(kf.Value.x)); + writer.Write((short)Mathf.RoundToInt(kf.Value.y)); + writer.Write((short)0); + break; + } + case PSXTrackType.UIColor: + { + // values[0] = r, values[1] = g, values[2] = b (0-255) + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(kf.Value.x), 0, 255)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(kf.Value.y), 0, 255)); + writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(kf.Value.z), 0, 255)); + break; + } } } @@ -240,13 +271,11 @@ namespace SplashEdit.RuntimeCode } } - // ── Object name strings (per track) ── + // ── Object / UI target name strings (per track) ── for (int ti = 0; ti < trackCount; ti++) { PSXCutsceneTrack track = clip.Tracks[ti]; - bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; - string objName = isCameraTrack ? "" : (track.ObjectName ?? ""); - if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN); + string objName = GetTrackTargetName(track); if (objName.Length > 0) { @@ -329,5 +358,29 @@ namespace SplashEdit.RuntimeCode if (padding > 0) writer.Write(new byte[padding]); } + + /// + /// Get the target name string for a track. + /// Camera tracks: empty. Object tracks: ObjectName. + /// UICanvasVisible: UICanvasName. + /// UI element tracks: "UICanvasName/UIElementName". + /// + private static string GetTrackTargetName(PSXCutsceneTrack track) + { + bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; + if (isCameraTrack) return ""; + + string name; + if (track.IsUIElementTrack) + name = (track.UICanvasName ?? "") + "/" + (track.UIElementName ?? ""); + else if (track.IsUITrack) + name = track.UICanvasName ?? ""; + else + name = track.ObjectName ?? ""; + + if (name.Length > MAX_NAME_LEN) + name = name.Substring(0, MAX_NAME_LEN); + return name; + } } } diff --git a/Runtime/PSXCutsceneTrack.cs b/Runtime/PSXCutsceneTrack.cs index 478435b..26d97c4 100644 --- a/Runtime/PSXCutsceneTrack.cs +++ b/Runtime/PSXCutsceneTrack.cs @@ -13,10 +13,22 @@ namespace SplashEdit.RuntimeCode [Tooltip("What property this track drives.")] public PSXTrackType TrackType; - [Tooltip("Target GameObject name (must match a PSXObjectExporter). Leave empty for camera tracks.")] + [Tooltip("Target GameObject name (must match a PSXObjectExporter). Leave empty for camera/UI tracks.")] public string ObjectName = ""; + [Tooltip("For UI tracks: canvas name (e.g. 'hud'). Used by UICanvasVisible and to resolve elements.")] + public string UICanvasName = ""; + + [Tooltip("For UI element tracks: element name within the canvas. Used by UIElementVisible, UIProgress, UIPosition, UIColor.")] + public string UIElementName = ""; + [Tooltip("Keyframes for this track. Sort by frame number.")] public List Keyframes = new List(); + + /// Returns true if this track type targets a UI canvas or element. + public bool IsUITrack => TrackType >= PSXTrackType.UICanvasVisible && TrackType <= PSXTrackType.UIColor; + + /// Returns true if this track type targets a UI element (not just a canvas). + public bool IsUIElementTrack => TrackType >= PSXTrackType.UIElementVisible && TrackType <= PSXTrackType.UIColor; } } diff --git a/Runtime/PSXFontAsset.cs b/Runtime/PSXFontAsset.cs new file mode 100644 index 0000000..529544e --- /dev/null +++ b/Runtime/PSXFontAsset.cs @@ -0,0 +1,323 @@ +using UnityEngine; +using System; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace SplashEdit.RuntimeCode +{ + /// + /// Defines a custom bitmap font for PSX UI text rendering. + /// + /// Two workflows are supported: + /// 1. Manual bitmap: assign a 256px-wide texture with glyphs in ASCII order. + /// 2. Auto-convert: assign a Unity Font (TTF/OTF) and set glyph dimensions. + /// The bitmap is generated automatically from the font at the chosen size. + /// + /// On export the texture is converted to 4bpp indexed format: + /// pixel value 0 = transparent, value 1 = opaque. + /// The color is applied at runtime through the CLUT. + /// + [CreateAssetMenu(fileName = "New PSXFont", menuName = "PSX/Font Asset")] + public class PSXFontAsset : ScriptableObject + { + [Header("Source — Option A: TrueType/OTF Font")] + [Tooltip("Assign a Unity Font asset (TTF/OTF). A bitmap will be auto-generated.\n" + + "Leave this empty to use a manual bitmap texture instead.")] + [SerializeField] private Font sourceFont; + + [Tooltip("Font size in pixels used to rasterize the TrueType font. " + + "This determines glyph height; width is derived auto-fit per glyph.")] + [Range(6, 32)] + [SerializeField] private int fontSize = 16; + + [Header("Source — Option B: Manual Bitmap")] + [Tooltip("Font bitmap texture. Must be 256 pixels wide. " + + "Glyphs arranged left-to-right, top-to-bottom in ASCII order starting from space (0x20). " + + "Transparent pixels = background, any opaque pixel = foreground.")] + [SerializeField] private Texture2D fontTexture; + + [Header("Glyph Metrics")] + [Tooltip("Width of each glyph cell in pixels (e.g. 8 for an 8x16 font).")] + [Range(4, 32)] + [SerializeField] private int glyphWidth = 8; + + [Tooltip("Height of each glyph cell in pixels (e.g. 16 for an 8x16 font).")] + [Range(4, 32)] + [SerializeField] private int glyphHeight = 16; + + /// The Unity Font source (TTF/OTF). Null if using manual bitmap. + public Font SourceFont => sourceFont; + + /// Font size for TrueType rasterization. + public int FontSize => fontSize; + + /// Source font texture (256px wide, monochrome). May be auto-generated. + public Texture2D FontTexture => fontTexture; + + /// Width of each glyph cell in pixels. + public int GlyphWidth => glyphWidth; + + /// Height of each glyph cell in pixels. + public int GlyphHeight => glyphHeight; + + /// Number of glyphs per row (always 256 / glyphWidth). + public int GlyphsPerRow => 256 / glyphWidth; + + /// Number of rows needed for all 95 printable ASCII characters. + public int RowCount => Mathf.CeilToInt(95f / GlyphsPerRow); + + /// Total height of the font texture in pixels. + public int TextureHeight => RowCount * glyphHeight; + +#if UNITY_EDITOR + /// + /// Generate a 256px-wide monochrome bitmap from the assigned TrueType font. + /// Each printable ASCII character (0x20–0x7E) is rasterized into + /// a fixed-size glyph cell. The result is stored in fontTexture. + /// + public void GenerateBitmapFromFont() + { + if (sourceFont == null) + { + Debug.LogWarning("PSXFontAsset: No source font assigned."); + return; + } + + // Request all printable ASCII characters from the font at the target size + CharacterInfo ci; + string ascii = ""; + for (int c = 0x20; c <= 0x7E; c++) ascii += (char)c; + sourceFont.RequestCharactersInTexture(ascii, fontSize, FontStyle.Normal); + + int texW = 256; + int glyphsPerRow = texW / glyphWidth; + int rowCount = Mathf.CeilToInt(95f / glyphsPerRow); + int texH = rowCount * glyphHeight; + + // Create output texture + Texture2D bmp = new Texture2D(texW, texH, TextureFormat.RGBA32, false); + bmp.filterMode = FilterMode.Point; + bmp.wrapMode = TextureWrapMode.Clamp; + + // Fill transparent + Color[] clearPixels = new Color[texW * texH]; + for (int i = 0; i < clearPixels.Length; i++) + clearPixels[i] = new Color(0, 0, 0, 0); + bmp.SetPixels(clearPixels); + + // Get the font's internal texture for reading glyph pixels + Texture2D fontTex = (Texture2D)sourceFont.material.mainTexture; + Color[] fontPixels = null; + int fontTexW = 0, fontTexH = 0; + + if (fontTex != null) + { + // Make sure it's readable + fontTexW = fontTex.width; + fontTexH = fontTex.height; + try + { + if (fontTex != null) + { + fontTexW = fontTex.width; + fontTexH = fontTex.height; + + // Make a readable copy + RenderTexture rt = RenderTexture.GetTemporary(fontTexW, fontTexH, 0); + Graphics.Blit(fontTex, rt); + RenderTexture.active = rt; + + Texture2D readableCopy = new Texture2D(fontTexW, fontTexH); + readableCopy.ReadPixels(new Rect(0, 0, fontTexW, fontTexH), 0, 0); + readableCopy.Apply(); + + RenderTexture.active = null; + RenderTexture.ReleaseTemporary(rt); + + // Get pixels from the copy + fontPixels = readableCopy.GetPixels(); + + // Done with it — dispose immediately + DestroyImmediate(readableCopy); + } + } + catch(Exception e) + { + Debug.LogException(e); + // Font texture not readable — fall back to RenderTexture copy + RenderTexture rt = RenderTexture.GetTemporary(fontTexW, fontTexH, 0, RenderTextureFormat.ARGB32); + Graphics.Blit(fontTex, rt); + RenderTexture prev = RenderTexture.active; + RenderTexture.active = rt; + Texture2D readable = new Texture2D(fontTexW, fontTexH, TextureFormat.RGBA32, false); + readable.ReadPixels(new Rect(0, 0, fontTexW, fontTexH), 0, 0); + readable.Apply(); + RenderTexture.active = prev; + RenderTexture.ReleaseTemporary(rt); + fontPixels = readable.GetPixels(); + DestroyImmediate(readable); + } + } + + // Rasterize each glyph into fixed-size cells. + // Glyphs are scaled to fill their bounding box using bilinear sampling. + // A 1-pixel margin is kept on each side to prevent bleeding between glyphs. + int margin = 1; + int drawW = glyphWidth - margin * 2; + int drawH = glyphHeight - margin * 2; + if (drawW < 2) drawW = glyphWidth; // fallback if cell too tiny + if (drawH < 2) drawH = glyphHeight; + + for (int idx = 0; idx < 95; idx++) + { + char ch = (char)(0x20 + idx); + int col = idx % glyphsPerRow; + int row = idx / glyphsPerRow; + int cellX = col * glyphWidth; + int cellY = row * glyphHeight; // top-down in output + + if (!sourceFont.GetCharacterInfo(ch, out ci, fontSize, FontStyle.Normal)) + continue; + + if (fontPixels == null) continue; + + // ci gives UV coords in font texture + int gw = Mathf.Abs(ci.maxX - ci.minX); + int gh = Mathf.Abs(ci.maxY - ci.minY); + if (gw <= 0 || gh <= 0) continue; + + // UV rect of the source glyph in the font's internal texture + float uvLeft = Mathf.Min(ci.uvBottomLeft.x, ci.uvTopRight.x); + float uvRight = Mathf.Max(ci.uvBottomLeft.x, ci.uvTopRight.x); + float uvBottom = Mathf.Min(ci.uvBottomLeft.y, ci.uvTopRight.y); + float uvTop = Mathf.Max(ci.uvBottomLeft.y, ci.uvTopRight.y); + bool flipped = ci.uvBottomLeft.y > ci.uvTopRight.y; + + // Scale the glyph to fit the cell exactly (minus margin). + // Each output pixel samples the source glyph proportionally. + for (int py = 0; py < drawH; py++) + { + for (int px = 0; px < drawW; px++) + { + // Map output pixel to source glyph UV + float srcU = (px + 0.5f) / drawW; + float srcV = (py + 0.5f) / drawH; + + float u = Mathf.Lerp(uvLeft, uvRight, srcU); + float v; + if (flipped) + v = Mathf.Lerp(uvTop, uvBottom, srcV); + else + v = Mathf.Lerp(uvBottom, uvTop, 1f - srcV); + + int sx = Mathf.Clamp(Mathf.FloorToInt(u * fontTexW), 0, fontTexW - 1); + int sy = Mathf.Clamp(Mathf.FloorToInt(v * fontTexH), 0, fontTexH - 1); + Color sc = fontPixels[sy * fontTexW + sx]; + + // Threshold: if alpha or luminance > 0.3, mark as opaque + bool opaque = sc.a > 0.3f || (sc.r + sc.g + sc.b) / 3f > 0.3f; + if (!opaque) continue; + + // Write to output: output Y is top-down, Unity texture Y is bottom-up + int outX = cellX + margin + px; + int outY = texH - 1 - (cellY + margin + py); + if (outX >= 0 && outX < texW && outY >= 0 && outY < texH) + bmp.SetPixel(outX, outY, Color.white); + } + } + } + + bmp.Apply(); + + // Save as asset + string path = AssetDatabase.GetAssetPath(this); + if (string.IsNullOrEmpty(path)) + { + fontTexture = bmp; + return; + } + + string dir = System.IO.Path.GetDirectoryName(path); + string texPath = dir + "/" + name + "_bitmap.png"; + byte[] pngData = bmp.EncodeToPNG(); + System.IO.File.WriteAllBytes(texPath, pngData); + DestroyImmediate(bmp); + + AssetDatabase.ImportAsset(texPath, ImportAssetOptions.ForceUpdate); + // Set import settings for point filter, no compression + TextureImporter importer = AssetImporter.GetAtPath(texPath) as TextureImporter; + if (importer != null) + { + importer.textureType = TextureImporterType.Default; + importer.filterMode = FilterMode.Point; + importer.textureCompression = TextureImporterCompression.Uncompressed; + importer.isReadable = true; + importer.npotScale = TextureImporterNPOTScale.None; + importer.mipmapEnabled = false; + importer.alphaIsTransparency = true; + importer.SaveAndReimport(); + } + + fontTexture = AssetDatabase.LoadAssetAtPath(texPath); + EditorUtility.SetDirty(this); + AssetDatabase.SaveAssets(); + Debug.Log($"PSXFontAsset: Generated bitmap {texW}x{texH} at {texPath}"); + } +#endif + + /// + /// Convert the font texture to packed 4bpp data suitable for PS1 VRAM upload. + /// Returns byte array where each byte contains two 4-bit pixels. + /// Pixel value 0 = transparent, value 1 = opaque. + /// Output is 256 pixels wide × TextureHeight pixels tall. + /// + public byte[] ConvertTo4BPP() + { + if (fontTexture == null) return null; + + int texW = 256; // always 256 pixels wide in 4bpp + int texH = TextureHeight; + // 4bpp: 2 pixels per byte → 128 bytes per row + int bytesPerRow = texW / 2; + byte[] result = new byte[bytesPerRow * texH]; + + Color[] pixels = fontTexture.GetPixels(0, 0, fontTexture.width, fontTexture.height); + int srcW = fontTexture.width; + int srcH = fontTexture.height; + + for (int y = 0; y < texH; y++) + { + for (int x = 0; x < texW; x += 2) + { + byte lo = SamplePixel(pixels, srcW, srcH, x, y, texH); + byte hi = SamplePixel(pixels, srcW, srcH, x + 1, y, texH); + // Pack two 4-bit values: low nibble first (PS1 byte order) + result[y * bytesPerRow + x / 2] = (byte)(lo | (hi << 4)); + } + } + + return result; + } + + /// + /// Sample the font texture at (x, y) in output space. + /// Y=0 is top of texture (PS1 convention: top-down). + /// Returns 0 (transparent) or 1 (opaque). + /// + private byte SamplePixel(Color[] pixels, int srcW, int srcH, int x, int y, int outH) + { + if (x >= srcW || y >= srcH) return 0; + // Source texture is bottom-up (Unity convention), output is top-down (PS1) + int srcY = srcH - 1 - y; + if (srcY < 0 || srcY >= srcH) return 0; + Color c = pixels[srcY * srcW + x]; + // Opaque if alpha > 0.5 or luminance > 0.5 (handles both alpha-based and grayscale fonts) + if (c.a > 0.5f || (c.r + c.g + c.b) / 3f > 0.5f) + return 1; + return 0; + } + } +} + diff --git a/Runtime/PSXFontAsset.cs.meta b/Runtime/PSXFontAsset.cs.meta new file mode 100644 index 0000000..ddecc09 --- /dev/null +++ b/Runtime/PSXFontAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b5bc0d8f6252841439e81a8406c37859 \ No newline at end of file diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs index 8b7098b..8f04489 100644 --- a/Runtime/PSXSceneExporter.cs +++ b/Runtime/PSXSceneExporter.cs @@ -55,6 +55,10 @@ namespace SplashEdit.RuntimeCode // Phase 5: Room/portal system (interior scenes) private PSXRoomBuilder _roomBuilder; + // Phase 6: UI canvases + private PSXCanvasData[] _canvases; + private PSXFontData[] _fonts; + private PSXData _psxData; private Vector2 selectedResolution; @@ -106,9 +110,27 @@ namespace SplashEdit.RuntimeCode _interactables = FindObjectsByType(FindObjectsSortMode.None); _audioSources = FindObjectsByType(FindObjectsSortMode.None); + // Collect UI image textures for VRAM packing alongside 3D textures + PSXUIImage[] uiImages = FindObjectsByType(FindObjectsSortMode.None); + List uiTextures = new List(); + foreach (PSXUIImage img in uiImages) + { + if (img.SourceTexture != null) + { + Utils.SetTextureImporterFormat(img.SourceTexture, true); + PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(img.SourceTexture, img.BitDepth); + tex.OriginalTexture = img.SourceTexture; + img.PackedTexture = tex; + uiTextures.Add(tex); + } + } + EditorUtility.ClearProgressBar(); - PackTextures(); + PackTextures(uiTextures); + + // Collect UI canvases after VRAM packing (so PSXUIImage.PackedTexture has valid VRAM coords) + _canvases = PSXUIExporter.CollectCanvases(selectedResolution, out _fonts); PSXPlayer player = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault(); if (player != null) @@ -185,7 +207,7 @@ namespace SplashEdit.RuntimeCode #endif } - void PackTextures() + void PackTextures(List additionalTextures = null) { (Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout); @@ -196,7 +218,7 @@ namespace SplashEdit.RuntimeCode } VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas); - var packed = tp.PackTexturesIntoVRAM(_exporters); + var packed = tp.PackTexturesIntoVRAM(_exporters, additionalTextures); _exporters = packed.processedObjects; _atlases = packed.atlases; @@ -260,6 +282,8 @@ namespace SplashEdit.RuntimeCode fogDensity = FogDensity, cutscenes = Cutscenes, audioSources = _audioSources, + canvases = _canvases, + fonts = _fonts, }; PSXSceneWriter.Write(path, in scene, (msg, type) => diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs index 248a35d..aa058e1 100644 --- a/Runtime/PSXSceneWriter.cs +++ b/Runtime/PSXSceneWriter.cs @@ -33,6 +33,12 @@ namespace SplashEdit.RuntimeCode public PSXCutsceneClip[] cutscenes; public PSXAudioSource[] audioSources; + // UI canvases (v13) + public PSXCanvasData[] canvases; + + // Custom fonts (v13, embedded in UI block) + public PSXFontData[] fonts; + // Player public Vector3 playerPos; public Quaternion playerRot; @@ -119,7 +125,7 @@ namespace SplashEdit.RuntimeCode // ────────────────────────────────────────────────────── writer.Write('S'); writer.Write('P'); - writer.Write((ushort)12); // version + writer.Write((ushort)13); // version writer.Write((ushort)luaFiles.Count); writer.Write((ushort)scene.exporters.Length); writer.Write((ushort)0); // navmeshCount (legacy) @@ -219,6 +225,15 @@ namespace SplashEdit.RuntimeCode long cutsceneTableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); // cutsceneTableOffset placeholder + // UI canvas header (version 13, 8 bytes) + int uiCanvasCount = scene.canvases?.Length ?? 0; + int uiFontCount = scene.fonts?.Length ?? 0; + writer.Write((ushort)uiCanvasCount); + writer.Write((byte)uiFontCount); // was uiReserved low byte + writer.Write((byte)0); // was uiReserved high byte + long uiTableOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // uiTableOffset placeholder + // ────────────────────────────────────────────────────── // Lua file metadata // ────────────────────────────────────────────────────── @@ -617,6 +632,242 @@ namespace SplashEdit.RuntimeCode } } + // ────────────────────────────────────────────────────── + // UI canvas + font data (version 13) + // Font descriptors: 16 bytes each (before canvas data) + // Font pixel data: raw 4bpp (after font descriptors) + // Canvas descriptor table: 12 bytes per canvas + // Element records: 48 bytes each + // Name and text strings follow with offset backfill + // ────────────────────────────────────────────────────── + if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0) + { + AlignToFourBytes(writer); + long uiTableStart = writer.BaseStream.Position; + + // ── Font descriptors (16 bytes each) ── + // Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2) + // dataOffset(4) dataSize(4) + List fontDataOffsetPositions = new List(); + if (scene.fonts != null) + { + foreach (var font in scene.fonts) + { + writer.Write(font.GlyphWidth); // [0] + writer.Write(font.GlyphHeight); // [1] + writer.Write(font.VramX); // [2-3] + writer.Write(font.VramY); // [4-5] + writer.Write(font.TextureHeight); // [6-7] + fontDataOffsetPositions.Add(writer.BaseStream.Position); + writer.Write((uint)0); // [8-11] dataOffset placeholder + writer.Write((uint)(font.PixelData?.Length ?? 0)); // [12-15] dataSize + } + } + + // ── Font pixel data (raw 4bpp) ── + if (scene.fonts != null) + { + for (int fi = 0; fi < scene.fonts.Length; fi++) + { + var font = scene.fonts[fi]; + if (font.PixelData == null || font.PixelData.Length == 0) continue; + + AlignToFourBytes(writer); + long dataPos = writer.BaseStream.Position; + writer.Write(font.PixelData); + + // Backfill data offset + long curPos = writer.BaseStream.Position; + writer.Seek((int)fontDataOffsetPositions[fi], SeekOrigin.Begin); + writer.Write((uint)dataPos); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + if (scene.fonts.Length > 0) + { + int totalFontBytes = 0; + foreach (var f in scene.fonts) totalFontBytes += f.PixelData?.Length ?? 0; + log?.Invoke($"{scene.fonts.Length} custom font(s) written ({totalFontBytes} bytes 4bpp data).", LogType.Log); + } + } + + // ── Canvas descriptor table (12 bytes each) ── + // Layout per descriptor: + // uint32 dataOffset — offset to this canvas's element array + // uint8 nameLen + // uint8 sortOrder + // uint8 elementCount + // uint8 flags — bit 0 = startVisible + // uint32 nameOffset — offset to null-terminated name string + List canvasDataOffsetPos = new List(); + List canvasNameOffsetPos = new List(); + for (int ci = 0; ci < uiCanvasCount; ci++) + { + var cv = scene.canvases[ci]; + string cvName = cv.Name ?? ""; + if (cvName.Length > 24) cvName = cvName.Substring(0, 24); + + canvasDataOffsetPos.Add(writer.BaseStream.Position); + writer.Write((uint)0); // dataOffset placeholder + writer.Write((byte)cvName.Length); // nameLen + writer.Write((byte)cv.SortOrder); // sortOrder + writer.Write((byte)(cv.Elements?.Length ?? 0)); // elementCount + writer.Write((byte)(cv.StartVisible ? 0x01 : 0x00)); // flags + canvasNameOffsetPos.Add(writer.BaseStream.Position); + writer.Write((uint)0); // nameOffset placeholder + } + + // Phase 2: Write element records (56 bytes each) per canvas + for (int ci = 0; ci < uiCanvasCount; ci++) + { + var cv = scene.canvases[ci]; + if (cv.Elements == null || cv.Elements.Length == 0) continue; + + AlignToFourBytes(writer); + long elemStart = writer.BaseStream.Position; + + // Track text offset positions for backfill + List textOffsetPositions = new List(); + List textContents = new List(); + + foreach (var el in cv.Elements) + { + // Identity (8 bytes) + writer.Write((byte)el.Type); // type + byte eFlags = (byte)(el.StartVisible ? 0x01 : 0x00); + writer.Write(eFlags); // flags + string eName = el.Name ?? ""; + if (eName.Length > 24) eName = eName.Substring(0, 24); + writer.Write((byte)eName.Length); // nameLen + writer.Write((byte)0); // pad0 + // nameOffset placeholder (backfilled later) + long elemNameOffPos = writer.BaseStream.Position; + writer.Write((uint)0); // nameOffset + + // Layout (8 bytes) + writer.Write(el.X); + writer.Write(el.Y); + writer.Write(el.W); + writer.Write(el.H); + + // Anchors (4 bytes) + writer.Write(el.AnchorMinX); + writer.Write(el.AnchorMinY); + writer.Write(el.AnchorMaxX); + writer.Write(el.AnchorMaxY); + + // Primary color (4 bytes) + writer.Write(el.ColorR); + writer.Write(el.ColorG); + writer.Write(el.ColorB); + writer.Write((byte)0); // pad1 + + // Type-specific data (16 bytes) + switch (el.Type) + { + case PSXUIElementType.Image: + writer.Write(el.TexpageX); // [0] + writer.Write(el.TexpageY); // [1] + writer.Write(el.ClutX); // [2-3] + writer.Write(el.ClutY); // [4-5] + writer.Write(el.U0); // [6] + writer.Write(el.V0); // [7] + writer.Write(el.U1); // [8] + writer.Write(el.V1); // [9] + writer.Write(el.BitDepthIndex); // [10] + writer.Write(new byte[5]); // [11-15] padding + break; + case PSXUIElementType.Progress: + writer.Write(el.BgR); // [0] + writer.Write(el.BgG); // [1] + writer.Write(el.BgB); // [2] + writer.Write(el.ProgressValue); // [3] + writer.Write(new byte[12]); // [4-15] padding + break; + case PSXUIElementType.Text: + writer.Write(el.FontIndex); // [0] font index (0=system, 1+=custom) + writer.Write(new byte[15]); // [1-15] padding + break; + default: + writer.Write(new byte[16]); // zeroed + break; + } + + // Text content offset (8 bytes) + long textOff = writer.BaseStream.Position; + writer.Write((uint)0); // textOffset placeholder + writer.Write((uint)0); // pad2 + + // Remember for backfill + textOffsetPositions.Add(textOff); + textContents.Add(el.Type == PSXUIElementType.Text ? (el.DefaultText ?? "") : null); + + // Also remember element name for backfill + // We need to write it after all elements + textOffsetPositions.Add(elemNameOffPos); + textContents.Add("__NAME__" + eName); + } + + // Backfill canvas data offset + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)canvasDataOffsetPos[ci], SeekOrigin.Begin); + writer.Write((uint)elemStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + // Write strings and backfill offsets + for (int si = 0; si < textOffsetPositions.Count; si++) + { + string s = textContents[si]; + if (s == null) continue; + + bool isName = s.StartsWith("__NAME__"); + string content = isName ? s.Substring(8) : s; + if (string.IsNullOrEmpty(content) && !isName) continue; + + long strPos = writer.BaseStream.Position; + byte[] strBytes = Encoding.UTF8.GetBytes(content); + writer.Write(strBytes); + writer.Write((byte)0); // null terminator + + long curPos = writer.BaseStream.Position; + writer.Seek((int)textOffsetPositions[si], SeekOrigin.Begin); + writer.Write((uint)strPos); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + } + + // Write canvas name strings and backfill name offsets + for (int ci = 0; ci < uiCanvasCount; ci++) + { + string cvName = scene.canvases[ci].Name ?? ""; + if (cvName.Length > 24) cvName = cvName.Substring(0, 24); + + long namePos = writer.BaseStream.Position; + byte[] nameBytes = Encoding.UTF8.GetBytes(cvName); + writer.Write(nameBytes); + writer.Write((byte)0); // null terminator + + long curPos = writer.BaseStream.Position; + writer.Seek((int)canvasNameOffsetPos[ci], SeekOrigin.Begin); + writer.Write((uint)namePos); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + // Backfill UI table offset in header + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)uiTableOffsetPos, SeekOrigin.Begin); + writer.Write((uint)uiTableStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + int totalElements = 0; + foreach (var cv in scene.canvases) totalElements += cv.Elements?.Length ?? 0; + log?.Invoke($"{uiCanvasCount} UI canvases ({totalElements} elements) written.", LogType.Log); + } + // Backfill offsets BackfillOffsets(writer, luaOffset, "lua", log); BackfillOffsets(writer, meshOffset, "mesh", log); diff --git a/Runtime/PSXTrackType.cs b/Runtime/PSXTrackType.cs index ea986a5..87e90ab 100644 --- a/Runtime/PSXTrackType.cs +++ b/Runtime/PSXTrackType.cs @@ -10,5 +10,11 @@ namespace SplashEdit.RuntimeCode ObjectPosition = 2, ObjectRotationY = 3, ObjectActive = 4, + // UI track types (v13+) + UICanvasVisible = 5, + UIElementVisible = 6, + UIProgress = 7, + UIPosition = 8, + UIColor = 9, } } diff --git a/Runtime/PSXUIBox.cs b/Runtime/PSXUIBox.cs new file mode 100644 index 0000000..eb8c74a --- /dev/null +++ b/Runtime/PSXUIBox.cs @@ -0,0 +1,33 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A solid-color rectangle UI element for PSX export. + /// Rendered as a FastFill primitive on PS1 hardware. + /// Attach to a child of a PSXCanvas GameObject. + /// + [RequireComponent(typeof(RectTransform))] + [DisallowMultipleComponent] + [AddComponentMenu("PSX/UI/PSX UI Box")] + public class PSXUIBox : MonoBehaviour + { + [Tooltip("Name used to reference this element from Lua (max 24 chars).")] + [SerializeField] private string elementName = "box"; + + [Tooltip("Fill color for the box.")] + [SerializeField] private Color boxColor = Color.black; + + [Tooltip("Whether this element is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + /// Element name for Lua access. + public string ElementName => elementName; + + /// Box fill color (RGB, alpha ignored). + public Color BoxColor => boxColor; + + /// Initial visibility flag. + public bool StartVisible => startVisible; + } +} diff --git a/Runtime/PSXUIBox.cs.meta b/Runtime/PSXUIBox.cs.meta new file mode 100644 index 0000000..a884e17 --- /dev/null +++ b/Runtime/PSXUIBox.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d0070761745bb147bb74f85ca7960cd \ No newline at end of file diff --git a/Runtime/PSXUIElementType.cs b/Runtime/PSXUIElementType.cs new file mode 100644 index 0000000..155d296 --- /dev/null +++ b/Runtime/PSXUIElementType.cs @@ -0,0 +1,14 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// UI element types matching the C++ UIElementType enum. + /// Values must stay in sync with uisystem.hh. + /// + public enum PSXUIElementType : byte + { + Image = 0, + Box = 1, + Text = 2, + Progress = 3 + } +} diff --git a/Runtime/PSXUIElementType.cs.meta b/Runtime/PSXUIElementType.cs.meta new file mode 100644 index 0000000..4d380fa --- /dev/null +++ b/Runtime/PSXUIElementType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f27e7fda137b1d43b493af1fa727370 \ No newline at end of file diff --git a/Runtime/PSXUIExporter.cs b/Runtime/PSXUIExporter.cs new file mode 100644 index 0000000..0d0d83e --- /dev/null +++ b/Runtime/PSXUIExporter.cs @@ -0,0 +1,346 @@ +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 + // Font textures go at x=960 (same column as system font), stacking upward from y=464 + // System font: (960, 464)-(1023, 511) → 64 wide, 48 tall + List fontDataList = new List(); + ushort fontVramY = 0; // start from top of VRAM at x=960 + foreach (PSXFontAsset fa in uniqueFonts) + { + byte[] pixelData = fa.ConvertTo4BPP(); + if (pixelData == null) continue; + + ushort texH = (ushort)fa.TextureHeight; + fontDataList.Add(new PSXFontData + { + Source = fa, + GlyphWidth = (byte)fa.GlyphWidth, + GlyphHeight = (byte)fa.GlyphHeight, + VramX = 960, // same column as system font (64 VRAM hwords for 256px 4bpp) + VramY = fontVramY, + TextureHeight = texH, + PixelData = pixelData + }); + fontVramY += texH; + } + 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; + 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 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, + }); + } + } + } +} diff --git a/Runtime/PSXUIExporter.cs.meta b/Runtime/PSXUIExporter.cs.meta new file mode 100644 index 0000000..76b1e69 --- /dev/null +++ b/Runtime/PSXUIExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 394bbca774462bf44b775ea916961394 \ No newline at end of file diff --git a/Runtime/PSXUIImage.cs b/Runtime/PSXUIImage.cs new file mode 100644 index 0000000..ecf976d --- /dev/null +++ b/Runtime/PSXUIImage.cs @@ -0,0 +1,51 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A textured UI image element for PSX export. + /// Attach to a child of a PSXCanvas GameObject. + /// The RectTransform determines position and size in PS1 screen space. + /// + [RequireComponent(typeof(RectTransform))] + [DisallowMultipleComponent] + [AddComponentMenu("PSX/UI/PSX UI Image")] + public class PSXUIImage : MonoBehaviour + { + [Tooltip("Name used to reference this element from Lua (max 24 chars).")] + [SerializeField] private string elementName = "image"; + + [Tooltip("Source texture for this UI image. Will be quantized and packed into VRAM.")] + [SerializeField] private Texture2D sourceTexture; + + [Tooltip("Bit depth for VRAM storage.")] + [SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT; + + [Tooltip("Tint color applied to the image (white = no tint).")] + [SerializeField] private Color tintColor = Color.white; + + [Tooltip("Whether this element is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + /// Element name for Lua access. + public string ElementName => elementName; + + /// Source texture for quantization and VRAM packing. + public Texture2D SourceTexture => sourceTexture; + + /// Bit depth for the packed texture. + public PSXBPP BitDepth => bitDepth; + + /// Tint color (RGB, alpha ignored). + public Color TintColor => tintColor; + + /// Initial visibility flag. + public bool StartVisible => startVisible; + + /// + /// After VRAM packing, the exporter fills in these fields so the + /// binary writer can emit the correct tpage/clut/UV data. + /// + [System.NonSerialized] public PSXTexture2D PackedTexture; + } +} diff --git a/Runtime/PSXUIImage.cs.meta b/Runtime/PSXUIImage.cs.meta new file mode 100644 index 0000000..e1025c3 --- /dev/null +++ b/Runtime/PSXUIImage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 00606032435f7154cb260ebf56f477a9 \ No newline at end of file diff --git a/Runtime/PSXUIProgressBar.cs b/Runtime/PSXUIProgressBar.cs new file mode 100644 index 0000000..6413c04 --- /dev/null +++ b/Runtime/PSXUIProgressBar.cs @@ -0,0 +1,46 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A progress bar UI element for PSX export. + /// Rendered as two FastFill primitives (background + fill) on PS1 hardware. + /// Attach to a child of a PSXCanvas GameObject. + /// + [RequireComponent(typeof(RectTransform))] + [DisallowMultipleComponent] + [AddComponentMenu("PSX/UI/PSX UI Progress Bar")] + public class PSXUIProgressBar : MonoBehaviour + { + [Tooltip("Name used to reference this element from Lua (max 24 chars).")] + [SerializeField] private string elementName = "progress"; + + [Tooltip("Background color (shown behind the fill).")] + [SerializeField] private Color backgroundColor = new Color(0.2f, 0.2f, 0.2f); + + [Tooltip("Fill color (the progressing portion).")] + [SerializeField] private Color fillColor = Color.green; + + [Tooltip("Initial progress value (0-100).")] + [Range(0, 100)] + [SerializeField] private int initialValue = 0; + + [Tooltip("Whether this element is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + /// Element name for Lua access. + public string ElementName => elementName; + + /// Background color (RGB). + public Color BackgroundColor => backgroundColor; + + /// Fill color (RGB). + public Color FillColor => fillColor; + + /// Initial progress value 0-100. + public int InitialValue => Mathf.Clamp(initialValue, 0, 100); + + /// Initial visibility flag. + public bool StartVisible => startVisible; + } +} diff --git a/Runtime/PSXUIProgressBar.cs.meta b/Runtime/PSXUIProgressBar.cs.meta new file mode 100644 index 0000000..6888b83 --- /dev/null +++ b/Runtime/PSXUIProgressBar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a3c1e93bdff6a714ca5b47989b5b2401 \ No newline at end of file diff --git a/Runtime/PSXUIText.cs b/Runtime/PSXUIText.cs new file mode 100644 index 0000000..df85359 --- /dev/null +++ b/Runtime/PSXUIText.cs @@ -0,0 +1,60 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A text UI element for PSX export. + /// Rendered via psyqo::Font::chainprintf on PS1 hardware. + /// Attach to a child of a PSXCanvas GameObject. + /// + [RequireComponent(typeof(RectTransform))] + [DisallowMultipleComponent] + [AddComponentMenu("PSX/UI/PSX UI Text")] + public class PSXUIText : MonoBehaviour + { + [Tooltip("Name used to reference this element from Lua (max 24 chars).")] + [SerializeField] private string elementName = "text"; + + [Tooltip("Default text content (max 63 chars). Can be changed at runtime via Lua UI.SetText().")] + [SerializeField] private string defaultText = ""; + + [Tooltip("Text color.")] + [SerializeField] private Color textColor = Color.white; + + [Tooltip("Whether this element is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + [Tooltip("Custom font override. If null, uses the canvas default font (or built-in system font).")] + [SerializeField] private PSXFontAsset fontOverride; + + /// Element name for Lua access. + public string ElementName => elementName; + + /// Default text content (truncated to 63 chars on export). + public string DefaultText => defaultText; + + /// Text color (RGB, alpha ignored). + public Color TextColor => textColor; + + /// Initial visibility flag. + public bool StartVisible => startVisible; + + /// + /// Custom font override. If null, inherits from parent PSXCanvas.DefaultFont. + /// If that is also null, uses the built-in system font. + /// + public PSXFontAsset FontOverride => fontOverride; + + /// + /// Resolve the effective font for this text element. + /// Checks: fontOverride → parent PSXCanvas.DefaultFont → null (system font). + /// + public PSXFontAsset GetEffectiveFont() + { + if (fontOverride != null) return fontOverride; + PSXCanvas canvas = GetComponentInParent(); + if (canvas != null && canvas.DefaultFont != null) return canvas.DefaultFont; + return null; // system font + } + } +} diff --git a/Runtime/PSXUIText.cs.meta b/Runtime/PSXUIText.cs.meta new file mode 100644 index 0000000..86a7753 --- /dev/null +++ b/Runtime/PSXUIText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca4c021b4490c8f4fb9de4e14055b9e3 \ No newline at end of file diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs index 31c61d1..8598193 100644 --- a/Runtime/TexturePacker.cs +++ b/Runtime/TexturePacker.cs @@ -64,8 +64,9 @@ namespace SplashEdit.RuntimeCode /// Returns the processed objects and the final VRAM pixel array. /// /// Array of PSXObjectExporter objects to process. + /// Optional standalone textures (e.g. UI images) to include in VRAM packing. /// Tuple containing processed objects, texture atlases, and the VRAM pixel array. - public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects) + public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects, List additionalTextures = null) { // Gather all textures from all exporters. List allTextures = new List(); @@ -74,6 +75,10 @@ namespace SplashEdit.RuntimeCode allTextures.AddRange(obj.Textures); } + // Include additional standalone textures (e.g. UI images) + if (additionalTextures != null) + allTextures.AddRange(additionalTextures); + // List to track unique textures and their indices List uniqueTextures = new List(); Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>();