Broken UI system

This commit is contained in:
Jan Racek
2026-03-25 12:25:48 +01:00
parent bb8e0804f5
commit 8914ba35cc
28 changed files with 2094 additions and 25 deletions

View File

@@ -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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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<RectTransform>();
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);
}
}
}
/// <summary>
/// Custom inspector for PSXCanvas component.
/// Shows canvas name, visibility, sort order, font, and a summary of child elements.
/// </summary>
[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<PSXUIImage>(true).Length;
int boxCount = canvas.GetComponentsInChildren<PSXUIBox>(true).Length;
int textCount = canvas.GetComponentsInChildren<PSXUIText>(true).Length;
int progressCount = canvas.GetComponentsInChildren<PSXUIProgressBar>(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();
}
}
/// <summary>
/// Custom inspector for PSXUIImage component.
/// </summary>
[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();
}
}
/// <summary>
/// Custom inspector for PSXUIBox component.
/// </summary>
[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();
}
}
/// <summary>
/// Custom inspector for PSXUIText component.
/// </summary>
[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();
}
}
/// <summary>
/// Custom inspector for PSXUIProgressBar component.
/// </summary>
[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();
}
}
/// <summary>
/// Custom inspector for PSXFontAsset ScriptableObject.
/// Shows font metrics, auto-conversion from TTF/OTF, and a preview of the glyph layout.
/// </summary>
[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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 385b3916e29dc0e48b2866851d1fc1a9

View File

@@ -87,6 +87,8 @@ namespace SplashEdit.RuntimeCode
// Collect scene references for validation // Collect scene references for validation
var exporterNames = new HashSet<string>(); var exporterNames = new HashSet<string>();
var audioNames = new HashSet<string>(); var audioNames = new HashSet<string>();
var canvasNames = new HashSet<string>();
var elementNames = new Dictionary<string, HashSet<string>>(); // canvas → element names
var exporters = Object.FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None); var exporters = Object.FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
foreach (var e in exporters) foreach (var e in exporters)
exporterNames.Add(e.gameObject.name); exporterNames.Add(e.gameObject.name);
@@ -94,6 +96,26 @@ namespace SplashEdit.RuntimeCode
foreach (var a in audioSources) foreach (var a in audioSources)
if (!string.IsNullOrEmpty(a.ClipName)) if (!string.IsNullOrEmpty(a.ClipName))
audioNames.Add(a.ClipName); audioNames.Add(a.ClipName);
var canvases = Object.FindObjectsByType<PSXCanvas>(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<string>();
// Gather all UI element names under this canvas
foreach (var box in c.GetComponentsInChildren<PSXUIBox>())
if (!string.IsNullOrEmpty(box.ElementName)) elementNames[cName].Add(box.ElementName);
foreach (var txt in c.GetComponentsInChildren<PSXUIText>())
if (!string.IsNullOrEmpty(txt.ElementName)) elementNames[cName].Add(txt.ElementName);
foreach (var bar in c.GetComponentsInChildren<PSXUIProgressBar>())
if (!string.IsNullOrEmpty(bar.ElementName)) elementNames[cName].Add(bar.ElementName);
foreach (var img in c.GetComponentsInChildren<PSXUIImage>())
if (!string.IsNullOrEmpty(img.ElementName)) elementNames[cName].Add(img.ElementName);
}
}
// ── Tracks ── // ── Tracks ──
EditorGUILayout.Space(8); EditorGUILayout.Space(8);
@@ -114,13 +136,39 @@ namespace SplashEdit.RuntimeCode
EditorGUILayout.EndHorizontal(); EditorGUILayout.EndHorizontal();
bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation;
EditorGUI.BeginDisabledGroup(isCameraTrack); bool isUITrack = track.IsUITrack;
track.ObjectName = EditorGUILayout.TextField("Object Name", isCameraTrack ? "(camera)" : track.ObjectName); bool isUIElementTrack = track.IsUIElementTrack;
EditorGUI.EndDisabledGroup();
// Validation if (isCameraTrack)
if (!isCameraTrack && !string.IsNullOrEmpty(track.ObjectName) && !exporterNames.Contains(track.ObjectName)) {
EditorGUILayout.HelpBox($"No PSXObjectExporter found for '{track.ObjectName}' in scene.", MessageType.Error); 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 ── // ── Keyframes ──
if (track.Keyframes == null) track.Keyframes = new List<PSXKeyframe>(); if (track.Keyframes == null) track.Keyframes = new List<PSXKeyframe>();
@@ -152,7 +200,7 @@ namespace SplashEdit.RuntimeCode
else Debug.LogWarning("No active Scene View."); 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))) if (GUILayout.Button("From Sel.", GUILayout.Width(70)))
{ {
@@ -173,13 +221,46 @@ namespace SplashEdit.RuntimeCode
switch (track.TrackType) switch (track.TrackType)
{ {
case PSXTrackType.ObjectActive: 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); kf.Value = new Vector3(active ? 1f : 0f, 0, 0);
break; break;
}
case PSXTrackType.ObjectRotationY: case PSXTrackType.ObjectRotationY:
{
float yRot = EditorGUILayout.FloatField("Y\u00b0", kf.Value.y); float yRot = EditorGUILayout.FloatField("Y\u00b0", kf.Value.y);
kf.Value = new Vector3(0, yRot, 0); kf.Value = new Vector3(0, yRot, 0);
break; 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: default:
kf.Value = EditorGUILayout.Vector3Field("Value", kf.Value); kf.Value = EditorGUILayout.Vector3Field("Value", kf.Value);
break; break;
@@ -216,7 +297,7 @@ namespace SplashEdit.RuntimeCode
track.Keyframes.Add(new PSXKeyframe { Frame = frame, Value = val }); 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))) if (GUILayout.Button("+ from Selected", GUILayout.Width(120)))
{ {
@@ -511,6 +592,15 @@ namespace SplashEdit.RuntimeCode
if (_savedObjectActive.ContainsKey(track.ObjectName ?? "")) if (_savedObjectActive.ContainsKey(track.ObjectName ?? ""))
initialVal = new Vector3(_savedObjectActive[track.ObjectName] ? 1f : 0f, 0, 0); initialVal = new Vector3(_savedObjectActive[track.ObjectName] ? 1f : 0f, 0, 0);
break; 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); Vector3 val = EvaluateTrack(track, frame, initialVal);
@@ -541,6 +631,13 @@ namespace SplashEdit.RuntimeCode
if (go != null) go.SetActive(val.x > 0.5f); if (go != null) go.SetActive(val.x > 0.5f);
break; 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) if (track.Keyframes == null || track.Keyframes.Count == 0)
return Vector3.zero; return Vector3.zero;
// ObjectActive always uses step interpolation regardless of InterpMode // Step interpolation tracks: ObjectActive, UICanvasVisible, UIElementVisible
if (track.TrackType == PSXTrackType.ObjectActive) 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) if (track.Keyframes.Count > 0 && track.Keyframes[0].Frame > 0 && frame < track.Keyframes[0].Frame)
return initialValue; return initialValue;
return EvaluateStep(track.Keyframes, frame); return EvaluateStep(track.Keyframes, frame);

View File

@@ -26,6 +26,7 @@ namespace SplashEdit.EditorCode
private Color bufferColor2 = new Color(0, 1, 0, 0.5f); private Color bufferColor2 = new Color(0, 1, 0, 0.5f);
private Color prohibitedColor = new Color(1, 0, 0, 0.3f); private Color prohibitedColor = new Color(1, 0, 0, 0.3f);
private PSXData _psxData; private PSXData _psxData;
private PSXFontData[] _cachedFonts;
private static readonly Vector2[] resolutions = private static readonly Vector2[] resolutions =
{ {
@@ -144,6 +145,62 @@ namespace SplashEdit.EditorCode
vramImage.SetPixel(x, VramHeight - y - 1, packed.vramPixels[x, y].GetUnityColor()); 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(); vramImage.Apply();
// Prompt the user to select a file location and save the VRAM data. // Prompt the user to select a file location and save the VRAM data.
@@ -297,6 +354,24 @@ namespace SplashEdit.EditorCode
EditorGUI.DrawRect(areaRect, prohibitedColor); 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(); GUILayout.EndHorizontal();
} }
} }

121
Runtime/PSXCanvas.cs Normal file
View File

@@ -0,0 +1,121 @@
using UnityEngine;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>Canvas name for Lua access. Truncated to 24 chars on export.</summary>
public string CanvasName => canvasName;
/// <summary>Initial visibility flag written into the splashpack.</summary>
public bool StartVisible => startVisible;
/// <summary>Sort order in 0-255 range.</summary>
public byte SortOrder => (byte)Mathf.Clamp(sortOrder, 0, 255);
/// <summary>Default font for text elements. Null = system font.</summary>
public PSXFontAsset DefaultFont => defaultFont;
/// <summary>
/// PSX target resolution read from the PSXData asset. Falls back to 320x240.
/// Cached per domain reload for efficiency.
/// </summary>
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;
/// <summary>Invalidate the cached resolution (call when PSXData changes).</summary>
public static void InvalidateResolutionCache()
{
s_resolutionCached = false;
}
private static Vector2 LoadResolutionFromProject()
{
#if UNITY_EDITOR
var data = AssetDatabase.LoadAssetAtPath<PSXData>("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
/// <summary>
/// Force the Canvas + CanvasScaler to match the PSX resolution from project settings.
/// </summary>
public void ConfigureCanvas()
{
if (this == null) return;
Vector2 res = PSXResolution;
Canvas canvas = GetComponent<Canvas>();
if (canvas != null)
{
canvas.renderMode = RenderMode.WorldSpace;
}
RectTransform rt = GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(res.x, res.y);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dc481397cd94e03409e462478df09d58

85
Runtime/PSXCanvasData.cs Normal file
View File

@@ -0,0 +1,85 @@
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Pre-computed data for one UI canvas and its elements,
/// ready for binary serialization by <see cref="PSXSceneWriter"/>.
/// Populated by <see cref="PSXUIExporter"/> during the export pipeline.
/// </summary>
public struct PSXCanvasData
{
/// <summary>Canvas name (max 24 chars, truncated on export).</summary>
public string Name;
/// <summary>Initial visibility flag.</summary>
public bool StartVisible;
/// <summary>Sort order (0 = back, 255 = front).</summary>
public byte SortOrder;
/// <summary>Exported elements belonging to this canvas.</summary>
public PSXUIElementData[] Elements;
}
/// <summary>
/// Pre-computed data for one UI element, ready for binary serialization.
/// Matches the 48-byte on-disk element record parsed by uisystem.cpp.
/// </summary>
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
}
/// <summary>
/// Export data for a custom font to be embedded in the splashpack.
/// </summary>
public struct PSXFontData
{
/// <summary>Source font asset (for identification/dedup).</summary>
public PSXFontAsset Source;
/// <summary>Glyph cell width in pixels.</summary>
public byte GlyphWidth;
/// <summary>Glyph cell height in pixels.</summary>
public byte GlyphHeight;
/// <summary>VRAM X position for upload (16-bit pixel units).</summary>
public ushort VramX;
/// <summary>VRAM Y position for upload (16-bit pixel units).</summary>
public ushort VramY;
/// <summary>Texture height in pixels (width is always 256 in 4bpp = 64 VRAM hwords).</summary>
public ushort TextureHeight;
/// <summary>Packed 4bpp pixel data ready for VRAM upload.</summary>
public byte[] PixelData;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f4142a9858eccd04eac0b826af8f3621

View File

@@ -151,9 +151,7 @@ namespace SplashEdit.RuntimeCode
for (int ti = 0; ti < trackCount; ti++) for (int ti = 0; ti < trackCount; ti++)
{ {
PSXCutsceneTrack track = clip.Tracks[ti]; PSXCutsceneTrack track = clip.Tracks[ti];
bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; string objName = GetTrackTargetName(track);
string objName = isCameraTrack ? "" : (track.ObjectName ?? "");
if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN);
int kfCount = Mathf.Min(track.Keyframes?.Count ?? 0, MAX_KEYFRAMES); int kfCount = Mathf.Min(track.Keyframes?.Count ?? 0, MAX_KEYFRAMES);
@@ -228,6 +226,39 @@ namespace SplashEdit.RuntimeCode
writer.Write((short)0); writer.Write((short)0);
break; 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++) for (int ti = 0; ti < trackCount; ti++)
{ {
PSXCutsceneTrack track = clip.Tracks[ti]; PSXCutsceneTrack track = clip.Tracks[ti];
bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; string objName = GetTrackTargetName(track);
string objName = isCameraTrack ? "" : (track.ObjectName ?? "");
if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN);
if (objName.Length > 0) if (objName.Length > 0)
{ {
@@ -329,5 +358,29 @@ namespace SplashEdit.RuntimeCode
if (padding > 0) if (padding > 0)
writer.Write(new byte[padding]); writer.Write(new byte[padding]);
} }
/// <summary>
/// Get the target name string for a track.
/// Camera tracks: empty. Object tracks: ObjectName.
/// UICanvasVisible: UICanvasName.
/// UI element tracks: "UICanvasName/UIElementName".
/// </summary>
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;
}
} }
} }

View File

@@ -13,10 +13,22 @@ namespace SplashEdit.RuntimeCode
[Tooltip("What property this track drives.")] [Tooltip("What property this track drives.")]
public PSXTrackType TrackType; 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 = ""; 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.")] [Tooltip("Keyframes for this track. Sort by frame number.")]
public List<PSXKeyframe> Keyframes = new List<PSXKeyframe>(); public List<PSXKeyframe> Keyframes = new List<PSXKeyframe>();
/// <summary>Returns true if this track type targets a UI canvas or element.</summary>
public bool IsUITrack => TrackType >= PSXTrackType.UICanvasVisible && TrackType <= PSXTrackType.UIColor;
/// <summary>Returns true if this track type targets a UI element (not just a canvas).</summary>
public bool IsUIElementTrack => TrackType >= PSXTrackType.UIElementVisible && TrackType <= PSXTrackType.UIColor;
} }
} }

323
Runtime/PSXFontAsset.cs Normal file
View File

@@ -0,0 +1,323 @@
using UnityEngine;
using System;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>The Unity Font source (TTF/OTF). Null if using manual bitmap.</summary>
public Font SourceFont => sourceFont;
/// <summary>Font size for TrueType rasterization.</summary>
public int FontSize => fontSize;
/// <summary>Source font texture (256px wide, monochrome). May be auto-generated.</summary>
public Texture2D FontTexture => fontTexture;
/// <summary>Width of each glyph cell in pixels.</summary>
public int GlyphWidth => glyphWidth;
/// <summary>Height of each glyph cell in pixels.</summary>
public int GlyphHeight => glyphHeight;
/// <summary>Number of glyphs per row (always 256 / glyphWidth).</summary>
public int GlyphsPerRow => 256 / glyphWidth;
/// <summary>Number of rows needed for all 95 printable ASCII characters.</summary>
public int RowCount => Mathf.CeilToInt(95f / GlyphsPerRow);
/// <summary>Total height of the font texture in pixels.</summary>
public int TextureHeight => RowCount * glyphHeight;
#if UNITY_EDITOR
/// <summary>
/// Generate a 256px-wide monochrome bitmap from the assigned TrueType font.
/// Each printable ASCII character (0x200x7E) is rasterized into
/// a fixed-size glyph cell. The result is stored in fontTexture.
/// </summary>
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<Texture2D>(texPath);
EditorUtility.SetDirty(this);
AssetDatabase.SaveAssets();
Debug.Log($"PSXFontAsset: Generated bitmap {texW}x{texH} at {texPath}");
}
#endif
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b5bc0d8f6252841439e81a8406c37859

View File

@@ -55,6 +55,10 @@ namespace SplashEdit.RuntimeCode
// Phase 5: Room/portal system (interior scenes) // Phase 5: Room/portal system (interior scenes)
private PSXRoomBuilder _roomBuilder; private PSXRoomBuilder _roomBuilder;
// Phase 6: UI canvases
private PSXCanvasData[] _canvases;
private PSXFontData[] _fonts;
private PSXData _psxData; private PSXData _psxData;
private Vector2 selectedResolution; private Vector2 selectedResolution;
@@ -106,9 +110,27 @@ namespace SplashEdit.RuntimeCode
_interactables = FindObjectsByType<PSXInteractable>(FindObjectsSortMode.None); _interactables = FindObjectsByType<PSXInteractable>(FindObjectsSortMode.None);
_audioSources = FindObjectsByType<PSXAudioSource>(FindObjectsSortMode.None); _audioSources = FindObjectsByType<PSXAudioSource>(FindObjectsSortMode.None);
// Collect UI image textures for VRAM packing alongside 3D textures
PSXUIImage[] uiImages = FindObjectsByType<PSXUIImage>(FindObjectsSortMode.None);
List<PSXTexture2D> uiTextures = new List<PSXTexture2D>();
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(); 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<PSXPlayer>(FindObjectsSortMode.None).FirstOrDefault(); PSXPlayer player = FindObjectsByType<PSXPlayer>(FindObjectsSortMode.None).FirstOrDefault();
if (player != null) if (player != null)
@@ -185,7 +207,7 @@ namespace SplashEdit.RuntimeCode
#endif #endif
} }
void PackTextures() void PackTextures(List<PSXTexture2D> additionalTextures = null)
{ {
(Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout); (Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout);
@@ -196,7 +218,7 @@ namespace SplashEdit.RuntimeCode
} }
VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas); VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas);
var packed = tp.PackTexturesIntoVRAM(_exporters); var packed = tp.PackTexturesIntoVRAM(_exporters, additionalTextures);
_exporters = packed.processedObjects; _exporters = packed.processedObjects;
_atlases = packed.atlases; _atlases = packed.atlases;
@@ -260,6 +282,8 @@ namespace SplashEdit.RuntimeCode
fogDensity = FogDensity, fogDensity = FogDensity,
cutscenes = Cutscenes, cutscenes = Cutscenes,
audioSources = _audioSources, audioSources = _audioSources,
canvases = _canvases,
fonts = _fonts,
}; };
PSXSceneWriter.Write(path, in scene, (msg, type) => PSXSceneWriter.Write(path, in scene, (msg, type) =>

View File

@@ -33,6 +33,12 @@ namespace SplashEdit.RuntimeCode
public PSXCutsceneClip[] cutscenes; public PSXCutsceneClip[] cutscenes;
public PSXAudioSource[] audioSources; public PSXAudioSource[] audioSources;
// UI canvases (v13)
public PSXCanvasData[] canvases;
// Custom fonts (v13, embedded in UI block)
public PSXFontData[] fonts;
// Player // Player
public Vector3 playerPos; public Vector3 playerPos;
public Quaternion playerRot; public Quaternion playerRot;
@@ -119,7 +125,7 @@ namespace SplashEdit.RuntimeCode
// ────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────
writer.Write('S'); writer.Write('S');
writer.Write('P'); writer.Write('P');
writer.Write((ushort)12); // version writer.Write((ushort)13); // version
writer.Write((ushort)luaFiles.Count); writer.Write((ushort)luaFiles.Count);
writer.Write((ushort)scene.exporters.Length); writer.Write((ushort)scene.exporters.Length);
writer.Write((ushort)0); // navmeshCount (legacy) writer.Write((ushort)0); // navmeshCount (legacy)
@@ -219,6 +225,15 @@ namespace SplashEdit.RuntimeCode
long cutsceneTableOffsetPos = writer.BaseStream.Position; long cutsceneTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // cutsceneTableOffset placeholder 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 // 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<long> fontDataOffsetPositions = new List<long>();
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<long> canvasDataOffsetPos = new List<long>();
List<long> canvasNameOffsetPos = new List<long>();
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<long> textOffsetPositions = new List<long>();
List<string> textContents = new List<string>();
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 // Backfill offsets
BackfillOffsets(writer, luaOffset, "lua", log); BackfillOffsets(writer, luaOffset, "lua", log);
BackfillOffsets(writer, meshOffset, "mesh", log); BackfillOffsets(writer, meshOffset, "mesh", log);

View File

@@ -10,5 +10,11 @@ namespace SplashEdit.RuntimeCode
ObjectPosition = 2, ObjectPosition = 2,
ObjectRotationY = 3, ObjectRotationY = 3,
ObjectActive = 4, ObjectActive = 4,
// UI track types (v13+)
UICanvasVisible = 5,
UIElementVisible = 6,
UIProgress = 7,
UIPosition = 8,
UIColor = 9,
} }
} }

33
Runtime/PSXUIBox.cs Normal file
View File

@@ -0,0 +1,33 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>Element name for Lua access.</summary>
public string ElementName => elementName;
/// <summary>Box fill color (RGB, alpha ignored).</summary>
public Color BoxColor => boxColor;
/// <summary>Initial visibility flag.</summary>
public bool StartVisible => startVisible;
}
}

2
Runtime/PSXUIBox.cs.meta Normal file
View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6d0070761745bb147bb74f85ca7960cd

View File

@@ -0,0 +1,14 @@
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// UI element types matching the C++ UIElementType enum.
/// Values must stay in sync with uisystem.hh.
/// </summary>
public enum PSXUIElementType : byte
{
Image = 0,
Box = 1,
Text = 2,
Progress = 3
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f27e7fda137b1d43b493af1fa727370

346
Runtime/PSXUIExporter.cs Normal file
View File

@@ -0,0 +1,346 @@
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Collects all PSXCanvas hierarchies in the scene, bakes RectTransform
/// coordinates into PS1 pixel space, and produces <see cref="PSXCanvasData"/>
/// arrays ready for binary serialization.
/// </summary>
public static class PSXUIExporter
{
/// <summary>
/// Collect all PSXCanvas components and their child UI elements,
/// converting RectTransform coordinates to PS1 pixel space.
/// Also collects and deduplicates custom fonts.
/// </summary>
/// <param name="resolution">Target PS1 resolution (e.g. 320×240).</param>
/// <param name="fonts">Output: collected custom font data (max 3).</param>
/// <returns>Array of canvas data ready for binary writing.</returns>
public static PSXCanvasData[] CollectCanvases(Vector2 resolution, out PSXFontData[] fonts)
{
// Collect and deduplicate all custom fonts used by text elements
List<PSXFontAsset> uniqueFonts = new List<PSXFontAsset>();
#if UNITY_EDITOR
PSXCanvas[] canvases = Object.FindObjectsByType<PSXCanvas>(FindObjectsSortMode.None);
#else
PSXCanvas[] canvases = Object.FindObjectsOfType<PSXCanvas>();
#endif
if (canvases == null || canvases.Length == 0)
{
fonts = new PSXFontData[0];
return new PSXCanvasData[0];
}
// First pass: collect unique fonts
foreach (PSXCanvas canvas in canvases)
{
PSXUIText[] texts = canvas.GetComponentsInChildren<PSXUIText>(true);
foreach (PSXUIText txt in texts)
{
PSXFontAsset font = txt.GetEffectiveFont();
if (font != null && !uniqueFonts.Contains(font) && uniqueFonts.Count < 3)
uniqueFonts.Add(font);
}
}
// Build font data with VRAM positions
// 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<PSXFontData> fontDataList = new List<PSXFontData>();
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<PSXCanvasData> result = new List<PSXCanvasData>();
foreach (PSXCanvas canvas in canvases)
{
Canvas unityCanvas = canvas.GetComponent<Canvas>();
if (unityCanvas == null) continue;
RectTransform canvasRect = canvas.GetComponent<RectTransform>();
float canvasW = canvasRect.rect.width;
float canvasH = canvasRect.rect.height;
if (canvasW <= 0) canvasW = resolution.x;
if (canvasH <= 0) canvasH = resolution.y;
float scaleX = resolution.x / canvasW;
float scaleY = resolution.y / canvasH;
List<PSXUIElementData> elements = new List<PSXUIElementData>();
CollectImages(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
CollectBoxes(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
CollectTexts(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements, uniqueFonts);
CollectProgressBars(canvas.transform, canvasRect, scaleX, scaleY, resolution, elements);
string name = canvas.CanvasName ?? "canvas";
if (name.Length > 24) name = name.Substring(0, 24);
result.Add(new PSXCanvasData
{
Name = name,
StartVisible = canvas.StartVisible,
SortOrder = canvas.SortOrder,
Elements = elements.ToArray()
});
}
return result.ToArray();
}
// ─── Coordinate baking helpers ───
/// <summary>
/// Convert a RectTransform into PS1 pixel-space layout values.
/// Handles anchor-based positioning and Y inversion.
/// </summary>
private static void BakeLayout(
RectTransform rt, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
out short x, out short y, out short w, out short h,
out byte anchorMinX, out byte anchorMinY,
out byte anchorMaxX, out byte anchorMaxY)
{
// Anchor values in 8.8 fixed point (0-255 maps to 0.0-~1.0)
anchorMinX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMin.x * 255f), 0, 255);
anchorMinY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMax.y) * 255f), 0, 255); // Y invert
anchorMaxX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMax.x * 255f), 0, 255);
anchorMaxY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMin.y) * 255f), 0, 255); // Y invert
if (Mathf.Approximately(rt.anchorMin.x, rt.anchorMax.x) &&
Mathf.Approximately(rt.anchorMin.y, rt.anchorMax.y))
{
// Fixed-size element with single anchor point
// anchoredPosition is the offset from the anchor in canvas pixels
float px = rt.anchoredPosition.x * scaleX;
float py = -rt.anchoredPosition.y * scaleY; // Y invert
float pw = rt.rect.width * scaleX;
float ph = rt.rect.height * scaleY;
// Adjust for pivot (anchoredPosition is at the pivot point)
px -= rt.pivot.x * pw;
py -= (1f - rt.pivot.y) * ph; // pivot Y inverted
x = (short)Mathf.RoundToInt(px);
y = (short)Mathf.RoundToInt(py);
w = (short)Mathf.Max(1, Mathf.RoundToInt(pw));
h = (short)Mathf.Max(1, Mathf.RoundToInt(ph));
}
else
{
// Stretched element: offsets from anchored edges
// offsetMin = distance from anchorMin corner, offsetMax = distance from anchorMax corner
float leftOff = rt.offsetMin.x * scaleX;
float rightOff = rt.offsetMax.x * scaleX;
float topOff = -rt.offsetMax.y * scaleY; // Y invert
float bottomOff = -rt.offsetMin.y * scaleY; // Y invert
// For stretched elements, x/y store the offset from the anchor start,
// and w/h store the combined inset (negative = shrink)
x = (short)Mathf.RoundToInt(leftOff);
y = (short)Mathf.RoundToInt(topOff);
w = (short)Mathf.RoundToInt(rightOff - leftOff);
h = (short)Mathf.RoundToInt(bottomOff - topOff);
}
}
private static string TruncateName(string name, int maxLen = 24)
{
if (string.IsNullOrEmpty(name)) return "";
return name.Length > maxLen ? name.Substring(0, maxLen) : name;
}
// ─── Collectors ───
private static void CollectImages(
Transform root, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
List<PSXUIElementData> elements)
{
PSXUIImage[] images = root.GetComponentsInChildren<PSXUIImage>(true);
foreach (PSXUIImage img in images)
{
RectTransform rt = img.GetComponent<RectTransform>();
if (rt == null) continue;
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
out short x, out short y, out short w, out short h,
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
var data = new PSXUIElementData
{
Type = PSXUIElementType.Image,
StartVisible = img.StartVisible,
Name = TruncateName(img.ElementName),
X = x, Y = y, W = w, H = h,
AnchorMinX = amin_x, AnchorMinY = amin_y,
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.r * 255f), 0, 255),
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.g * 255f), 0, 255),
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(img.TintColor.b * 255f), 0, 255),
};
// Image texture data is filled in after VRAM packing by
// FillImageTextureData() — see PSXSceneExporter integration
if (img.PackedTexture != null)
{
PSXTexture2D tex = img.PackedTexture;
int expander = 16 / (int)tex.BitDepth;
data.TexpageX = tex.TexpageX;
data.TexpageY = tex.TexpageY;
data.ClutX = (ushort)tex.ClutPackingX;
data.ClutY = (ushort)tex.ClutPackingY;
data.U0 = (byte)(tex.PackingX * expander);
data.V0 = (byte)tex.PackingY;
data.U1 = (byte)(tex.PackingX * expander + tex.Width * expander / ((int)tex.BitDepth / (int)PSXBPP.TEX_4BIT));
data.V1 = (byte)(tex.PackingY + tex.Height);
data.BitDepthIndex = tex.BitDepth switch
{
PSXBPP.TEX_4BIT => 0,
PSXBPP.TEX_8BIT => 1,
PSXBPP.TEX_16BIT => 2,
_ => 2
};
}
elements.Add(data);
}
}
private static void CollectBoxes(
Transform root, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
List<PSXUIElementData> elements)
{
PSXUIBox[] boxes = root.GetComponentsInChildren<PSXUIBox>(true);
foreach (PSXUIBox box in boxes)
{
RectTransform rt = box.GetComponent<RectTransform>();
if (rt == null) continue;
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
out short x, out short y, out short w, out short h,
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
elements.Add(new PSXUIElementData
{
Type = PSXUIElementType.Box,
StartVisible = box.StartVisible,
Name = TruncateName(box.ElementName),
X = x, Y = y, W = w, H = h,
AnchorMinX = amin_x, AnchorMinY = amin_y,
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.r * 255f), 0, 255),
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.g * 255f), 0, 255),
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(box.BoxColor.b * 255f), 0, 255),
});
}
}
private static void CollectTexts(
Transform root, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
List<PSXUIElementData> elements,
List<PSXFontAsset> uniqueFonts = null)
{
PSXUIText[] texts = root.GetComponentsInChildren<PSXUIText>(true);
foreach (PSXUIText txt in texts)
{
RectTransform rt = txt.GetComponent<RectTransform>();
if (rt == null) continue;
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
out short x, out short y, out short w, out short h,
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
string defaultText = txt.DefaultText ?? "";
if (defaultText.Length > 63) defaultText = defaultText.Substring(0, 63);
// Resolve font index: 0 = system font, 1+ = custom font
byte fontIndex = 0;
PSXFontAsset effectiveFont = txt.GetEffectiveFont();
if (effectiveFont != null && uniqueFonts != null)
{
int idx = uniqueFonts.IndexOf(effectiveFont);
if (idx >= 0) fontIndex = (byte)(idx + 1); // 1-based for custom fonts
}
elements.Add(new PSXUIElementData
{
Type = PSXUIElementType.Text,
StartVisible = txt.StartVisible,
Name = TruncateName(txt.ElementName),
X = x, Y = y, W = w, H = h,
AnchorMinX = amin_x, AnchorMinY = amin_y,
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.r * 255f), 0, 255),
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.g * 255f), 0, 255),
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(txt.TextColor.b * 255f), 0, 255),
DefaultText = defaultText,
FontIndex = fontIndex,
});
}
}
private static void CollectProgressBars(
Transform root, RectTransform canvasRect,
float scaleX, float scaleY, Vector2 resolution,
List<PSXUIElementData> elements)
{
PSXUIProgressBar[] bars = root.GetComponentsInChildren<PSXUIProgressBar>(true);
foreach (PSXUIProgressBar bar in bars)
{
RectTransform rt = bar.GetComponent<RectTransform>();
if (rt == null) continue;
BakeLayout(rt, canvasRect, scaleX, scaleY, resolution,
out short x, out short y, out short w, out short h,
out byte amin_x, out byte amin_y, out byte amax_x, out byte amax_y);
elements.Add(new PSXUIElementData
{
Type = PSXUIElementType.Progress,
StartVisible = bar.StartVisible,
Name = TruncateName(bar.ElementName),
X = x, Y = y, W = w, H = h,
AnchorMinX = amin_x, AnchorMinY = amin_y,
AnchorMaxX = amax_x, AnchorMaxY = amax_y,
// Fill color goes into primary color (used for the fill bar)
ColorR = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.r * 255f), 0, 255),
ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.g * 255f), 0, 255),
ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.FillColor.b * 255f), 0, 255),
// Background color goes into progress-specific fields
BgR = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.r * 255f), 0, 255),
BgG = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.g * 255f), 0, 255),
BgB = (byte)Mathf.Clamp(Mathf.RoundToInt(bar.BackgroundColor.b * 255f), 0, 255),
ProgressValue = (byte)bar.InitialValue,
});
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 394bbca774462bf44b775ea916961394

51
Runtime/PSXUIImage.cs Normal file
View File

@@ -0,0 +1,51 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>Element name for Lua access.</summary>
public string ElementName => elementName;
/// <summary>Source texture for quantization and VRAM packing.</summary>
public Texture2D SourceTexture => sourceTexture;
/// <summary>Bit depth for the packed texture.</summary>
public PSXBPP BitDepth => bitDepth;
/// <summary>Tint color (RGB, alpha ignored).</summary>
public Color TintColor => tintColor;
/// <summary>Initial visibility flag.</summary>
public bool StartVisible => startVisible;
/// <summary>
/// After VRAM packing, the exporter fills in these fields so the
/// binary writer can emit the correct tpage/clut/UV data.
/// </summary>
[System.NonSerialized] public PSXTexture2D PackedTexture;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00606032435f7154cb260ebf56f477a9

View File

@@ -0,0 +1,46 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>Element name for Lua access.</summary>
public string ElementName => elementName;
/// <summary>Background color (RGB).</summary>
public Color BackgroundColor => backgroundColor;
/// <summary>Fill color (RGB).</summary>
public Color FillColor => fillColor;
/// <summary>Initial progress value 0-100.</summary>
public int InitialValue => Mathf.Clamp(initialValue, 0, 100);
/// <summary>Initial visibility flag.</summary>
public bool StartVisible => startVisible;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a3c1e93bdff6a714ca5b47989b5b2401

60
Runtime/PSXUIText.cs Normal file
View File

@@ -0,0 +1,60 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// A text UI element for PSX export.
/// Rendered via psyqo::Font::chainprintf on PS1 hardware.
/// Attach to a child of a PSXCanvas GameObject.
/// </summary>
[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;
/// <summary>Element name for Lua access.</summary>
public string ElementName => elementName;
/// <summary>Default text content (truncated to 63 chars on export).</summary>
public string DefaultText => defaultText;
/// <summary>Text color (RGB, alpha ignored).</summary>
public Color TextColor => textColor;
/// <summary>Initial visibility flag.</summary>
public bool StartVisible => startVisible;
/// <summary>
/// Custom font override. If null, inherits from parent PSXCanvas.DefaultFont.
/// If that is also null, uses the built-in system font.
/// </summary>
public PSXFontAsset FontOverride => fontOverride;
/// <summary>
/// Resolve the effective font for this text element.
/// Checks: fontOverride → parent PSXCanvas.DefaultFont → null (system font).
/// </summary>
public PSXFontAsset GetEffectiveFont()
{
if (fontOverride != null) return fontOverride;
PSXCanvas canvas = GetComponentInParent<PSXCanvas>();
if (canvas != null && canvas.DefaultFont != null) return canvas.DefaultFont;
return null; // system font
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ca4c021b4490c8f4fb9de4e14055b9e3

View File

@@ -64,8 +64,9 @@ namespace SplashEdit.RuntimeCode
/// Returns the processed objects and the final VRAM pixel array. /// Returns the processed objects and the final VRAM pixel array.
/// </summary> /// </summary>
/// <param name="objects">Array of PSXObjectExporter objects to process.</param> /// <param name="objects">Array of PSXObjectExporter objects to process.</param>
/// <param name="additionalTextures">Optional standalone textures (e.g. UI images) to include in VRAM packing.</param>
/// <returns>Tuple containing processed objects, texture atlases, and the VRAM pixel array.</returns> /// <returns>Tuple containing processed objects, texture atlases, and the VRAM pixel array.</returns>
public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects) public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects, List<PSXTexture2D> additionalTextures = null)
{ {
// Gather all textures from all exporters. // Gather all textures from all exporters.
List<PSXTexture2D> allTextures = new List<PSXTexture2D>(); List<PSXTexture2D> allTextures = new List<PSXTexture2D>();
@@ -74,6 +75,10 @@ namespace SplashEdit.RuntimeCode
allTextures.AddRange(obj.Textures); 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 to track unique textures and their indices
List<PSXTexture2D> uniqueTextures = new List<PSXTexture2D>(); List<PSXTexture2D> uniqueTextures = new List<PSXTexture2D>();
Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>(); Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>();