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

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++)
{
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]);
}
/// <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.")]
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<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)
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<PSXInteractable>(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();
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();
if (player != null)
@@ -185,7 +207,7 @@ namespace SplashEdit.RuntimeCode
#endif
}
void PackTextures()
void PackTextures(List<PSXTexture2D> 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) =>

View File

@@ -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<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
BackfillOffsets(writer, luaOffset, "lua", log);
BackfillOffsets(writer, meshOffset, "mesh", log);

View File

@@ -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,
}
}

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.
/// </summary>
/// <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>
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.
List<PSXTexture2D> allTextures = new List<PSXTexture2D>();
@@ -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<PSXTexture2D> uniqueTextures = new List<PSXTexture2D>();
Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>();