From a8904f1de3efc70d8bc7af35ad073edbf177d355 Mon Sep 17 00:00:00 2001 From: Bandwidth Date: Mon, 28 Sep 2026 20:19:10 +0200 Subject: [PATCH 1/7] Pushed all of networking in one go because I'm very dumb + Full sprite support to go with PSXSUS --- Editor/Core/MkpsxisoDownloader.cs | 2 +- Editor/Core/PCSXReduxDownloader.cs | 12 +- Editor/Core/PCdrvSerialHost.cs | 52 +- Editor/Core/PSXAudioConverter.cs | 116 ++++- Editor/Core/PSXConsoleWindow.cs | 34 +- Editor/Core/SceneMemoryReport.cs | 22 +- Editor/Core/SplashBuildPaths.cs | 2 +- Editor/Core/SplashControlPanel.cs | 180 +++---- Editor/Core/UniromUploader.cs | 52 +- Editor/Inspectors/PSXComponentEditors2.cs | 48 +- Editor/Inspectors/PSXUIEditors.cs | 77 ++- Editor/Inspectors/PSXUISpriteEditor.cs | 144 ++++++ Editor/Inspectors/PSXUISpriteEditor.cs.meta | 2 + Editor/PSXAgentEditor.cs | 276 ++++++++++ Editor/PSXAgentEditor.cs.meta | 2 + Editor/PSXMenuItems.cs | 8 +- Editor/PSXNavRegionEditor.cs | 21 +- Editor/PSXNavigationSettingsEditor.cs | 87 ++++ Editor/PSXNavigationSettingsEditor.cs.meta | 2 + Editor/PSXSceneExporterEditor.cs | 45 +- Editor/PSXScreenOverlay.cs | 123 +++++ Editor/PSXScreenOverlay.cs.meta | 2 + Editor/PSXScreenPreview.cs | 345 +++++++++++++ Editor/PSXScreenPreview.cs.meta | 2 + Editor/PSXSkinnedObjectExporterEditor.cs | 2 +- Editor/PSXSplashInstaller.cs | 34 +- Editor/PSXTilemapPreview.cs | 305 +++++++++++ Editor/PSXTilemapPreview.cs.meta | 2 + Editor/PSXTilemapRendererEditor.cs | 73 +++ Editor/PSXTilemapRendererEditor.cs.meta | 2 + Editor/PSXUIMenuItems.cs | 183 +++++++ Editor/PSXUIMenuItems.cs.meta | 2 + Editor/QuantizedPreviewWindow.cs | 6 +- Editor/TilePainterWindow.cs | 536 ++++++++++++++++++++ Editor/TilePainterWindow.cs.meta | 2 + Editor/Timeline/PSXTimelineDrawer.cs | 12 +- Editor/Timeline/PSXTimelineState.cs | 20 +- Editor/Timeline/PSXTimelineWindow.cs | 8 +- Editor/ToolchainChecker.cs | 2 +- Editor/ToolchainInstaller.cs | 10 +- Runtime/BVH.cs | 2 +- Runtime/ImageProcessing.cs | 97 +++- Runtime/PSXAgent.cs | 222 ++++++++ Runtime/PSXAgent.cs.meta | 2 + Runtime/PSXAnimationExporter.cs | 2 +- Runtime/PSXAudioClip.cs | 12 + Runtime/PSXCanvasData.cs | 11 +- Runtime/PSXCollisionExporter.cs | 2 +- Runtime/PSXCutsceneClip.cs | 2 +- Runtime/PSXCutsceneExporter.cs | 28 +- Runtime/PSXFontAsset.cs | 12 +- Runtime/PSXLoaderPackWriter.cs | 42 +- Runtime/PSXNavRegionBuilder.cs | 20 +- Runtime/PSXNavigationSettings.cs | 101 ++++ Runtime/PSXNavigationSettings.cs.meta | 2 + Runtime/PSXPlayer.cs | 12 +- Runtime/PSXRoom.cs | 28 +- Runtime/PSXSceneExporter.cs | 90 +++- Runtime/PSXSceneWriter.cs | 432 +++++++++++++--- Runtime/PSXSkinnedMeshExporter.cs | 26 +- Runtime/PSXSprite.cs | 28 + Runtime/PSXSprite.cs.meta | 2 + Runtime/PSXSpriteExporter.cs | 186 +++++++ Runtime/PSXSpriteExporter.cs.meta | 2 + Runtime/PSXSpriteSheet.cs | 150 ++++++ Runtime/PSXSpriteSheet.cs.meta | 2 + Runtime/PSXTexture2D.cs | 27 +- Runtime/PSXTilemap.cs | 194 +++++++ Runtime/PSXTilemap.cs.meta | 2 + Runtime/PSXTilemapExporter.cs | 155 ++++++ Runtime/PSXTilemapExporter.cs.meta | 2 + Runtime/PSXTilemapRenderer.cs | 23 + Runtime/PSXTilemapRenderer.cs.meta | 2 + Runtime/PSXUIBudget.cs | 35 ++ Runtime/PSXUIBudget.cs.meta | 2 + Runtime/PSXUIExporter.cs | 187 +++++-- Runtime/PSXUILayout.cs | 128 +++++ Runtime/PSXUILayout.cs.meta | 2 + Runtime/PSXUISprite.cs | 123 +++++ Runtime/PSXUISprite.cs.meta | 2 + Runtime/PSXUIText.cs | 2 +- Runtime/TexturePacker.cs | 2 +- Runtime/Utils.cs | 38 ++ 83 files changed, 4800 insertions(+), 496 deletions(-) create mode 100644 Editor/Inspectors/PSXUISpriteEditor.cs create mode 100644 Editor/Inspectors/PSXUISpriteEditor.cs.meta create mode 100644 Editor/PSXAgentEditor.cs create mode 100644 Editor/PSXAgentEditor.cs.meta create mode 100644 Editor/PSXNavigationSettingsEditor.cs create mode 100644 Editor/PSXNavigationSettingsEditor.cs.meta create mode 100644 Editor/PSXScreenOverlay.cs create mode 100644 Editor/PSXScreenOverlay.cs.meta create mode 100644 Editor/PSXScreenPreview.cs create mode 100644 Editor/PSXScreenPreview.cs.meta create mode 100644 Editor/PSXTilemapPreview.cs create mode 100644 Editor/PSXTilemapPreview.cs.meta create mode 100644 Editor/PSXTilemapRendererEditor.cs create mode 100644 Editor/PSXTilemapRendererEditor.cs.meta create mode 100644 Editor/PSXUIMenuItems.cs create mode 100644 Editor/PSXUIMenuItems.cs.meta create mode 100644 Editor/TilePainterWindow.cs create mode 100644 Editor/TilePainterWindow.cs.meta create mode 100644 Runtime/PSXAgent.cs create mode 100644 Runtime/PSXAgent.cs.meta create mode 100644 Runtime/PSXNavigationSettings.cs create mode 100644 Runtime/PSXNavigationSettings.cs.meta create mode 100644 Runtime/PSXSprite.cs create mode 100644 Runtime/PSXSprite.cs.meta create mode 100644 Runtime/PSXSpriteExporter.cs create mode 100644 Runtime/PSXSpriteExporter.cs.meta create mode 100644 Runtime/PSXSpriteSheet.cs create mode 100644 Runtime/PSXSpriteSheet.cs.meta create mode 100644 Runtime/PSXTilemap.cs create mode 100644 Runtime/PSXTilemap.cs.meta create mode 100644 Runtime/PSXTilemapExporter.cs create mode 100644 Runtime/PSXTilemapExporter.cs.meta create mode 100644 Runtime/PSXTilemapRenderer.cs create mode 100644 Runtime/PSXTilemapRenderer.cs.meta create mode 100644 Runtime/PSXUIBudget.cs create mode 100644 Runtime/PSXUIBudget.cs.meta create mode 100644 Runtime/PSXUILayout.cs create mode 100644 Runtime/PSXUILayout.cs.meta create mode 100644 Runtime/PSXUISprite.cs create mode 100644 Runtime/PSXUISprite.cs.meta diff --git a/Editor/Core/MkpsxisoDownloader.cs b/Editor/Core/MkpsxisoDownloader.cs index 73a1854..9dcbca0 100644 --- a/Editor/Core/MkpsxisoDownloader.cs +++ b/Editor/Core/MkpsxisoDownloader.cs @@ -10,7 +10,7 @@ namespace SplashEdit.EditorCode { /// - /// Downloads and manages mkpsxiso — the tool that builds PlayStation CD images + /// Downloads and manages mkpsxiso - the tool that builds PlayStation CD images /// from an XML catalog. Used for the ISO build target. /// https://github.com/Lameguy64/mkpsxiso /// diff --git a/Editor/Core/PCSXReduxDownloader.cs b/Editor/Core/PCSXReduxDownloader.cs index 41c0213..fce3851 100644 --- a/Editor/Core/PCSXReduxDownloader.cs +++ b/Editor/Core/PCSXReduxDownloader.cs @@ -12,8 +12,8 @@ namespace SplashEdit.EditorCode /// Downloads and installs PCSX-Redux from the official distrib.app CDN. /// Mirrors the logic from pcsx-redux.js (the official download script). /// - /// Flow: fetch platform manifest → find latest build ID → fetch build manifest → - /// get download URL → download zip → extract to .tools/pcsx-redux/ + /// Flow: fetch platform manifest -> find latest build ID -> fetch build manifest -> + /// get download URL -> download zip -> extract to .tools/pcsx-redux/ /// public static class PCSXReduxDownloader { @@ -94,7 +94,7 @@ public static async Task DownloadAndInstall(Action log = null) string downloadUrl = $"https://distrib.app{downloadPath}"; log?.Invoke($"Downloading: {downloadUrl}"); - // Step 3: Download — use the real extension from the URL + // Step 3: Download - use the real extension from the URL string ext = ".zip"; if (downloadPath.EndsWith(".tar.gz")) ext = ".tar.gz"; else if (downloadPath.EndsWith(".dmg")) ext = ".dmg"; @@ -120,7 +120,7 @@ public static async Task DownloadAndInstall(Action log = null) log?.Invoke($"Downloaded to {tempFile}"); EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f); - // Step 4: Extract — handle zip, tar.gz, and dmg + // Step 4: Extract - handle zip, tar.gz, and dmg string installDir = SplashBuildPaths.PCSXReduxDir; if (Directory.Exists(installDir)) Directory.Delete(installDir, true); @@ -166,7 +166,7 @@ public static async Task DownloadAndInstall(Action log = null) } else { - // The zip might have a nested directory — try to find the exe + // The zip might have a nested directory - try to find the exe SplashEdit.RuntimeCode.Utils.FixNestedDirectory(installDir); if (SplashBuildPaths.IsPCSXReduxInstalled()) { @@ -254,7 +254,7 @@ private static void PostInstallMacOS(string installDir, Action log) // SplashBuildPaths.PCSXReduxBinary expects a flat "pcsx-redux" executable, // but distrib.app ships a .app bundle. This wrapper bridges the gap. - // It also handles process cleanup — without pkill, PCSX-Redux sometimes + // It also handles process cleanup - without pkill, PCSX-Redux sometimes // survives Unity's Process.Kill and blocks the port on next launch. if (File.Exists(appBinary)) { diff --git a/Editor/Core/PCdrvSerialHost.cs b/Editor/Core/PCdrvSerialHost.cs index 51218a2..20cc897 100644 --- a/Editor/Core/PCdrvSerialHost.cs +++ b/Editor/Core/PCdrvSerialHost.cs @@ -10,11 +10,11 @@ namespace SplashEdit.EditorCode { /// - /// PCdrv Host — serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol). + /// PCdrv Host - serves files to a PS1 over serial (Unirom/NOTPSXSerial protocol). /// /// PCdrv uses MIPS `break` instructions to request file I/O from the host. /// For real hardware running Unirom, we must: - /// 1. Enter debug mode (DEBG/OKAY) — installs Unirom's kernel-resident SIO handler + /// 1. Enter debug mode (DEBG/OKAY) - installs Unirom's kernel-resident SIO handler /// 2. Continue execution (CONT/OKAY) /// 3. Monitor serial for escape sequences: 0x00 followed by 'p' = PCDrv command /// 4. Handle file operations (init, open, read, close, seek, etc.) @@ -47,7 +47,7 @@ private class PCFile public FileAccess Mode; } - // Protocol escape char — PCDrv commands are prefixed with 0x00 + 'p' + // Protocol escape char - PCDrv commands are prefixed with 0x00 + 'p' private const byte ESCAPE_CHAR = 0x00; // PCDrv function codes (from Unirom kernel) @@ -101,9 +101,9 @@ public void Start() /// /// Starts the PCDrv monitor loop on an already-open serial port. - /// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG → SEXE + /// Use this after UniromUploader.UploadExeForPCdrv() which sends DEBG -> SEXE /// and returns the open port. The debug hooks are already installed and the - /// exe is already running — we just need to listen for escape sequences. + /// exe is already running - we just need to listen for escape sequences. /// public void Start(SerialPort openPort) { @@ -148,10 +148,10 @@ public void Stop() public void Dispose() => Stop(); - // ═══════════════════════════════════════════════════════════════ - // Monitor loop — reads serial byte-by-byte looking for escape sequences + // --------------------------------------------------------------- + // Monitor loop - reads serial byte-by-byte looking for escape sequences // Matches NOTPSXSerial's Bridge.MonitorSerial() - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void MonitorLoop(CancellationToken ct) { @@ -214,12 +214,12 @@ private void MonitorLoop(CancellationToken ct) if (b == 'p') { // PCDrv command incoming - _log?.Invoke("PCdrv monitor: got escape+p → PCDrv command!"); + _log?.Invoke("PCdrv monitor: got escape+p -> PCDrv command!"); HandlePCDrvCommand(ct); } else { - // Unknown escape sequence — log it + // Unknown escape sequence - log it _log?.Invoke($"PCdrv monitor: unknown escape seq: 0x00 + 0x{b:X2} ('{(b >= 0x20 && b < 0x7F ? (char)b : '.')}')"); } @@ -232,7 +232,7 @@ private void MonitorLoop(CancellationToken ct) continue; } - // Regular byte — this is printf output from the PS1 + // Regular byte - this is printf output from the PS1 if (b == '\n' || b == '\r') { if (textBuffer.Length > 0) @@ -273,10 +273,10 @@ private void MonitorLoop(CancellationToken ct) } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // PCDrv command dispatcher // Matches NOTPSXSerial's PCDrv.ReadCommand() - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void HandlePCDrvCommand(CancellationToken ct) { @@ -297,9 +297,9 @@ private void HandlePCDrvCommand(CancellationToken ct) } } - // ═══════════════════════════════════════════════════════════════ - // Individual PCDrv handlers — match NOTPSXSerial's PCDrv.cs - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- + // Individual PCDrv handlers - match NOTPSXSerial's PCDrv.cs + // --------------------------------------------------------------- private void HandleInit() { @@ -429,7 +429,7 @@ private void HandleClose(CancellationToken ct) var f = FindOpenFile(handle); if (f == null) { - // No such file — "great success" per NOTPSXSerial + // No such file - "great success" per NOTPSXSerial SendString("OKAY"); WriteInt32(0); return; @@ -563,9 +563,9 @@ private void HandleSeek(CancellationToken ct) } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // PS1 output routing - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// /// Routes PS1 printf output to PSXConsoleWindow (via _psxLog) if available, @@ -579,11 +579,11 @@ private void EmitPsxLine(string text) _log?.Invoke($"PS1> {text}"); } - // ═══════════════════════════════════════════════════════════════ - // Chunked data write — matches NOTPSXSerial's WriteBytes() + // --------------------------------------------------------------- + // Chunked data write - matches NOTPSXSerial's WriteBytes() // Sends data in 2048-byte chunks; for protocol V2+ Unirom // responds with CHEK/MORE/ERR! per chunk. - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void WriteDataChunked(byte[] data) { @@ -622,9 +622,9 @@ private void WriteDataChunked(byte[] data) } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // File handle helpers - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private int NextHandle() => _files.Count + 1; @@ -655,9 +655,9 @@ private string ResolvePath(string filename) return Path.Combine(_baseDir, filename); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Low-level serial I/O - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private int ReadInt32(CancellationToken ct) { diff --git a/Editor/Core/PSXAudioConverter.cs b/Editor/Core/PSXAudioConverter.cs index 4190c09..1cb3bfd 100644 --- a/Editor/Core/PSXAudioConverter.cs +++ b/Editor/Core/PSXAudioConverter.cs @@ -69,7 +69,7 @@ public static bool CheckMacOSBuildDeps(out string missing) /// public static async Task DownloadAndInstall(Action log = null) { - // macOS: no prebuilt binary available — try to build from source + // macOS: no prebuilt binary available - try to build from source if (Application.platform == RuntimePlatform.OSXEditor) return await BuildFromSourceMacOS(log); @@ -123,7 +123,7 @@ public static async Task DownloadAndInstall(Action log = null) } else { - // tar.gz extraction — use system tar + // tar.gz extraction - use system tar var psi = new ProcessStartInfo { FileName = "tar", @@ -175,7 +175,7 @@ private static async Task BuildFromSourceMacOS(Action log) string srcDir = Path.Combine(installDir, "src"); string buildDir = Path.Combine(srcDir, "build"); - // Safety check — the UI should prevent getting here without deps, + // Safety check - the UI should prevent getting here without deps, // but guard against direct calls string missingDeps; if (!CheckMacOSBuildDeps(out missingDeps)) @@ -419,7 +419,8 @@ private static void AugmentPath(ProcessStartInfo psi) /// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc. /// Returns the ADPCM byte array, or null on failure. /// - public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop) + public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop, + bool trimLeadingSilence = false) { if (!IsInstalled()) { @@ -439,7 +440,7 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l try { - ExportWav(clip, tempWav); + ExportWav(clip, tempWav, trimLeadingSilence); // Run psxavenc: convert WAV to SPU ADPCM // -t spu: raw SPU ADPCM output (no header, ready for DMA upload) @@ -474,7 +475,7 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l return null; } - // -t spu outputs raw SPU ADPCM blocks (no header) — use directly. + // -t spu outputs raw SPU ADPCM blocks (no header) - use directly. byte[] adpcm = File.ReadAllBytes(tempVag); if (adpcm.Length == 0) { @@ -493,10 +494,106 @@ public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool l /// /// Exports a Unity AudioClip to a 16-bit mono WAV file. /// - private static void ExportWav(AudioClip clip, string path) + /// Amplitude below which a sample counts as silence. + /// + /// About -54 dBFS: inaudible through a television, but comfortably above + /// the residue that dithering and lossy source formats leave behind in a + /// passage that looks silent in a waveform editor. A threshold of exactly + /// zero trims nothing at all off a real recording. + private const float SilenceFloor = 1f / 512f; + + /// Kept either side of the audible region. The threshold necessarily cuts + /// partway up an attack, and starting a sample ON its transient is what + /// makes a trimmed effect click. + private const int GuardMillis = 4; + + /// Strip silence from the START of a clip before it is encoded. + /// + /// OPT-IN, per clip, off by default. This rewrites the author's asset on + /// the way to the disc, and a lead-in somebody put there deliberately is + /// not the exporter's to remove - a tool that silently edits your audio + /// is a tool you cannot trust with the rest of it. + /// + /// What it is FOR, when you do want it: leading silence is latency. It is + /// dead time between the frame that asks for a sound and the frame it is + /// audible on, and exported clips routinely carry half a second of it or + /// more. Past roughly 100ms the effect stops reading as feedback and + /// starts reading as an echo. It is also SPU RAM: a pack measured at 55% + /// silence spent 206 KB of its 373 KB, out of the 511 KB the hardware has, + /// on nothing. + /// + /// The TAIL is never touched. On a one-shot it is inaudible either way, + /// and on a LOOPING clip it is the rhythm: the hardware loops back to the + /// start of the sample, so the trailing silence IS the interval the sound + /// repeats at. Cutting it would turn a measured klaxon into a flat tone. + private static float[] TrimLeadingSilence(float[] mono, int frequency, string name) { + if (mono == null || mono.Length == 0 || frequency <= 0) return mono; + + int first = -1; + for (int i = 0; i < mono.Length; i++) + { + if (mono[i] > SilenceFloor || mono[i] < -SilenceFloor) { first = i; break; } + } + + // Nothing above the floor anywhere: the clip really is silent. Leave + // it exactly as it is rather than handing psxavenc an empty buffer + // and getting back a zero-length clip that `loadClip` will refuse. + if (first < 0) + { + Debug.LogWarning($"[SplashEdit] '{name}' has Trim Leading Silence on but is " + + "silent all the way through; left untouched."); + return mono; + } + + int guard = (frequency * GuardMillis) / 1000; + first -= guard; + if (first < 0) first = 0; + if (first == 0) return mono; + + int len = mono.Length - first; + float[] trimmed = new float[len]; + Array.Copy(mono, first, trimmed, 0, len); + + Debug.Log($"[SplashEdit] '{name}': trimmed {first / (float)frequency:0.00}s " + + "of leading silence."); + return trimmed; + } + + private static void ExportWav(AudioClip clip, string path, + bool trimLeadingSilence = false) + { + // LOAD THE SAMPLES FIRST. + // + // `GetData` on a clip whose audio data is not loaded returns false and + // leaves the buffer as it found it - all zeros. It does not throw and + // it does not warn, so the export runs to completion and writes a + // perfectly well-formed ADPCM clip of pure silence. + // + // That failure is close to undiagnosable from the game: the clip has a + // non-zero size, it lands in SPU RAM, its name resolves, `Audio.Play` + // accepts it and returns a voice, and the console plays it. Everything + // reports success and nothing comes out of the television. + // + // Unity's default importer sets `preloadAudioData: 0`, so this is the + // NORMAL state of a freshly imported clip rather than a misconfigured + // one. Loading it here means the export does not depend on an import + // setting nobody would think to check. + bool loadedHere = false; + if (clip.loadState != AudioDataLoadState.Loaded) + { + loadedHere = clip.LoadAudioData(); + if (!loadedHere) + Debug.LogWarning($"PSXAudioConverter: could not load audio data for " + + $"'{clip.name}'; it will export as silence."); + } + float[] samples = new float[clip.samples * clip.channels]; - clip.GetData(samples, 0); + if (!clip.GetData(samples, 0)) + Debug.LogWarning($"PSXAudioConverter: GetData failed for '{clip.name}'; " + + "it will export as silence."); + + if (loadedHere) clip.UnloadAudioData(); // Downmix to mono if stereo float[] mono; @@ -516,6 +613,9 @@ private static void ExportWav(AudioClip clip, string path) mono = samples; } + if (trimLeadingSilence) + mono = TrimLeadingSilence(mono, clip.frequency, clip.name); + // Write WAV using (var fs = new FileStream(path, FileMode.Create)) using (var writer = new BinaryWriter(fs)) diff --git a/Editor/Core/PSXConsoleWindow.cs b/Editor/Core/PSXConsoleWindow.cs index e5fa139..33d7a6e 100644 --- a/Editor/Core/PSXConsoleWindow.cs +++ b/Editor/Core/PSXConsoleWindow.cs @@ -19,14 +19,14 @@ public class PSXConsoleWindow : EditorWindow private const int MAX_LINES = 2000; private const int TRIM_AMOUNT = 500; - // ── Shared state (set by SplashControlPanel) ── + // -- Shared state (set by SplashControlPanel) -- private static Process _process; private static readonly List _lines = new List(); private static readonly object _lock = new object(); private static volatile bool _autoScroll = true; private static volatile bool _reading; - // ── Instance state ── + // -- Instance state -- private Vector2 _scrollPos; private string _filterText = ""; private bool _showStdout = true; @@ -37,7 +37,7 @@ public class PSXConsoleWindow : EditorWindow private GUIStyle _monoStyleSelected; private int _lastLineCount; - // ── Selection state (for shift-click range and right-click copy) ── + // -- Selection state (for shift-click range and right-click copy) -- private int _selectionAnchor = -1; // first clicked line index (into _lines) private int _selectionEnd = -1; // last shift-clicked line index (into _lines) @@ -48,9 +48,9 @@ private struct LogLine public string timestamp; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Menu - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- [MenuItem(MENU_PATH, false, 10)] public static void ShowWindow() @@ -61,9 +61,9 @@ public static void ShowWindow() window.Show(); } - // ═══════════════════════════════════════════════════════════════ - // Public API — called by SplashControlPanel - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- + // Public API - called by SplashControlPanel + // --------------------------------------------------------------- /// /// Adds a line to the console from any source (serial host, emulator fallback, etc.). @@ -127,9 +127,9 @@ public static void Detach() _process = null; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Async readers - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static void StartReader(System.IO.StreamReader reader, bool isError) { @@ -161,7 +161,7 @@ private static void StartReader(System.IO.StreamReader reader, bool isError) } catch (Exception) { - // Stream closed — normal when process exits + // Stream closed - normal when process exits } }) { @@ -171,9 +171,9 @@ private static void StartReader(System.IO.StreamReader reader, bool isError) thread.Start(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Window lifecycle - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void OnEnable() { @@ -197,9 +197,9 @@ private void OnEditorUpdate() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // GUI - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void EnsureStyles() { @@ -278,7 +278,7 @@ private void DrawToolbar() bool alive = _process != null && !_process.HasExited; var statusColor = GUI.contentColor; GUI.contentColor = alive ? Color.green : Color.gray; - GUILayout.Label(alive ? "● Live" : "● Stopped", EditorStyles.toolbarButton, GUILayout.Width(60)); + GUILayout.Label(alive ? "* Live" : "* Stopped", EditorStyles.toolbarButton, GUILayout.Width(60)); GUI.contentColor = statusColor; // Filter @@ -293,7 +293,7 @@ private void DrawToolbar() _wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40)); // Auto-scroll - _autoScroll = GUILayout.Toggle(_autoScroll, "Auto↓", EditorStyles.toolbarButton, GUILayout.Width(50)); + _autoScroll = GUILayout.Toggle(_autoScroll, "Autov", EditorStyles.toolbarButton, GUILayout.Width(50)); // Clear if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45))) diff --git a/Editor/Core/SceneMemoryReport.cs b/Editor/Core/SceneMemoryReport.cs index 1a88e2c..3140560 100644 --- a/Editor/Core/SceneMemoryReport.cs +++ b/Editor/Core/SceneMemoryReport.cs @@ -13,13 +13,13 @@ public class SceneMemoryReport { public string sceneName; - // ─── Main RAM ─── + // --- Main RAM --- public long splashpackFileSize; // Total file on disc public long splashpackLiveSize; // Bytes kept in RAM at runtime (before bulk data freed) public int triangleCount; public int gameObjectCount; - // ─── VRAM (1024 x 512 x 2 = 1,048,576 bytes) ─── + // --- VRAM (1024 x 512 x 2 = 1,048,576 bytes) --- public long framebufferSize; // 2 x W x H x 2 public long textureAtlasSize; // Sum of atlas pixel data public long clutSize; // Sum of CLUT entries x 2 @@ -27,14 +27,14 @@ public class SceneMemoryReport public int atlasCount; public int clutCount; - // ─── SPU RAM (512KB, 0x1010 reserved) ─── + // --- SPU RAM (512KB, 0x1010 reserved) --- public long audioDataSize; public int audioClipCount; - // ─── CD Storage ─── + // --- CD Storage --- public long loaderPackSize; - // ─── Constants ─── + // --- Constants --- public const long TOTAL_RAM = 2 * 1024 * 1024; public const long KERNEL_RESERVED = 0x10000; // 64KB kernel area public const long USABLE_RAM = TOTAL_RAM - KERNEL_RESERVED; @@ -103,7 +103,7 @@ public static SceneMemoryReport Analyze( { var r = new SceneMemoryReport { sceneName = sceneName }; - // ── File sizes ── + // -- File sizes -- if (File.Exists(splashpackPath)) r.splashpackFileSize = new FileInfo(splashpackPath).Length; if (!string.IsNullOrEmpty(loaderPackPath) && File.Exists(loaderPackPath)) @@ -111,20 +111,20 @@ public static SceneMemoryReport Analyze( r.triangleCount = triangleCount; - // ── Parse splashpack header for counts and pixelDataOffset ── + // -- Parse splashpack header for counts and pixelDataOffset -- if (File.Exists(splashpackPath)) { try { ReadHeader(splashpackPath, r); } catch (Exception e) { Debug.LogWarning($"Memory report: failed to read header: {e.Message}"); } } - // ── Framebuffers ── + // -- Framebuffers -- int fbW = SplashSettings.ResolutionWidth; int fbH = SplashSettings.ResolutionHeight; int fbCount = SplashSettings.DualBuffering ? 2 : 1; r.framebufferSize = fbW * fbH * 2L * fbCount; - // ── VRAM: Texture atlases + CLUTs ── + // -- VRAM: Texture atlases + CLUTs -- if (atlases != null) { r.atlasCount = atlases.Length; @@ -142,7 +142,7 @@ public static SceneMemoryReport Analyze( } } - // ── VRAM: Custom fonts ── + // -- VRAM: Custom fonts -- if (fonts != null) { foreach (var font in fonts) @@ -152,7 +152,7 @@ public static SceneMemoryReport Analyze( } } - // ── SPU: Audio ── + // -- SPU: Audio -- if (audioExportSizes != null) { r.audioClipCount = audioExportSizes.Length; diff --git a/Editor/Core/SplashBuildPaths.cs b/Editor/Core/SplashBuildPaths.cs index f20e7da..e9d47e6 100644 --- a/Editor/Core/SplashBuildPaths.cs +++ b/Editor/Core/SplashBuildPaths.cs @@ -184,7 +184,7 @@ public static void EnsureDirectories() EnsureGitIgnore(); } - // ───── Lua bytecode compilation paths ───── + // ----- Lua bytecode compilation paths ----- /// /// Directory for Lua source files extracted during export. diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs index 21f4781..215ea24 100644 --- a/Editor/Core/SplashControlPanel.cs +++ b/Editor/Core/SplashControlPanel.cs @@ -15,16 +15,16 @@ namespace SplashEdit.EditorCode { /// - /// SplashEdit Control Panel — the single unified window for the entire pipeline. + /// SplashEdit Control Panel - the single unified window for the entire pipeline. /// One window. One button. Everything works. /// public class SplashControlPanel : EditorWindow { - // ───── Constants ───── + // ----- Constants ----- private const string WINDOW_TITLE = "SplashEdit Control Panel"; private const string MENU_PATH = "PlayStation 1/SplashEdit Control Panel %#l"; - // ───── UI State ───── + // ----- UI State ----- private Vector2 _scrollPos; private int _selectedTab = 0; private static readonly string[] _tabNames = { "Dependencies", "Scenes", "Music (CD-DA)", "Build", "Memory Card" }; @@ -36,23 +36,23 @@ public class SplashControlPanel : EditorWindow private bool _showBuildSection = true; private bool _showMemCardSection = true; - // ───── Build State ───── + // ----- Build State ----- private static bool _isBuilding; private static bool _isRunning; private static bool _luaBytecodeCompiled; private static Process _emulatorProcess; - // ───── Scene List ───── + // ----- Scene List ----- private List _sceneList = new List(); - // ───── Music List ───── + // ----- Music List ----- private List _musicList = new List(); - // ───── Memory Reports ───── + // ----- Memory Reports ----- private List _memoryReports = new List(); private bool _showMemoryReport = true; - // ───── Toolchain Cache ───── + // ----- Toolchain Cache ----- private bool _hasMIPS; private bool _hasMake; private bool _hasRedux; @@ -65,19 +65,19 @@ public class SplashControlPanel : EditorWindow private bool _hasXcodeCLT; private bool _hasBrew; - // ───── Native project installer ───── + // ----- Native project installer ----- private bool _isInstallingNative; private string _nativeInstallStatus = ""; private string _manualNativePath = ""; - // ───── Release selector ───── + // ----- Release selector ----- private int _selectedReleaseIndex = 0; private string[] _releaseDisplayNames = new string[0]; private bool _isFetchingReleases; private string _currentTag = ""; private bool _isSwitchingRelease; - // ───── Native status cache (avoid expensive checks every repaint) ───── + // ----- Native status cache (avoid expensive checks every repaint) ----- private bool _isGitAvailable; private bool _isNativeRepoInstalled; private double _nextNativeStatusRefreshTime; @@ -100,9 +100,9 @@ private struct MusicEntry public string name; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Menu & Window Lifecycle - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- [MenuItem(MENU_PATH, false, 0)] public static void ShowWindow() @@ -135,9 +135,9 @@ private void OnFocus() RefreshNativeProjectStatus(force: true); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Main GUI - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void OnGUI() { @@ -185,9 +185,9 @@ private void OnGUI() EditorGUILayout.EndScrollView(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Header - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawHeader() { @@ -205,12 +205,12 @@ private void DrawHeader() if (!_hasMIPS) { - statusText = "Setup required — install the MIPS toolchain to get started"; + statusText = "Setup required - install the MIPS toolchain to get started"; statusColor = PSXEditorStyles.Warning; } else if (!_hasNativeProject) { - statusText = "Native project not found — clone or set path below"; + statusText = "Native project not found - clone or set path below"; statusColor = PSXEditorStyles.Warning; } else if (_isBuilding) @@ -238,9 +238,9 @@ private void DrawHeader() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Native Project Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawNativeProjectSection() { @@ -262,13 +262,13 @@ private void DrawNativeProjectSection() } else { - GUILayout.Label("Not found — download from GitHub or set path manually", EditorStyles.miniLabel); + GUILayout.Label("Not found - download from GitHub or set path manually", EditorStyles.miniLabel); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(6); - // ── Option 1: Download a release from GitHub ── + // -- Option 1: Download a release from GitHub -- PSXEditorStyles.DrawSeparator(4, 4); GUILayout.Label("Download from GitHub", PSXEditorStyles.SectionHeader); @@ -297,7 +297,7 @@ private void DrawNativeProjectSection() else { _selectedReleaseIndex = EditorGUILayout.Popup(_selectedReleaseIndex, _releaseDisplayNames); - if (GUILayout.Button("↻", EditorStyles.miniButton, GUILayout.Width(22))) + if (GUILayout.Button("(cycle)", EditorStyles.miniButton, GUILayout.Width(22))) FetchGitHubReleases(); } EditorGUILayout.EndHorizontal(); @@ -315,7 +315,7 @@ private void DrawNativeProjectSection() EditorGUILayout.BeginHorizontal(); if (!_isNativeRepoInstalled) { - // Not installed yet — show Clone button + // Not installed yet - show Clone button EditorGUI.BeginDisabledGroup( _isInstallingNative || _releaseDisplayNames.Length == 0 || !_isGitAvailable); @@ -327,7 +327,7 @@ private void DrawNativeProjectSection() } else { - // Already installed — show Switch and Open buttons + // Already installed - show Switch and Open buttons EditorGUI.BeginDisabledGroup( _isSwitchingRelease || _isInstallingNative || _releaseDisplayNames.Length == 0 || !_isGitAvailable); @@ -366,7 +366,7 @@ private void DrawNativeProjectSection() EditorGUILayout.Space(6); - // ── Option 2: Manual path ── + // -- Option 2: Manual path -- PSXEditorStyles.DrawSeparator(4, 4); GUILayout.Label("Or set path manually", PSXEditorStyles.SectionHeader); EditorGUILayout.BeginHorizontal(); @@ -412,16 +412,16 @@ private void DrawNativeProjectSection() { var prevColor = GUI.contentColor; GUI.contentColor = PSXEditorStyles.Success; - GUILayout.Label("✓ Path is set and valid", EditorStyles.miniLabel); + GUILayout.Label("yes Path is set and valid", EditorStyles.miniLabel); GUI.contentColor = prevColor; } EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Toolchain Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawToolchainSection() { @@ -432,7 +432,7 @@ private void DrawToolchainSection() bool isMac = Application.platform == RuntimePlatform.OSXEditor; - // GNU Make (before MIPS — it's a prerequisite for everything on macOS) + // GNU Make (before MIPS - it's a prerequisite for everything on macOS) EditorGUILayout.BeginHorizontal(); DrawStatusIcon(_hasMake); GUILayout.Label("GNU Make", GUILayout.Width(160)); @@ -516,7 +516,7 @@ private void DrawToolchainSection() "curl -LO https://raw.githubusercontent.com/grumpycoders/pcsx-redux/main/tools/macos-mips/mipsel-none-elf-gcc.rb && " + "brew install-path ./mipsel-none-elf-binutils.rb && " + "brew install-path ./mipsel-none-elf-gcc.rb", - "Builds GCC from source — expect 15-30 minutes."); + "Builds GCC from source - expect 15-30 minutes."); } } @@ -596,9 +596,9 @@ private void DrawToolchainSection() EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Scenes Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawScenesSection() { @@ -646,17 +646,17 @@ private void DrawScenesSection() // Move buttons EditorGUI.BeginDisabledGroup(i == 0); - if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22))) + if (GUILayout.Button("^", EditorStyles.miniButtonLeft, GUILayout.Width(22))) moveUp = i; EditorGUI.EndDisabledGroup(); EditorGUI.BeginDisabledGroup(i == _sceneList.Count - 1); - if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22))) + if (GUILayout.Button("v", EditorStyles.miniButtonRight, GUILayout.Width(22))) moveDown = i; EditorGUI.EndDisabledGroup(); // Remove - if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20))) + if (GUILayout.Button("x", EditorStyles.miniButton, GUILayout.Width(20))) removeIndex = i; EditorGUILayout.EndHorizontal(); @@ -710,9 +710,9 @@ private void DrawScenesSection() EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Memory Card Section (global project save settings) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawMemoryCardSection() { @@ -761,7 +761,7 @@ private void DrawMemoryCardSection() if (!string.IsNullOrEmpty(data.MemCardTitle) && data.MemCardTitle.Length > 32) EditorGUILayout.HelpBox("Save title is longer than 32 characters and will be truncated.", MessageType.Warning); if (data.MemCardIcons == null || data.MemCardIcons.Length == 0) - EditorGUILayout.HelpBox("No icon assigned — a blank icon will be shown in the BIOS. Assign a 16x16 texture.", MessageType.Info); + EditorGUILayout.HelpBox("No icon assigned - a blank icon will be shown in the BIOS. Assign a 16x16 texture.", MessageType.Info); else if (data.MemCardIcons.Length > 3) EditorGUILayout.HelpBox("Only the first 3 icon frames are used.", MessageType.Info); } @@ -769,9 +769,9 @@ private void DrawMemoryCardSection() EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Music Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawMusicSection() { @@ -820,17 +820,17 @@ private void DrawMusicSection() // Move buttons EditorGUI.BeginDisabledGroup(i == 0); - if (GUILayout.Button("▲", EditorStyles.miniButtonLeft, GUILayout.Width(22))) + if (GUILayout.Button("^", EditorStyles.miniButtonLeft, GUILayout.Width(22))) moveUp = i; EditorGUI.EndDisabledGroup(); EditorGUI.BeginDisabledGroup(i == _musicList.Count - 1); - if (GUILayout.Button("▼", EditorStyles.miniButtonRight, GUILayout.Width(22))) + if (GUILayout.Button("v", EditorStyles.miniButtonRight, GUILayout.Width(22))) moveDown = i; EditorGUI.EndDisabledGroup(); // Remove - if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20))) + if (GUILayout.Button("x", EditorStyles.miniButton, GUILayout.Width(20))) removeIndex = i; EditorGUILayout.EndHorizontal(); @@ -880,9 +880,9 @@ private void DrawMusicSection() EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // VRAM & Textures Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawVRAMSection() { @@ -912,9 +912,9 @@ private void DrawVRAMSection() EditorGUILayout.EndVertical(); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Build & Run Section - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawBuildSection() { @@ -981,7 +981,7 @@ private void DrawBuildSection() EditorGUILayout.BeginHorizontal(); GUILayout.Label("License File:", GUILayout.Width(80)); string licensePath = SplashSettings.LicenseFilePath; - string displayPath = string.IsNullOrEmpty(licensePath) ? "(none — homebrew)" : Path.GetFileName(licensePath); + string displayPath = string.IsNullOrEmpty(licensePath) ? "(none - homebrew)" : Path.GetFileName(licensePath); GUILayout.Label(displayPath, EditorStyles.miniLabel, GUILayout.ExpandWidth(true)); if (GUILayout.Button("Browse", EditorStyles.miniButton, GUILayout.Width(60))) { @@ -1063,9 +1063,9 @@ private void DrawBuildSection() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Memory Reports - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void DrawMemoryReports() { @@ -1079,7 +1079,7 @@ private void DrawMemoryReports() GUILayout.Label($"Scene: {report.sceneName}", PSXEditorStyles.SectionHeader); EditorGUILayout.Space(4); - // Main RAM bar — segmented: OT | Bump | Scene | Heap + // Main RAM bar - segmented: OT | Bump | Scene | Heap DrawSegmentedMemoryBar("Main RAM", SceneMemoryReport.USABLE_RAM, report.RamPercent, @@ -1264,12 +1264,12 @@ private static string FormatBytes(long bytes) return $"{bytes / (1024f * 1024f):F2} MB"; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Pipeline Actions - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// - /// The main pipeline: Validate → Export all scenes → Compile → Launch. + /// The main pipeline: Validate -> Export all scenes -> Compile -> Launch. /// public async void BuildAndRun() { @@ -1367,7 +1367,7 @@ public async void BuildAndRun() } } - // ───── Step 1: Validate ───── + // ----- Step 1: Validate ----- private bool ValidateToolchain() { @@ -1410,7 +1410,7 @@ private bool ValidateToolchain() return true; } - // ───── Step 2: Export ───── + // ----- Step 2: Export ----- /// /// Exports all scenes in the scene list to splashpack files in PSXBuild/. @@ -1455,7 +1455,7 @@ public bool ExportAllScenes() string outputPath = SplashBuildPaths.GetSceneSplashpackPath(i, scene.name); string loaderPath = null; exporter.ExportToPath(outputPath); - Log($"Exported '{scene.name}' → {Path.GetFileName(outputPath)}", LogType.Log); + Log($"Exported '{scene.name}' -> {Path.GetFileName(outputPath)}", LogType.Log); // Export loading screen if assigned if (exporter.LoadingScreenPrefab != null) @@ -1535,7 +1535,7 @@ private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneInd if (File.Exists(cachedPath)) { File.Copy(cachedPath, outputPath, true); - Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log); + Log($"Loading screen for '{sceneName}' -> {Path.GetFileName(outputPath)} (deduped from {Path.GetFileName(cachedPath)})", LogType.Log); return; } } @@ -1586,7 +1586,7 @@ private void ExportLoaderPack(GameObject prefab, string outputPath, int sceneInd (msg, type) => Log(msg, type)); if (ok) { - Log($"Loading screen for '{sceneName}' → {Path.GetFileName(outputPath)}", LogType.Log); + Log($"Loading screen for '{sceneName}' -> {Path.GetFileName(outputPath)}", LogType.Log); if (!string.IsNullOrEmpty(guid)) _loaderPackCache[guid] = outputPath; } @@ -1623,7 +1623,7 @@ private void WriteManifest() Log("Wrote scene manifest.", LogType.Log); } - // ───── Lua bytecode compilation ───── + // ----- Lua bytecode compilation ----- /// /// Scan all scenes in the scene list and collect unique Lua source files. @@ -1960,7 +1960,7 @@ private async Task BuildLuacPsxAsync(string luacDir) } } - // ───── Step 3: Compile ───── + // ----- Step 3: Compile ----- private async void CompileOnly() { @@ -2126,7 +2126,7 @@ private string FindCompiledExe(string nativeDir) return null; } - // ───── Step 4: Launch ───── + // ----- Step 4: Launch ----- private void Launch() { @@ -2222,7 +2222,7 @@ private void LaunchToHardware() // Stop any previous run (emulator or PCdrv) without clearing the console StopAllQuiet(); - // Upload the exe with debug hooks (DEBG → SEXE on the same port). + // Upload the exe with debug hooks (DEBG -> SEXE on the same port). // DEBG installs kernel-resident break handlers BEFORE the exe auto-starts. // The returned port stays open so PCDrv monitoring can begin immediately. Log($"Uploading to {port}...", LogType.Log); @@ -2234,7 +2234,7 @@ private void LaunchToHardware() return; } - // Start PCdrv host on the same open port — no re-open, no DEBG/CONT needed + // Start PCdrv host on the same open port - no re-open, no DEBG/CONT needed try { _pcdrvHost = new PCdrvSerialHost(port, baud, SplashBuildPaths.BuildOutputDir, @@ -2252,7 +2252,7 @@ private void LaunchToHardware() } } - // ───── ISO Build ───── + // ----- ISO Build ----- private void BuildAndLaunchISO() { @@ -2299,7 +2299,7 @@ private void BuildAndLaunchISO() return; } - // Step 3: Delete existing .bin/.cue — mkpsxiso won't overwrite them + // Step 3: Delete existing .bin/.cue - mkpsxiso won't overwrite them try { if (File.Exists(outputBin)) File.Delete(outputBin); @@ -2360,10 +2360,10 @@ private bool GenerateSystemCnf() { string cnfPath = SplashBuildPaths.SystemCnfPath; - // The executable name on disc — no extension, max 12 chars + // The executable name on disc - no extension, max 12 chars string exeName = GetISOExeName(); - // SYSTEM.CNF content — the BIOS reads this to launch the executable. + // SYSTEM.CNF content - the BIOS reads this to launch the executable. // BOOT: path to the executable on disc (cdrom:\path;1) // TCB: number of thread control blocks (4 is standard) // EVENT: number of event control blocks (10 is standard) @@ -2423,13 +2423,13 @@ private string GenerateISOCatalog(string outputBin, string outputCue) xml.AppendLine(" "); - // SYSTEM.CNF — the BIOS locates this by scanning only the first + // SYSTEM.CNF - the BIOS locates this by scanning only the first // sector of the root directory, so the root must stay small enough // that SYSTEM.CNF's record fits there (see the SCENES/ dir below). string cnfPath = SplashBuildPaths.SystemCnfPath; xml.AppendLine($" "); - // The executable — renamed to match what SYSTEM.CNF points to + // The executable - renamed to match what SYSTEM.CNF points to string exePath = SplashBuildPaths.CompiledExePath; string isoExeName = GetISOExeName(); xml.AppendLine($" "); @@ -2449,18 +2449,18 @@ private string GenerateISOCatalog(string outputBin, string outputCue) // // 1. Boot: the BIOS boot ROM only scans the *first* 2048-byte // sector of the ROOT directory when looking for SYSTEM.CNF. - // A flat root with many scenes grows past one sector and — - // because ISO9660 sorts entries alphabetically — "SYSTEM.CNF" + // A flat root with many scenes grows past one sector and - + // because ISO9660 sorts entries alphabetically - "SYSTEM.CNF" // (which sorts after every "SCENE_*") spills into the second // sector, becomes invisible, and the console falls back to // cdrom:\PSX.EXE;1 (black screen, no boot). // // 2. Runtime: psyqo's ISO9660Parser caches one directory sector // at a time. Any single directory that spans MORE than one - // sector forces its multi-sector "continuation" read — it reads + // sector forces its multi-sector "continuation" read - it reads // directory sector N, then from that read's completion callback // immediately reads the physically-adjacent sector N+1. That - // READN→PAUSE→immediate-adjacent-READN pattern desyncs the CD + // READN->PAUSE->immediate-adjacent-READN pattern desyncs the CD // drive and aborts ("ReadSectorsAction got CDROM acknowledge in // wrong state"). A single shared SCENES/ folder with all files // is 2+ sectors and hits this; per-scene folders never do. @@ -2548,7 +2548,7 @@ private void StopPCdrvHost() } /// - /// Stops everything (emulator, PCdrv host, console reader) — used by the STOP button. + /// Stops everything (emulator, PCdrv host, console reader) - used by the STOP button. /// private void StopAll() { @@ -2601,9 +2601,9 @@ private void StopEmulatorProcess() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Toolchain Detection & Install - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static string WrapCommandForMacOS(string dir, string makeCmd) { @@ -2784,7 +2784,7 @@ private void ScanSerialPorts() } } - // ───── Release fetching & management ───── + // ----- Release fetching & management ----- private async void FetchGitHubReleases() { @@ -2801,7 +2801,7 @@ private async void FetchGitHubReleases() { string label = r.TagName; if (!string.IsNullOrEmpty(r.Name) && r.Name != r.TagName) - label += $" — {r.Name}"; + label += $" - {r.Name}"; if (r.IsPrerelease) label += " (pre-release)"; return label; @@ -2838,7 +2838,7 @@ private void RefreshCurrentTag() _currentTag = PSXSplashInstaller.GetCurrentTag() ?? ""; } - // ───── Native Project Clone/Switch ───── + // ----- Native Project Clone/Switch ----- private async void CloneNativeProject() { @@ -2872,7 +2872,7 @@ private async void CloneNativeProject() else { Log("Download failed. Check console for errors.", LogType.Error); - _nativeInstallStatus = "Download failed — check console for details."; + _nativeInstallStatus = "Download failed - check console for details."; } } catch (Exception ex) @@ -2924,7 +2924,7 @@ private async void SwitchNativeRelease() else { Log($"Failed to switch to {tag}.", LogType.Error); - _nativeInstallStatus = "Switch failed — check console for details."; + _nativeInstallStatus = "Switch failed - check console for details."; } } catch (Exception ex) @@ -2939,9 +2939,9 @@ private async void SwitchNativeRelease() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Scene List Persistence (EditorPrefs) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void LoadSceneList() { @@ -3038,9 +3038,9 @@ private void HandleSceneDragDrop() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Music List Persistence (EditorPrefs) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private void LoadMusicList() { @@ -3127,9 +3127,9 @@ private void HandleMusicDragDrop() } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Utilities - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static void Log(string message, LogType type) { diff --git a/Editor/Core/UniromUploader.cs b/Editor/Core/UniromUploader.cs index 8aa293c..5413b64 100644 --- a/Editor/Core/UniromUploader.cs +++ b/Editor/Core/UniromUploader.cs @@ -10,7 +10,7 @@ namespace SplashEdit.EditorCode /// /// Uploads a .ps-exe to a PS1 running Unirom 8 via serial. /// Implements the NOTPSXSerial / Unirom protocol: - /// Challenge/Response handshake → header → metadata → chunked data with checksums. + /// Challenge/Response handshake -> header -> metadata -> chunked data with checksums. /// Reference: https://github.com/JonathanDotCel/NOTPSXSerial /// public static class UniromUploader @@ -22,7 +22,7 @@ public static class UniromUploader private const int HEADER_SIZE = 0x800; // 2048 private const int SERIAL_TIMEOUT_MS = 5000; - // Protocol version — negotiated during handshake + // Protocol version - negotiated during handshake private static int _protocolVersion = 1; /// @@ -42,8 +42,8 @@ public static bool UploadExe(string portName, int baudRate, string exePath, Acti /// Uploads a .ps-exe with Unirom debug hooks installed, using SBIN+JUMP /// instead of SEXE to avoid BIOS Exec() clobbering the debug handler. /// - /// Flow: DEBG (install kernel-resident debug hooks) → SBIN (raw binary to address) - /// → JUMP (start execution at entry point). This bypasses BIOS Exec() entirely, + /// Flow: DEBG (install kernel-resident debug hooks) -> SBIN (raw binary to address) + /// -> JUMP (start execution at entry point). This bypasses BIOS Exec() entirely, /// so the exception vector table patched by DEBG survives into the running program. /// /// Returns the open SerialPort for the caller to use for PCDrv monitoring. @@ -104,7 +104,7 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath while (port.BytesToRead > 0) port.ReadByte(); - // ── Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode ── + // -- Step 0 (PCDrv only): Install debug hooks while Unirom is still in command mode -- if (installDebugHooks) { log?.Invoke("Installing debug hooks (DEBG)..."); @@ -122,7 +122,7 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath port.ReadByte(); } - // ── Step 1: Challenge/Response handshake ── + // -- Step 1: Challenge/Response handshake -- log?.Invoke("Sending SEXE challenge..."); if (!ChallengeResponse(port, CHALLENGE_SEND_EXE, RESPONSE_OK, log)) { @@ -132,19 +132,19 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath } log?.Invoke($"Unirom responded (protocol V{_protocolVersion}). Starting transfer..."); - // ── Step 2: Calculate checksum (skip first 0x800 header sector) ── + // -- Step 2: Calculate checksum (skip first 0x800 header sector) -- uint checksum = CalculateChecksum(exeData, skipFirstSector: true); - // ── Step 3: Send the 2048-byte header sector ── + // -- Step 3: Send the 2048-byte header sector -- port.Write(exeData, 0, HEADER_SIZE); - // ── Step 4: Send metadata ── + // -- Step 4: Send metadata -- port.Write(exeData, 0x10, 4); // Jump/PC address port.Write(exeData, 0x18, 4); // Base/write address port.Write(BitConverter.GetBytes(exeData.Length - HEADER_SIZE), 0, 4); // Data length port.Write(BitConverter.GetBytes(checksum), 0, 4); // Checksum - // ── Step 5: Send data chunks (skip first sector) ── + // -- Step 5: Send data chunks (skip first sector) -- if (!WriteChunked(port, exeData, skipFirstSector: true, log)) { log?.Invoke("Data transfer failed."); @@ -176,9 +176,9 @@ private static SerialPort DoUpload(string portName, int baudRate, string exePath /// the BIOS. This preserves the break-instruction handler that PCDrv depends on. /// /// Protocol: - /// 1. DEBG → OKAY: Install kernel-resident SIO debug stub - /// 2. SBIN → OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data - /// 3. JUMP → OKAY: addr(4 LE) — jump to entry point + /// 1. DEBG -> OKAY: Install kernel-resident SIO debug stub + /// 2. SBIN -> OKAY: addr(4 LE) + len(4 LE) + checksum(4 LE) + raw program data + /// 3. JUMP -> OKAY: addr(4 LE) - jump to entry point /// private static SerialPort DoUploadSBIN(string portName, int baudRate, string exePath, Action log) { @@ -249,7 +249,7 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe while (port.BytesToRead > 0) port.ReadByte(); - // ── Step 1: DEBG — Install kernel-resident debug hooks ── + // -- Step 1: DEBG - Install kernel-resident debug hooks -- log?.Invoke("Installing debug hooks (DEBG)..."); if (!ChallengeResponse(port, "DEBG", "OKAY", log)) { @@ -259,12 +259,12 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe } log?.Invoke("Debug hooks installed."); - // Drain + settle — Unirom may send extra bytes after DEBG + // Drain + settle - Unirom may send extra bytes after DEBG Thread.Sleep(100); while (port.BytesToRead > 0) port.ReadByte(); - // ── Step 2: SBIN — Upload raw program data to target address ── + // -- Step 2: SBIN - Upload raw program data to target address -- log?.Invoke($"Sending SBIN to 0x{destAddr:X8} ({progData.Length} bytes)..."); if (!ChallengeResponse(port, "SBIN", "OKAY", log)) { @@ -295,7 +295,7 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe while (port.BytesToRead > 0) port.ReadByte(); - // ── Step 3: JUMP — Start execution at entry point ── + // -- Step 3: JUMP - Start execution at entry point -- log?.Invoke($"Sending JUMP to 0x{entryPoint:X8}..."); if (!ChallengeResponse(port, "JUMP", "OKAY", log)) { @@ -321,9 +321,9 @@ private static SerialPort DoUploadSBIN(string portName, int baudRate, string exe } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Challenge / Response with protocol negotiation - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static bool ChallengeResponse(SerialPort port, string challenge, string expectedResponse, Action log) { @@ -352,7 +352,7 @@ private static bool WaitResponse(SerialPort port, string expected, Action1 from a prior DEBG exchange. if (buffer == "OKV3") { @@ -395,9 +395,9 @@ private static bool WaitResponse(SerialPort port, string expected, Action log) { @@ -441,7 +441,7 @@ private static bool WriteChunked(SerialPort port, byte[] data, bool skipFirstSec return false; } if (retry) - continue; // Don't advance offset — resend this chunk + continue; // Don't advance offset - resend this chunk } offset += thisChunk; @@ -505,7 +505,7 @@ private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[] if (cmdBuffer == "ERR!") { - log?.Invoke("Checksum error — retrying chunk..."); + log?.Invoke("Checksum error - retrying chunk..."); retry = true; return true; } @@ -520,9 +520,9 @@ private static bool HandleChunkAck(SerialPort port, ulong chunkChecksum, byte[] return false; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Checksum calculation - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static uint CalculateChecksum(byte[] data, bool skipFirstSector) { diff --git a/Editor/Inspectors/PSXComponentEditors2.cs b/Editor/Inspectors/PSXComponentEditors2.cs index 3e2e57b..5068b99 100644 --- a/Editor/Inspectors/PSXComponentEditors2.cs +++ b/Editor/Inspectors/PSXComponentEditors2.cs @@ -32,6 +32,8 @@ public override void OnInspectorGUI() if (audioClip.Loop) PSXEditorStyles.DrawStatusBadge("Loop", PSXEditorStyles.AccentCyan, 50); + if (audioClip.TrimLeadingSilence) + PSXEditorStyles.DrawStatusBadge("Trimmed", PSXEditorStyles.Warning, 70); EditorGUILayout.EndHorizontal(); PSXEditorStyles.EndCard(); @@ -53,6 +55,16 @@ public override void OnInspectorGUI() "Whether this clip should loop when played.")); EditorGUILayout.PropertyField(serializedObject.FindProperty("DefaultVolume"), new GUIContent("Volume", "Default playback volume (0-127).")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("TrimLeadingSilence"), + new GUIContent("Trim Leading Silence", + "Cut silence off the START of the clip at export.\n\n" + + "Off by default: this rewrites your asset on the way to the disc, " + + "and a lead-in you put there on purpose is not the exporter's to remove.\n\n" + + "Turn it on for a clip whose file begins with dead air. That silence " + + "is latency between the frame that asks for the sound and the frame " + + "you hear it, and it costs SPU RAM for the whole round.\n\n" + + "The tail is always kept - on a looping clip it is the interval the " + + "sound repeats at.")); PSXEditorStyles.EndCard(); @@ -112,9 +124,9 @@ public override void OnInspectorGUI() serializedObject.Update(); PSXPlayer player = (PSXPlayer)target; - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Header - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- PSXEditorStyles.BeginCard(); EditorGUILayout.LabelField("PSX Player", PSXEditorStyles.CardHeaderStyle); EditorGUILayout.LabelField("First-person player controller for PS1", PSXEditorStyles.RichLabel); @@ -122,9 +134,9 @@ public override void OnInspectorGUI() EditorGUILayout.Space(4); - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Player Dimensions - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- _dimensionsFoldout = PSXEditorStyles.DrawFoldoutCard("Player Dimensions", _dimensionsFoldout, () => { EditorGUILayout.PropertyField(serializedObject.FindProperty("playerHeight"), new GUIContent("Height", @@ -135,9 +147,9 @@ public override void OnInspectorGUI() EditorGUILayout.Space(2); - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Movement - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- _movementFoldout = PSXEditorStyles.DrawFoldoutCard("Movement", _movementFoldout, () => { EditorGUILayout.PropertyField(serializedObject.FindProperty("moveSpeed"), new GUIContent("Walk Speed", @@ -148,9 +160,9 @@ public override void OnInspectorGUI() EditorGUILayout.Space(2); - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Navigation (fully merged from PSXNavRegionEditor) - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- _navigationFoldout = PSXEditorStyles.DrawFoldoutCard("Navigation", _navigationFoldout, () => { EditorGUILayout.LabelField( @@ -167,7 +179,7 @@ public override void OnInspectorGUI() EditorGUILayout.Space(6); - // ── Agent settings ── + // -- Agent settings -- EditorGUILayout.LabelField("Agent", EditorStyles.boldLabel); EditorGUILayout.PropertyField(serializedObject.FindProperty("maxStepHeight"), new GUIContent("Max Step Height", "Maximum vertical step the agent can climb. If your stairs are taller than this, the navmesh won't connect across them.")); @@ -176,7 +188,7 @@ public override void OnInspectorGUI() PSXEditorStyles.DrawSeparator(4, 4); - // ── Voxelization ── + // -- Voxelization -- EditorGUILayout.LabelField("Voxelization", EditorStyles.boldLabel); EditorGUILayout.LabelField( "Controls the 3D grid resolution used to analyze your geometry. " + @@ -208,7 +220,7 @@ public override void OnInspectorGUI() PSXEditorStyles.DrawSeparator(4, 4); - // ── Region Partitioning ── + // -- Region Partitioning -- EditorGUILayout.LabelField("Region Partitioning", EditorStyles.boldLabel); EditorGUILayout.LabelField( "Controls how walkable voxels are grouped into convex regions. " + @@ -234,7 +246,7 @@ public override void OnInspectorGUI() PSXEditorStyles.DrawSeparator(4, 4); - // ── Contour & Edge ── + // -- Contour & Edge -- EditorGUILayout.LabelField("Contour & Edge", EditorStyles.boldLabel); EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxSimplifyError"), new GUIContent("Max Simplify Error", "Maximum distance (world units) that polygon edges can deviate from the voxelized boundary during contour simplification. " + @@ -245,18 +257,18 @@ public override void OnInspectorGUI() "Longer edges reduce region count.\n\n" + "For terrain: 4\u20136. For indoors: 8\u201316.")); - // ── Advanced detail settings ── + // -- Advanced detail settings -- DrawAdvancedNavSection(player); - // ── Presets ── + // -- Presets -- DrawPresetsSection(player); }); EditorGUILayout.Space(2); - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Jump & Gravity - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- _physicsFoldout = PSXEditorStyles.DrawFoldoutCard("Jump & Gravity", _physicsFoldout, () => { EditorGUILayout.PropertyField(serializedObject.FindProperty("jumpHeight"), new GUIContent("Jump Height", @@ -269,9 +281,9 @@ public override void OnInspectorGUI() EditorGUILayout.Space(4); - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- // Nav Region Preview (build, visualize, stats, validation) - // ──────────────────────────────────────────────────────── + // -------------------------------------------------------- DrawNavPreviewSection(player); } diff --git a/Editor/Inspectors/PSXUIEditors.cs b/Editor/Inspectors/PSXUIEditors.cs index c8ddff2..3ba9bb2 100644 --- a/Editor/Inspectors/PSXUIEditors.cs +++ b/Editor/Inspectors/PSXUIEditors.cs @@ -25,13 +25,20 @@ static void DrawCanvasGizmo(PSXCanvas canvas, GizmoType gizmoType) Color border = canvasSelected ? Color.yellow : new Color(1, 1, 0, 0.3f); Handles.DrawSolidRectangleWithOutline(canvasCorners, Color.clear, border); - // Draw all children in hierarchy order (first child = back, last child = front) - var children = canvas.GetComponentsInChildren(true).Reverse(); + // Hierarchy order, NOT reversed: the first child is behind and the + // last child is in front, which is Unity's own convention and (since + // UISystem::renderOT was fixed to walk its arrays backwards) the + // console's too. This used to be `.Reverse()` to match an engine that + // had the ordering-table insertion the wrong way round. + var children = canvas.GetComponentsInChildren(true); foreach (var child in children) { if (child == canvas.transform) continue; bool childSelected = Selection.Contains(child.gameObject); + var sprite = child.GetComponent(); + if (sprite != null) { DrawSprite(sprite, childSelected); continue; } + var box = child.GetComponent(); if (box != null) { DrawBox(box, childSelected); continue; } @@ -135,6 +142,31 @@ static void DrawImage(PSXUIImage image, bool selected) } } + /// + /// One cell of a sheet, in the scene view. The tint goes through + /// PSXScreenPreview.TexTint so a 128-grey element looks like the art + /// rather than half-dark - see the note there. + /// + static void DrawSprite(PSXUISprite sprite, bool selected) + { + RectTransform rt = sprite.GetComponent(); + if (rt == null) return; + Vector3[] corners = new Vector3[4]; + rt.GetWorldCorners(corners); + + Handles.BeginGUI(); + Vector2 min = HandleUtility.WorldToGUIPoint(corners[1]); // top-left + Vector2 max = HandleUtility.WorldToGUIPoint(corners[3]); // bottom-right + var 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 > 1 && screenRect.height > 1) + PSXScreenPreview.DrawSprite(sprite, screenRect, selected ? 1f : 0.9f); + Handles.EndGUI(); + + if (selected) + Handles.DrawSolidRectangleWithOutline(corners, Color.clear, Color.cyan); + } + static void DrawText(PSXUIText text, bool selected) { RectTransform rt = text.GetComponent(); @@ -296,20 +328,49 @@ public override void OnInspectorGUI() // Element summary card PSXCanvas canvas = (PSXCanvas)target; int imageCount = canvas.GetComponentsInChildren(true).Length; + int spriteCount = canvas.GetComponentsInChildren(true).Length; int boxCount = canvas.GetComponentsInChildren(true).Length; int textCount = canvas.GetComponentsInChildren(true).Length; int progressCount = canvas.GetComponentsInChildren(true).Length; - int total = imageCount + boxCount + textCount + progressCount; + int lineCount = canvas.GetComponentsInChildren(true).Length; + int total = imageCount + spriteCount + boxCount + textCount + progressCount + lineCount; PSXEditorStyles.BeginCard(); EditorGUILayout.LabelField( - $"Elements: {total} total\n" + - $" Images: {imageCount} | Boxes: {boxCount}\n" + - $" Texts: {textCount} | Progress Bars: {progressCount}", + $"Elements: {total} in this canvas\n" + + $" Sprites: {spriteCount} | Images: {imageCount} | Boxes: {boxCount}\n" + + $" Texts: {textCount} | Progress Bars: {progressCount} | Lines: {lineCount}", + PSXEditorStyles.InfoBox); + + // The pool is shared by every canvas in the scene, so this canvas's + // own count says nothing about whether the scene fits. Elements past + // the cap are dropped SILENTLY at load (uisystem.cpp clamps the count), + // which reads as "the bottom half of my panel did not export". + int sceneTotal = 0; + var allCanvases = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (var c in allCanvases) + { + sceneTotal += c.GetComponentsInChildren(true).Length + + c.GetComponentsInChildren(true).Length + + c.GetComponentsInChildren(true).Length + + c.GetComponentsInChildren(true).Length + + c.GetComponentsInChildren(true).Length + + c.GetComponentsInChildren(true).Length; + } + EditorGUILayout.LabelField( + $"Scene: {sceneTotal} / {PSXUIBudget.MaxElements} elements, " + + $"{allCanvases.Length} / {PSXUIBudget.MaxCanvases} canvases", PSXEditorStyles.InfoBox); - if (total > 128) - EditorGUILayout.LabelField("PS1 UI system supports max 128 elements total across all canvases.", PSXEditorStyles.InfoBox); + if (sceneTotal > PSXUIBudget.MaxElements) + EditorGUILayout.HelpBox( + $"The scene has {sceneTotal} UI elements but the runtime pool holds " + + $"{PSXUIBudget.MaxElements}. Everything past the cap is dropped at load, " + + "without an error.", MessageType.Error); + if (allCanvases.Length > PSXUIBudget.MaxCanvases) + EditorGUILayout.HelpBox( + $"The scene has {allCanvases.Length} canvases but the runtime pool holds " + + $"{PSXUIBudget.MaxCanvases}.", MessageType.Error); PSXEditorStyles.EndCard(); serializedObject.ApplyModifiedProperties(); diff --git a/Editor/Inspectors/PSXUISpriteEditor.cs b/Editor/Inspectors/PSXUISpriteEditor.cs new file mode 100644 index 0000000..a56dacf --- /dev/null +++ b/Editor/Inspectors/PSXUISpriteEditor.cs @@ -0,0 +1,144 @@ +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Inspector for : a cell picker rather than an int + /// field. + /// + /// + /// Typing "17" into a Cell field and rebuilding a disc to find out whether 17 + /// is the chevron or the lamp is the authoring loop this component exists to + /// end. So the sheet is drawn, the current cell is highlighted, and clicking a + /// cell picks it. + /// + [CustomEditor(typeof(PSXUISprite))] + [CanEditMultipleObjects] + public class PSXUISpriteEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + + PSXEditorStyles.BeginCard(); + EditorGUILayout.LabelField("PSX UI Sprite", PSXEditorStyles.CardHeaderStyle); + PSXEditorStyles.EndCard(); + + EditorGUILayout.Space(4); + + PSXEditorStyles.BeginCard(); + EditorGUILayout.PropertyField(serializedObject.FindProperty("elementName"), new GUIContent( + "Element Name", "Name used from Lua: UI.FindElement(canvas, \"name\"). Max 24 chars.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("sheet"), new GUIContent( + "Sprite Sheet", "The sheet this cell comes from. Packed into VRAM once, however " + + "many elements draw from it.")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("cell"), new GUIContent( + "Cell", "Cell index, left-to-right then top-to-bottom from 0. " + + "Change at run time with UI.SetFrame().")); + + SerializedProperty tint = serializedObject.FindProperty("tint"); + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.PropertyField(tint, new GUIContent( + "Tint", "The PS1 computes texel * colour / 128, so 128 grey is NO tint " + + "and white is double brightness.")); + if (GUILayout.Button(new GUIContent("128", "Reset to the tint identity (no colour change)."), + EditorStyles.miniButton, GUILayout.Width(34))) + tint.colorValue = PSXUISprite.TintIdentity; + } + + EditorGUILayout.PropertyField(serializedObject.FindProperty("startVisible")); + PSXEditorStyles.EndCard(); + + var spr = (PSXUISprite)target; + + if (!serializedObject.isEditingMultipleObjects && spr.Sheet != null) + { + EditorGUILayout.Space(4); + PSXEditorStyles.BeginCard(); + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField("Pick a cell", PSXEditorStyles.CardHeaderStyle); + if (GUILayout.Button(new GUIContent("Snap size", + "Resize the RectTransform to the sheet's cell size."), + EditorStyles.miniButton, GUILayout.Width(74))) + { + Undo.RecordObject(spr.GetComponent(), "Snap to cell size"); + spr.SnapToCellSize(); + } + } + PSXEditorStyles.DrawSeparator(2, 4); + DrawCellPicker(spr); + PSXEditorStyles.EndCard(); + } + + if (spr.Sheet == null) + { + EditorGUILayout.Space(4); + EditorGUILayout.HelpBox( + "No sheet assigned. This element still exports and Lua can still find it, " + + "but it draws whatever happens to be at UV (0,0) in the atlas.", + MessageType.Warning); + } + + serializedObject.ApplyModifiedProperties(); + } + + /// The sheet drawn as a clickable grid, current cell highlighted. + private void DrawCellPicker(PSXUISprite spr) + { + PSXSpriteSheet sheet = spr.Sheet; + Texture2D tex = sheet.SourceTexture; + if (tex == null) + { + EditorGUILayout.HelpBox("The sheet has no source texture.", MessageType.Warning); + return; + } + + int cols = Mathf.Max(1, sheet.Columns); + int rows = Mathf.Max(1, sheet.Rows); + + float avail = EditorGUIUtility.currentViewWidth - 60f; + // Whole-number zoom only: a tile grid at 1.37x has seams in it, and + // the seam looks exactly like a one-pixel gap in the art. + int zoom = Mathf.Max(1, Mathf.FloorToInt(avail / Mathf.Max(1, tex.width))); + float w = tex.width * zoom, h = tex.height * zoom; + + Rect area = GUILayoutUtility.GetRect(w, h, GUILayout.ExpandWidth(false)); + EditorGUI.DrawRect(area, new Color(0.1f, 0.1f, 0.12f, 1f)); + GUI.DrawTexture(area, tex, ScaleMode.StretchToFill, true); + + float cw = area.width / cols, ch = area.height / rows; + + Handles.BeginGUI(); + Handles.color = new Color(1f, 1f, 1f, 0.15f); + for (int c = 1; c < cols; c++) + Handles.DrawLine(new Vector3(area.x + c * cw, area.y), new Vector3(area.x + c * cw, area.yMax)); + for (int r = 1; r < rows; r++) + Handles.DrawLine(new Vector3(area.x, area.y + r * ch), new Vector3(area.xMax, area.y + r * ch)); + + int cur = spr.ResolvedCell; + var sel = new Rect(area.x + (cur % cols) * cw, area.y + (cur / cols) * ch, cw, ch); + Handles.color = Color.cyan; + Handles.DrawAAPolyLine(2f, + new Vector3(sel.xMin, sel.yMin), new Vector3(sel.xMax, sel.yMin), + new Vector3(sel.xMax, sel.yMax), new Vector3(sel.xMin, sel.yMax), new Vector3(sel.xMin, sel.yMin)); + Handles.EndGUI(); + + Event e = Event.current; + if (e.type == EventType.MouseDown && e.button == 0 && area.Contains(e.mousePosition)) + { + int cx = Mathf.Clamp(Mathf.FloorToInt((e.mousePosition.x - area.x) / cw), 0, cols - 1); + int cy = Mathf.Clamp(Mathf.FloorToInt((e.mousePosition.y - area.y) / ch), 0, rows - 1); + serializedObject.FindProperty("cell").intValue = cy * cols + cx; + e.Use(); + } + + EditorGUILayout.LabelField( + $"Cell {cur} of {sheet.CellCount} ({sheet.CellWidth}x{sheet.CellHeight}px, {cols}x{rows} grid)", + PSXEditorStyles.InfoBox); + } + } +} diff --git a/Editor/Inspectors/PSXUISpriteEditor.cs.meta b/Editor/Inspectors/PSXUISpriteEditor.cs.meta new file mode 100644 index 0000000..b9c6be8 --- /dev/null +++ b/Editor/Inspectors/PSXUISpriteEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20f9b0e9364ba79fa19a96880a378b04 \ No newline at end of file diff --git a/Editor/PSXAgentEditor.cs b/Editor/PSXAgentEditor.cs new file mode 100644 index 0000000..fa1edb6 --- /dev/null +++ b/Editor/PSXAgentEditor.cs @@ -0,0 +1,276 @@ +using UnityEditor; +using UnityEngine; +using SplashEdit.RuntimeCode; + +namespace SplashEdit.EditorCode +{ + [CustomEditor(typeof(PSXAgent))] + [CanEditMultipleObjects] + public class PSXAgentEditor : UnityEditor.Editor + { + private SerializedProperty _startEnabledProp; + private SerializedProperty _moveSpeedProp; + private SerializedProperty _stopDistanceProp; + private SerializedProperty _hasVisionProp; + private SerializedProperty _visionRangeProp; + private SerializedProperty _visionFovDegreesProp; + private SerializedProperty _visionRegionDepthProp; + private SerializedProperty _hasHearingProp; + private SerializedProperty _hearingRangeProp; + private SerializedProperty _alertTimeoutFramesProp; + private SerializedProperty _patrolWaypointsProp; + private SerializedProperty _stateAnimsProp; + + private bool _moveFoldout = true; + private bool _visionFoldout = true; + private bool _hearFoldout = true; + private bool _alertFoldout = false; + private bool _patrolFoldout = true; + private bool _animFoldout = false; + private bool _luaFoldout = false; + private bool _runtimeFoldout = false; + + private static readonly string[] s_stateLabels = + { "Idle","Patrol","Seek","Flee","Attack","Wander","Investigate","Custom" }; + + private void OnEnable() + { + _startEnabledProp = serializedObject.FindProperty("startEnabled"); + _moveSpeedProp = serializedObject.FindProperty("moveSpeed"); + _stopDistanceProp = serializedObject.FindProperty("stopDistance"); + _hasVisionProp = serializedObject.FindProperty("hasVision"); + _visionRangeProp = serializedObject.FindProperty("visionRange"); + _visionFovDegreesProp = serializedObject.FindProperty("visionFovDegrees"); + _visionRegionDepthProp = serializedObject.FindProperty("visionRegionDepth"); + _hasHearingProp = serializedObject.FindProperty("hasHearing"); + _hearingRangeProp = serializedObject.FindProperty("hearingRange"); + _alertTimeoutFramesProp = serializedObject.FindProperty("alertTimeoutFrames"); + _patrolWaypointsProp = serializedObject.FindProperty("patrolWaypoints"); + _stateAnimsProp = serializedObject.FindProperty("stateAnims"); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + var agent = (PSXAgent)target; + var exporter = agent.GetComponent(); + var sceneExp = FindFirstObjectByType(); + var navSettings = FindFirstObjectByType(); + var player = FindFirstObjectByType(); + + DrawHeader(agent, exporter); + EditorGUILayout.Space(4); + + _moveFoldout = PSXEditorStyles.DrawFoldoutCard("Movement", _moveFoldout, () => + { + EditorGUILayout.PropertyField(_startEnabledProp, new GUIContent("Start Enabled")); + EditorGUILayout.Slider(_moveSpeedProp, 0.01f, 12f, new GUIContent("Move Speed (u/s)")); + EditorGUILayout.Slider(_stopDistanceProp, 0f, 2f, new GUIContent("Stop Distance (u)")); + EditorGUILayout.Space(2); + EditorGUILayout.LabelField( + "Speed converts to fp12 per-frame at 30 fps. Same scale as PSXPlayer walk speed.", + PSXEditorStyles.RichLabel); + }); + EditorGUILayout.Space(2); + + _visionFoldout = PSXEditorStyles.DrawFoldoutCard("Vision", _visionFoldout, () => + { + EditorGUILayout.PropertyField(_hasVisionProp, new GUIContent("Enable Vision")); + using (new EditorGUI.DisabledScope(!_hasVisionProp.boolValue)) + { + EditorGUILayout.Slider(_visionRangeProp, 0f, 30f, new GUIContent("Range (u)")); + EditorGUILayout.Slider(_visionFovDegreesProp, 1f, 360f, + new GUIContent("FOV (degrees, full cone)", + "Total angular width of the vision cone. 90 = 45 each side of forward.")); + EditorGUILayout.IntSlider(_visionRegionDepthProp, 0, 8, + new GUIContent("Region Hop Depth", + "Max nav-region portal hops for portal-graph LOS. 0=same region only. 2-3 covers most interiors.")); + float fov = _visionFovDegreesProp != null ? _visionFovDegreesProp.floatValue : 90f; + int fp12 = UnityEngine.Mathf.Clamp(UnityEngine.Mathf.RoundToInt(UnityEngine.Mathf.Cos(fov * 0.5f * UnityEngine.Mathf.Deg2Rad) * 4096f), -4096, 4096); + EditorGUILayout.LabelField("visionCosAngle (fp12)", fp12.ToString(), EditorStyles.miniLabel); + } + EditorGUILayout.Space(2); + EditorGUILayout.LabelField("Yellow cone arc shown in Scene view when selected.", PSXEditorStyles.RichLabel); + }); + EditorGUILayout.Space(2); + + _hearFoldout = PSXEditorStyles.DrawFoldoutCard("Hearing", _hearFoldout, () => + { + EditorGUILayout.PropertyField(_hasHearingProp, new GUIContent("Enable Hearing")); + using (new EditorGUI.DisabledScope(!_hasHearingProp.boolValue)) + { + EditorGUILayout.Slider(_hearingRangeProp, 0f, 30f, new GUIContent("Range (u)")); + EditorGUILayout.LabelField("Pure range check, no geometry occlusion. Cyan disc in Scene view.", PSXEditorStyles.RichLabel); + } + }); + EditorGUILayout.Space(2); + + _alertFoldout = PSXEditorStyles.DrawFoldoutCard("Alert", _alertFoldout, () => + { + if (_alertTimeoutFramesProp != null) + { + EditorGUILayout.PropertyField(_alertTimeoutFramesProp, + new GUIContent("Alert Timeout (frames)", + "Frames the agent stays alert after losing sight/sound before onTargetLost fires. 60=2s@30fps.")); + float secs = _alertTimeoutFramesProp.intValue / 30f; + EditorGUILayout.LabelField($"approx {secs:F1} s at 30 fps", EditorStyles.miniLabel); + } + }); + EditorGUILayout.Space(2); + + _patrolFoldout = PSXEditorStyles.DrawFoldoutCard("Patrol Waypoints", _patrolFoldout, () => + { + if (_patrolWaypointsProp == null) return; + int count = _patrolWaypointsProp.arraySize; + EditorGUILayout.LabelField($"{count} / 8 waypoints", EditorStyles.miniLabel); + for (int i = 0; i < count; i++) + { + EditorGUILayout.BeginHorizontal(); + var elem = _patrolWaypointsProp.GetArrayElementAtIndex(i); + EditorGUILayout.PropertyField(elem, new GUIContent($"[{i}]")); + using (new EditorGUI.DisabledScope(i == 0)) + if (GUILayout.Button("^", GUILayout.Width(22))) + _patrolWaypointsProp.MoveArrayElement(i, i - 1); + using (new EditorGUI.DisabledScope(i >= count - 1)) + if (GUILayout.Button("v", GUILayout.Width(22))) + _patrolWaypointsProp.MoveArrayElement(i, i + 1); + if (GUILayout.Button("X", GUILayout.Width(22))) + { _patrolWaypointsProp.DeleteArrayElementAtIndex(i); break; } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.Space(2); + using (new EditorGUI.DisabledScope(count >= 8)) + { + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("+ Agent Position")) + { + _patrolWaypointsProp.arraySize++; + _patrolWaypointsProp.GetArrayElementAtIndex(_patrolWaypointsProp.arraySize - 1).vector3Value = agent.transform.position; + } + if (GUILayout.Button("+ World Origin")) + { + _patrolWaypointsProp.arraySize++; + _patrolWaypointsProp.GetArrayElementAtIndex(_patrolWaypointsProp.arraySize - 1).vector3Value = Vector3.zero; + } + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.Space(2); + EditorGUILayout.LabelField( + "Exported as absolute world-space fp12 positions (12 bytes each). Sets AGENT_FLAG_HAS_PATROL. Draggable handles in Scene view.", + PSXEditorStyles.RichLabel); + }); + EditorGUILayout.Space(2); + + _animFoldout = PSXEditorStyles.DrawFoldoutCard("State Animations", _animFoldout, () => + { + EditorGUILayout.LabelField( + "Clip played when the agent enters each state. Leave empty to keep the previous clip. " + + "Resolved to index in PSXSceneExporter Animations list at export time.", + PSXEditorStyles.RichLabel); + EditorGUILayout.Space(4); + if (_stateAnimsProp != null) + { + int len = UnityEngine.Mathf.Min(_stateAnimsProp.arraySize, 8); + for (int i = 0; i < len; i++) + { + var elem = _stateAnimsProp.GetArrayElementAtIndex(i); + var clipProp = elem.FindPropertyRelative("clip"); + if (clipProp != null) + EditorGUILayout.PropertyField(clipProp, + new GUIContent(s_stateLabels[i], $"Clip for {s_stateLabels[i]} state (stateAnimClip[{i}])")); + } + } + }); + EditorGUILayout.Space(2); + + _runtimeFoldout = PSXEditorStyles.DrawFoldoutCard("Runtime Checks", _runtimeFoldout, () => + { + if (sceneExp == null) + EditorGUILayout.HelpBox("No PSXSceneExporter in this scene. Agent will not export.", MessageType.Error); + if (navSettings == null && player == null) + EditorGUILayout.HelpBox("No PSXNavigationSettings or PSXPlayer found. Agents need nav regions to pathfind.", MessageType.Error); + else if (navSettings != null && player == null) + EditorGUILayout.HelpBox("Nav regions baked from PSXNavigationSettings. Agents run without a player.", MessageType.Info); + else if (player != null) + EditorGUILayout.HelpBox("PSXPlayer present - agents share the scene nav data.", MessageType.Info); + if (exporter != null && exporter.LuaFile == null) + EditorGUILayout.HelpBox("No Lua script assigned. Agent can be driven by scene scripts or cutscenes.", MessageType.None); + int wpCount = agent.PatrolWaypoints != null ? agent.PatrolWaypoints.Count : 0; + EditorGUILayout.LabelField("Export Cost", $"{28 + wpCount * 12} bytes (28 struct + {wpCount}x12 waypoints)", EditorStyles.miniLabel); + EditorGUILayout.LabelField("Movement Model", "Native nav-region path following", EditorStyles.miniLabel); + EditorGUILayout.Space(4); + EditorGUILayout.BeginHorizontal(); + if (sceneExp != null && GUILayout.Button("Scene Exporter", PSXEditorStyles.SecondaryButton)) + Selection.activeGameObject = sceneExp.gameObject; + if (navSettings != null && GUILayout.Button("Nav Settings", PSXEditorStyles.SecondaryButton)) + Selection.activeGameObject = navSettings.gameObject; + else if (player != null && GUILayout.Button("PSX Player", PSXEditorStyles.SecondaryButton)) + Selection.activeGameObject = player.gameObject; + EditorGUILayout.EndHorizontal(); + }); + EditorGUILayout.Space(2); + + _luaFoldout = PSXEditorStyles.DrawFoldoutCard("Lua Quick Start", _luaFoldout, () => + { + EditorGUILayout.LabelField( + "Events: onStateEnter(newState,oldState) onStateExit(state) onTargetSeen(target) " + + "onTargetLost(target) onTargetReached() onPatrolPoint(idx) onPathBlocked(). Fire on transitions only.", + PSXEditorStyles.RichLabel); + EditorGUILayout.Space(4); + EditorGUILayout.SelectableLabel( + "-- AGENT_IDLE AGENT_PATROL AGENT_SEEK AGENT_FLEE\n" + + "-- AGENT_ATTACK AGENT_WANDER AGENT_INVESTIGATE AGENT_CUSTOM\n\n" + + "function self:onTargetSeen(target)\n" + + " Agent.SetState(self, AGENT_SEEK)\n" + + "end\n\n" + + "function self:onTargetLost(target)\n" + + " Agent.SetState(self, AGENT_PATROL)\n" + + "end\n\n" + + "function self:onStateEnter(newState, oldState)\n" + + " -- react to state change\n" + + "end", + EditorStyles.textArea, GUILayout.MinHeight(140)); + if (exporter != null && exporter.LuaFile != null && + GUILayout.Button("Open Lua Script", PSXEditorStyles.SecondaryButton)) + AssetDatabase.OpenAsset(exporter.LuaFile); + }); + + serializedObject.ApplyModifiedProperties(); + } + + private void OnSceneGUI() + { + var agent = (PSXAgent)target; + var wpProp = serializedObject.FindProperty("patrolWaypoints"); + if (wpProp == null || wpProp.arraySize == 0) return; + serializedObject.Update(); + bool changed = false; + Handles.color = new Color(0.3f, 1f, 0.4f, 0.9f); + for (int i = 0; i < wpProp.arraySize; i++) + { + var elem = wpProp.GetArrayElementAtIndex(i); + Vector3 pos = elem.vector3Value; + EditorGUI.BeginChangeCheck(); + Vector3 newPos = Handles.PositionHandle(pos, Quaternion.identity); + if (EditorGUI.EndChangeCheck()) { elem.vector3Value = newPos; changed = true; } + Handles.Label(pos + Vector3.up * 0.55f, $"WP {i}", EditorStyles.miniLabel); + } + if (changed) serializedObject.ApplyModifiedProperties(); + } + + private static void DrawHeader(PSXAgent agent, PSXObjectExporter exporter) + { + PSXEditorStyles.BeginCard(); + EditorGUILayout.LabelField("PSX Agent", PSXEditorStyles.CardHeaderStyle); + EditorGUILayout.LabelField( + "Native nav agent with vision, hearing, patrol waypoints and per-state animations. " + + "Serialized as SPLASHPACKAgentV2 (28 bytes + waypoints).", + PSXEditorStyles.RichLabel); + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("GameObject", agent.gameObject.name, EditorStyles.miniLabel); + EditorGUILayout.LabelField("Exporter", exporter != null ? "Attached" : "Missing", EditorStyles.miniLabel); + PSXEditorStyles.EndCard(); + } + } +} diff --git a/Editor/PSXAgentEditor.cs.meta b/Editor/PSXAgentEditor.cs.meta new file mode 100644 index 0000000..b457e78 --- /dev/null +++ b/Editor/PSXAgentEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 623b9b02fc77f094897657badad3cb1f \ No newline at end of file diff --git a/Editor/PSXMenuItems.cs b/Editor/PSXMenuItems.cs index e2fae1d..1b56903 100644 --- a/Editor/PSXMenuItems.cs +++ b/Editor/PSXMenuItems.cs @@ -6,14 +6,14 @@ namespace SplashEdit.EditorCode { /// - /// Minimal menu items — everything goes through the unified Control Panel. + /// Minimal menu items - everything goes through the unified Control Panel. /// Only keeps: Control Panel shortcut + GameObject creation helpers. /// public static class PSXMenuItems { private const string MENU_ROOT = "PlayStation 1/"; - // ───── Main Entry Point ───── + // ----- Main Entry Point ----- [MenuItem(MENU_ROOT + "SplashEdit Control Panel %#l", false, 0)] public static void OpenControlPanel() @@ -27,7 +27,7 @@ public static void OpenAboutWindow() PSXAboutWindow.ShowWindow(); } - // ───── GameObject Menu ───── + // ----- GameObject Menu ----- [MenuItem("GameObject/PlayStation 1/Scene Exporter", false, 10)] public static void CreateSceneExporter(MenuCommand menuCommand) @@ -61,7 +61,7 @@ public static void CreateExportableObject(MenuCommand menuCommand) Selection.activeGameObject = go; } - // ───── Context Menu ───── + // ----- Context Menu ----- [MenuItem("CONTEXT/MeshFilter/Add PSX Object Exporter")] public static void AddPSXObjectExporterFromMesh(MenuCommand command) diff --git a/Editor/PSXNavRegionEditor.cs b/Editor/PSXNavRegionEditor.cs index aa2f3ba..838ac19 100644 --- a/Editor/PSXNavRegionEditor.cs +++ b/Editor/PSXNavRegionEditor.cs @@ -6,27 +6,36 @@ namespace SplashEdit.EditorCode { /// /// Legacy editor window kept for menu-bar convenience. - /// All navigation settings and the build/preview workflow have been merged - /// into the PSXPlayer inspector. This window simply selects the PSXPlayer - /// so users who click the old menu item land on the right place. + /// Navigation can now live on a dedicated PSXNavigationSettings component. + /// This window simply selects the most relevant scene object so users who + /// click the old menu item land on the right place. /// public class PSXNavRegionEditor : EditorWindow { [MenuItem("PlayStation 1/Nav Region Builder")] public static void ShowWindow() { - // Find the PSXPlayer in scene and select it so the inspector shows nav settings + var navSettings = FindObjectsByType(FindObjectsSortMode.None); + if (navSettings.Length > 0) + { + Selection.activeGameObject = navSettings[0].gameObject; + EditorGUIUtility.PingObject(navSettings[0]); + Debug.Log("[Nav] Selecting PSXNavigationSettings."); + return; + } + + // Fallback to the legacy PSXPlayer-hosted nav settings. var players = FindObjectsByType(FindObjectsSortMode.None); if (players.Length > 0) { Selection.activeGameObject = players[0].gameObject; EditorGUIUtility.PingObject(players[0]); - Debug.Log("[Nav] Navigation settings are now on the PSXPlayer inspector. Selecting PSXPlayer."); + Debug.Log("[Nav] Selecting legacy PSXPlayer navigation settings."); } else { EditorUtility.DisplayDialog("Nav Region Builder", - "No PSXPlayer found in the scene.\n\nAdd a PSXPlayer component to a GameObject to configure navigation settings.", + "No PSXNavigationSettings or PSXPlayer found in the scene.\n\nAdd a PSXNavigationSettings component for scene-level navigation, or use the legacy PSXPlayer workflow.", "OK"); } } diff --git a/Editor/PSXNavigationSettingsEditor.cs b/Editor/PSXNavigationSettingsEditor.cs new file mode 100644 index 0000000..b1271b8 --- /dev/null +++ b/Editor/PSXNavigationSettingsEditor.cs @@ -0,0 +1,87 @@ +using UnityEditor; +using UnityEngine; +using SplashEdit.RuntimeCode; + +namespace SplashEdit.EditorCode +{ + [CustomEditor(typeof(PSXNavigationSettings))] + public class PSXNavigationSettingsEditor : UnityEditor.Editor + { + private bool _agentFoldout = true; + private bool _voxelFoldout = true; + private bool _advancedFoldout = true; + private bool _presetsFoldout = true; + + public override void OnInspectorGUI() + { + serializedObject.Update(); + var settings = (PSXNavigationSettings)target; + + PSXEditorStyles.BeginCard(); + EditorGUILayout.LabelField("Navigation Settings", PSXEditorStyles.CardHeaderStyle); + EditorGUILayout.LabelField("Scene-level nav bake settings for player and AI agents.", PSXEditorStyles.RichLabel); + PSXEditorStyles.EndCard(); + + EditorGUILayout.Space(4); + + _agentFoldout = PSXEditorStyles.DrawFoldoutCard("Agent", _agentFoldout, () => + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("agentHeight"), new GUIContent("Agent Height")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("agentRadius"), new GUIContent("Agent Radius")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("maxStepHeight"), new GUIContent("Max Step Height")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("walkableSlopeAngle"), new GUIContent("Walkable Slope")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("spawnAnchor"), new GUIContent("Spawn Anchor")); + }); + + EditorGUILayout.Space(2); + + _voxelFoldout = PSXEditorStyles.DrawFoldoutCard("Voxelization", _voxelFoldout, () => + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("navCellSize"), new GUIContent("Cell Size (XZ)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navCellHeight"), new GUIContent("Cell Height (Y)")); + }); + + EditorGUILayout.Space(2); + + _advancedFoldout = PSXEditorStyles.DrawFoldoutCard("Advanced", _advancedFoldout, () => + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("navMinRegionArea"), new GUIContent("Min Region Area")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navMergeRegionArea"), new GUIContent("Merge Region Area")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxSimplifyError"), new GUIContent("Max Simplify Error")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxEdgeLength"), new GUIContent("Max Edge Length")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navPartitionMethod"), new GUIContent("Partition Method")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navDetailSampleDist"), new GUIContent("Detail Sample Dist")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navDetailMaxError"), new GUIContent("Detail Max Error")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("navMaxPlaneError"), new GUIContent("Max Plane Error")); + }); + + EditorGUILayout.Space(2); + + _presetsFoldout = PSXEditorStyles.DrawFoldoutCard("Quick Presets", _presetsFoldout, () => + { + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Indoor", GUILayout.Height(24))) + { + Undo.RecordObject(settings, "Apply Indoor Nav Preset"); + settings.ApplyIndoorPreset(); + EditorUtility.SetDirty(settings); + } + if (GUILayout.Button("Terrain", GUILayout.Height(24))) + { + Undo.RecordObject(settings, "Apply Terrain Nav Preset"); + settings.ApplyTerrainPreset(); + EditorUtility.SetDirty(settings); + } + if (GUILayout.Button("Multi-Level", GUILayout.Height(24))) + { + Undo.RecordObject(settings, "Apply Multi-Level Nav Preset"); + settings.ApplyMultiLevelPreset(); + EditorUtility.SetDirty(settings); + } + EditorGUILayout.EndHorizontal(); + }); + + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Editor/PSXNavigationSettingsEditor.cs.meta b/Editor/PSXNavigationSettingsEditor.cs.meta new file mode 100644 index 0000000..438d5e4 --- /dev/null +++ b/Editor/PSXNavigationSettingsEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 06a68da4a9be63b43b86778ba20f2a3b \ No newline at end of file diff --git a/Editor/PSXSceneExporterEditor.cs b/Editor/PSXSceneExporterEditor.cs index fcea397..8b119c6 100644 --- a/Editor/PSXSceneExporterEditor.cs +++ b/Editor/PSXSceneExporterEditor.cs @@ -14,6 +14,7 @@ public class PSXSceneExporterEditor : UnityEditor.Editor private SerializedProperty fogColorProp; private SerializedProperty fogDensityProp; private SerializedProperty sceneTypeProp; + private SerializedProperty sceneNetworkIdProp; // Added: Networking ID prop private SerializedProperty cutscenesProp; private SerializedProperty animationsProp; private SerializedProperty loadingScreenProp; @@ -22,6 +23,7 @@ public class PSXSceneExporterEditor : UnityEditor.Editor private SerializedProperty bvhDepthProp; private bool showFog = true; + private bool showNetworking = true; // Added: Foldout toggle for Networking private bool showCutscenes = true; private bool showDebug = false; @@ -33,6 +35,7 @@ private void OnEnable() fogColorProp = serializedObject.FindProperty("FogColor"); fogDensityProp = serializedObject.FindProperty("FogDensity"); sceneTypeProp = serializedObject.FindProperty("SceneType"); + sceneNetworkIdProp = serializedObject.FindProperty("SceneNetworkId"); // Added: Find networking prop cutscenesProp = serializedObject.FindProperty("Cutscenes"); animationsProp = serializedObject.FindProperty("Animations"); loadingScreenProp = serializedObject.FindProperty("LoadingScreenPrefab"); @@ -55,6 +58,8 @@ public override void OnInspectorGUI() DrawSceneSettings(); PSXEditorStyles.DrawSeparator(6, 6); + DrawNetworkingSection(); // Added: Draw Networking section + PSXEditorStyles.DrawSeparator(6, 6); DrawFogSection(exporter); PSXEditorStyles.DrawSeparator(6, 6); DrawCutscenesSection(); @@ -65,7 +70,7 @@ public override void OnInspectorGUI() PSXEditorStyles.DrawSeparator(6, 6); DrawDebugSection(); PSXEditorStyles.DrawSeparator(6, 6); - DrawSceneStats(); + DrawSceneStats(exporter); // Updated: Pass exporter to access post-export stats serializedObject.ApplyModifiedProperties(); } @@ -105,6 +110,31 @@ private void DrawSceneSettings() } } + // Added: Dedicated Networking Section + private void DrawNetworkingSection() + { + showNetworking = EditorGUILayout.Foldout(showNetworking, "Networking", true, PSXEditorStyles.FoldoutHeader); + if (!showNetworking) return; + + EditorGUI.indentLevel++; + EditorGUILayout.PropertyField(sceneNetworkIdProp, new GUIContent("Scene Network Id", + "Stable id for this scene on the network. Leave empty to fall back to the derived hash.")); + + if (string.IsNullOrEmpty(sceneNetworkIdProp.stringValue)) + { + EditorGUILayout.LabelField( + "Using derived hash (changes when scene objects change).", + PSXEditorStyles.RichLabel); + } + else + { + EditorGUILayout.LabelField( + $"Locked ID: {sceneNetworkIdProp.stringValue}", + PSXEditorStyles.RichLabel); + } + EditorGUI.indentLevel--; + } + private void DrawFogSection(PSXSceneExporter exporter) { showFog = EditorGUILayout.Foldout(showFog, "Fog & Background", true, PSXEditorStyles.FoldoutHeader); @@ -187,7 +217,8 @@ private void DrawDebugSection() EditorGUI.indentLevel--; } - private void DrawSceneStats() + // Updated: Added post-export triangle count display + private void DrawSceneStats(PSXSceneExporter exporter) { var exporters = FindObjectsByType(FindObjectsSortMode.None); int total = exporters.Length; @@ -200,8 +231,16 @@ private void DrawSceneStats() EditorGUILayout.LabelField( $"{active}/{total} objects | {staticCol} static {dynamicCol} dynamic {triggerBoxes} triggers", PSXEditorStyles.RichLabel); + + // Display Last Export stats if an export has been run + if (exporter.LastExportTriangleCount > 0) + { + EditorGUILayout.LabelField( + $"Last Export: {exporter.LastExportTriangleCount} triangles", + PSXEditorStyles.RichLabel); + } EditorGUILayout.EndVertical(); } } -} +} \ No newline at end of file diff --git a/Editor/PSXScreenOverlay.cs b/Editor/PSXScreenOverlay.cs new file mode 100644 index 0000000..09495dd --- /dev/null +++ b/Editor/PSXScreenOverlay.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEditor.Overlays; +using UnityEngine; +using UnityEngine.UIElements; + +namespace SplashEdit.EditorCode +{ + /// + /// A live 320x240 view of the scene, docked inside the scene view. + /// + /// + /// Toggle it from the scene view's overlay menu (the three dots, top right) + /// under "PSX Screen". + /// + /// It is the scene view's answer to a question the 3D viewport structurally + /// cannot answer: a canvas lives in the XY plane and the tilemap in XZ, so + /// "what will the television show" is not a camera angle. Click an element in + /// here to select it in the hierarchy - which is how you find the one row out + /// of nine that is three pixels too low. + /// + [Overlay(typeof(SceneView), k_Id, "PSX Screen", false)] + public class PSXScreenOverlay : Overlay + { + private const string k_Id = "psxsplash-screen-preview"; + + private IMGUIContainer m_Container; + + public override VisualElement CreatePanelContent() + { + m_Container = new IMGUIContainer(OnGUI); + Resize(); + // The preview must follow the scene, not the repaint clock: a colour + // typed into an inspector should show up here immediately. + m_Container.onGUIHandler = OnGUI; + return m_Container; + } + + private void Resize() + { + if (m_Container == null) return; + Vector2 size = PSXScreenPreview.ScreenSize(PSXScreenPreview.Zoom); + m_Container.style.width = size.x + 8; + m_Container.style.height = size.y + 30; + } + + private void OnGUI() + { + int zoom = PSXScreenPreview.Zoom; + + // --- toolbar --- + using (new EditorGUILayout.HorizontalScope()) + { + int newZoom = EditorGUILayout.IntPopup(zoom, + new[] { "1x", "2x", "3x", "4x" }, new[] { 1, 2, 3, 4 }, + GUILayout.Width(46)); + if (newZoom != zoom) + { + PSXScreenPreview.Zoom = newZoom; + Resize(); + } + + PSXScreenPreview.ShowTilemap = GUILayout.Toggle( + PSXScreenPreview.ShowTilemap, new GUIContent("Map", "Draw the scene's tilemap behind the UI."), + EditorStyles.miniButton, GUILayout.Width(38)); + + PSXScreenPreview.ShowHiddenCanvases = GUILayout.Toggle( + PSXScreenPreview.ShowHiddenCanvases, + new GUIContent("Hidden", "Also draw canvases whose Start Visible is off, dimmed."), + EditorStyles.miniButton, GUILayout.Width(50)); + + PSXScreenPreview.ShowOutlines = GUILayout.Toggle( + PSXScreenPreview.ShowOutlines, + new GUIContent("Bounds", "Outline every element's rect."), + EditorStyles.miniButton, GUILayout.Width(50)); + } + + Vector2 size = PSXScreenPreview.ScreenSize(zoom); + Rect area = GUILayoutUtility.GetRect(size.x, size.y, GUILayout.ExpandWidth(false)); + + List hits = PSXScreenPreview.Draw(area, zoom); + + HandleInput(area, size, hits); + } + + private void HandleInput(Rect area, Vector2 size, List hits) + { + Event e = Event.current; + var screen = new Rect(area.x, area.y, size.x, size.y); + if (!screen.Contains(e.mousePosition)) return; + + // Middle-drag (or alt-drag) scrolls the tilemap window, which is the + // engine's Sprite.SetViewOffset - the same thing the camera does at + // run time. Without it the preview only ever shows the top-left + // corner of a map, which is rarely where the UI has to work. + if (e.type == EventType.MouseDrag && (e.button == 2 || e.alt)) + { + float scale = Mathf.Max(1, PSXScreenPreview.Zoom); + Vector2Int v = PSXScreenPreview.TileView; + PSXScreenPreview.TileView = new Vector2Int( + Mathf.Max(0, v.x - Mathf.RoundToInt(e.delta.x / scale)), + Mathf.Max(0, v.y - Mathf.RoundToInt(e.delta.y / scale))); + e.Use(); + return; + } + + if (e.type == EventType.MouseDown && e.button == 0) + { + // Backwards: the list is back-to-front, so the last match is the + // one actually on top - which is the one the click meant. + for (int i = hits.Count - 1; i >= 0; i--) + { + if (!hits[i].ScreenRect.Contains(e.mousePosition)) continue; + Selection.activeGameObject = hits[i].Target; + EditorGUIUtility.PingObject(hits[i].Target); + e.Use(); + return; + } + } + } + } +} diff --git a/Editor/PSXScreenOverlay.cs.meta b/Editor/PSXScreenOverlay.cs.meta new file mode 100644 index 0000000..4b818f4 --- /dev/null +++ b/Editor/PSXScreenOverlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e1fae22b550e5fc949389c1c5ff7d13 \ No newline at end of file diff --git a/Editor/PSXScreenPreview.cs b/Editor/PSXScreenPreview.cs new file mode 100644 index 0000000..dfae939 --- /dev/null +++ b/Editor/PSXScreenPreview.cs @@ -0,0 +1,345 @@ +using System.Collections.Generic; +using System.Linq; +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Composites the whole PS1 screen - the tilemap floor and every UI canvas + /// over it - into a rect, at exact PS1 pixels. + /// + /// + /// This is the answer to authoring UI blind. A PSXCanvas is a world-space + /// Canvas in the XY plane and a tilemap lies in the XZ plane, so the 3D scene + /// view can never show them together no matter how good the gizmos are - and + /// "together" is exactly the question you have while placing a HUD: does this + /// label sit on top of the corridor, does the task panel cover the prompt bar. + /// + /// Everything here goes through the same code the exporter uses + /// () and mirrors what uisystem.cpp draws, including + /// the two things that are easiest to get wrong by eye: + /// + /// * draw order - lower sortOrder behind, earlier sibling behind; + /// * tint - the PS1 computes texel * colour / 128, so a 128 + /// grey is "no tint" and Unity's white is DOUBLE brightness. + /// + public static class PSXScreenPreview + { + /// One drawn element, kept so the preview can be clicked. + public struct Hit + { + public GameObject Target; + public Rect ScreenRect; // in GUI space + public RectInt PixelRect; // in PS1 pixels + } + + // --- persisted view options ----------------------------------------- + + private const string k_Prefix = "PSXSplash.ScreenPreview."; + + public static bool ShowTilemap + { + get => EditorPrefs.GetBool(k_Prefix + "Tilemap", true); + set => EditorPrefs.SetBool(k_Prefix + "Tilemap", value); + } + + public static bool ShowHiddenCanvases + { + get => EditorPrefs.GetBool(k_Prefix + "Hidden", true); + set => EditorPrefs.SetBool(k_Prefix + "Hidden", value); + } + + public static bool ShowOutlines + { + get => EditorPrefs.GetBool(k_Prefix + "Outlines", false); + set => EditorPrefs.SetBool(k_Prefix + "Outlines", value); + } + + public static int Zoom + { + get => Mathf.Clamp(EditorPrefs.GetInt(k_Prefix + "Zoom", 1), 1, 4); + set => EditorPrefs.SetInt(k_Prefix + "Zoom", Mathf.Clamp(value, 1, 4)); + } + + public static Vector2Int TileView + { + get => new Vector2Int(EditorPrefs.GetInt(k_Prefix + "ViewX", 0), + EditorPrefs.GetInt(k_Prefix + "ViewY", 0)); + set + { + EditorPrefs.SetInt(k_Prefix + "ViewX", value.x); + EditorPrefs.SetInt(k_Prefix + "ViewY", value.y); + } + } + + /// The PS1 clear colour behind everything. Matches a black TV. + private static readonly Color k_Clear = new Color(0.02f, 0.02f, 0.03f, 1f); + + /// + /// texel * colour / 128: 128 grey leaves art alone, 255 doubles it. + /// Applied to every TEXTURED draw so the preview is not systematically + /// half as bright as the console. + /// + public static Color TexTint(Color c) => + new Color(Mathf.Clamp01(c.r * 2f), Mathf.Clamp01(c.g * 2f), Mathf.Clamp01(c.b * 2f), 1f); + + /// + /// Draw the composite. Returns what was drawn, in back-to-front order, so + /// a caller can hit-test a click (walk it backwards to pick the topmost). + /// + public static List Draw(Rect area, int zoom) + { + var hits = new List(); + Vector2 res = PSXCanvas.PSXResolution; + float scale = Mathf.Max(1, zoom); + var screen = new Rect(area.x, area.y, res.x * scale, res.y * scale); + + EditorGUI.DrawRect(screen, k_Clear); + + if (ShowTilemap) + { + var renderer = Object.FindFirstObjectByType(); + if (renderer != null && renderer.tilemap != null) + { + Vector2Int v = TileView; + GUI.BeginClip(screen); + PSXTilemapPreview.DrawScreen(renderer.tilemap, new Rect(0, 0, screen.width, screen.height), + scale, v.x, v.y, res); + GUI.EndClip(); + } + } + + // Ascending sortOrder: the engine draws the lowest first, so the + // highest ends up in front. See UISystem::renderOT. + var canvases = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None) + .OrderBy(c => c.SortOrder) + .ToList(); + + GUI.BeginClip(screen); + var inner = new Rect(0, 0, screen.width, screen.height); + foreach (PSXCanvas canvas in canvases) + { + if (!canvas.StartVisible && !ShowHiddenCanvases) continue; + DrawCanvas(canvas, inner, scale, res, hits, screen.position); + } + GUI.EndClip(); + + // Border last, so it is never painted over. + Handles.BeginGUI(); + Handles.color = new Color(1f, 1f, 1f, 0.4f); + Handles.DrawSolidRectangleWithOutline( + new Vector3[] + { + new Vector3(screen.xMin, screen.yMax), new Vector3(screen.xMin, screen.yMin), + new Vector3(screen.xMax, screen.yMin), new Vector3(screen.xMax, screen.yMax), + }, Color.clear, new Color(1f, 1f, 1f, 0.4f)); + Handles.EndGUI(); + + return hits; + } + + /// The size needs, for laying out the caller. + public static Vector2 ScreenSize(int zoom) + { + Vector2 res = PSXCanvas.PSXResolution; + float scale = Mathf.Max(1, zoom); + return new Vector2(res.x * scale, res.y * scale); + } + + private static void DrawCanvas(PSXCanvas canvas, Rect clip, float scale, Vector2 res, + List hits, Vector2 clipOrigin) + { + RectTransform canvasRect = canvas.GetComponent(); + if (canvasRect == null) return; + + bool dim = !canvas.StartVisible; + + // Hierarchy order: depth-first in sibling order, which is what + // PSXUIExporter collects and therefore what the element array is. + // Earlier = behind, so painting in order is correct. + foreach (Transform t in canvas.GetComponentsInChildren(true)) + { + if (t == canvas.transform) continue; + var rt = t as RectTransform; + if (rt == null) continue; + + RectInt px = PSXUILayout.ScreenRect(rt, canvasRect, res); + var r = new Rect(px.x * scale, px.y * scale, px.width * scale, px.height * scale); + + bool drew = DrawElement(t, rt, canvasRect, r, scale, res, dim); + if (drew) + hits.Add(new Hit { Target = t.gameObject, ScreenRect = new Rect(r.position + clipOrigin, r.size), PixelRect = px }); + } + } + + private static bool DrawElement(Transform t, RectTransform rt, RectTransform canvasRect, + Rect r, float scale, Vector2 res, bool dim) + { + float alpha = dim ? 0.35f : 1f; + + var spr = t.GetComponent(); + if (spr != null) { DrawSprite(spr, r, alpha); Outline(r, Color.cyan); return true; } + + var img = t.GetComponent(); + if (img != null) + { + if (img.SourceTexture != null) + { + Color c = TexTint(img.TintColor); c.a = alpha; + GUI.color = c; + GUI.DrawTexture(r, img.SourceTexture, ScaleMode.StretchToFill); + GUI.color = Color.white; + } + else EditorGUI.DrawRect(r, new Color(0.3f, 0.3f, 0.6f, 0.6f * alpha)); + Outline(r, Color.cyan); + return true; + } + + var box = t.GetComponent(); + if (box != null) + { + // A Box is an untextured primitive: the PS1 uses its colour + // directly, no /128 halving. Do NOT run it through TexTint. + Color c = box.BoxColor; c.a = alpha; + EditorGUI.DrawRect(r, c); + Outline(r, Color.green); + return true; + } + + var bar = t.GetComponent(); + if (bar != null) + { + Color bg = bar.BackgroundColor; bg.a = alpha; + EditorGUI.DrawRect(r, bg); + float f = Mathf.Clamp01(bar.InitialValue / 100f); + if (f > 0f) + { + Color fc = bar.FillColor; fc.a = alpha; + EditorGUI.DrawRect(new Rect(r.x, r.y, r.width * f, r.height), fc); + } + Outline(r, Color.yellow); + return true; + } + + var line = t.GetComponent(); + if (line != null) + { + // A Line's endpoints are absolute PS1 pixels, not its rect - the + // exporter writes Point1/Point2 straight into the record. + Handles.BeginGUI(); + Color c = line.LineColor; c.a = alpha; + Handles.color = c; + Handles.DrawLine(new Vector3(line.Point1.x * scale, line.Point1.y * scale), + new Vector3(line.Point2.x * scale, line.Point2.y * scale)); + Handles.EndGUI(); + return true; + } + + var txt = t.GetComponent(); + if (txt != null) + { + DrawText(txt, r, scale, alpha); + Outline(r, new Color(1f, 1f, 1f, 0.5f)); + return true; + } + + return false; + } + + private static void Outline(Rect r, Color c) + { + if (!ShowOutlines) return; + Handles.BeginGUI(); + Handles.color = new Color(c.r, c.g, c.b, 0.5f); + Handles.DrawAAPolyLine(1f, + new Vector3(r.xMin, r.yMin), new Vector3(r.xMax, r.yMin), + new Vector3(r.xMax, r.yMax), new Vector3(r.xMin, r.yMax), new Vector3(r.xMin, r.yMin)); + Handles.EndGUI(); + } + + /// One cell of a sheet, with the cell's tex coords. + public static void DrawSprite(PSXUISprite spr, Rect r, float alpha = 1f) + { + PSXSpriteSheet sheet = spr.Sheet; + if (sheet == null || sheet.SourceTexture == null) + { + EditorGUI.DrawRect(r, new Color(0.8f, 0.2f, 0.8f, 0.5f * alpha)); + return; + } + + Texture2D tex = sheet.SourceTexture; + RectInt cell = spr.CellRect; + var uv = new Rect((float)cell.x / tex.width, + // CellRect counts down from the top; UVs count up. + 1f - (float)(cell.y + cell.height) / tex.height, + (float)cell.width / tex.width, + (float)cell.height / tex.height); + + Color c = TexTint(spr.Tint); c.a = alpha; + GUI.color = c; + GUI.DrawTextureWithTexCoords(r, tex, uv, true); + GUI.color = Color.white; + } + + /// + /// Text, glyph by glyph with the authored font's advance widths - the same + /// walk UISystem::renderProportionalText does. Drawing it with a + /// Unity label instead is what let a "32 characters wide" row ship running + /// off the right of the screen. + /// + public static void DrawText(PSXUIText txt, Rect r, float scale, float alpha = 1f) + { + string label = txt.DefaultText ?? ""; + PSXFontAsset font = txt.GetEffectiveFont(); + Color tint = TexTint(txt.TextColor); tint.a = alpha; + + if (font == null || font.FontTexture == null) + { + // System font (8x16 fixed). No bitmap to sample, so approximate + // with a label - and say so, because the sizes differ. + var style = new GUIStyle(EditorStyles.label) + { + fontSize = Mathf.Clamp(Mathf.RoundToInt(11 * scale), 7, 40), + clipping = TextClipping.Clip, + wordWrap = false, + }; + style.normal.textColor = tint; + GUI.Label(r, label, style); + return; + } + + Texture2D fontTex = font.FontTexture; + int glyphW = font.GlyphWidth, glyphH = font.GlyphHeight; + int perRow = font.GlyphsPerRow; + + GUI.color = tint; + float cursor = r.x; + foreach (char ch in label) + { + char c = (ch < 32 || ch > 126) ? '?' : ch; + int idx = c - 32; + float advance = (font.AdvanceWidths != null && idx < font.AdvanceWidths.Length) + ? font.AdvanceWidths[idx] : glyphW; + + if (c != ' ') + { + int col = idx % perRow, row = idx / perRow; + // The runtime draws `advance` pixels of a left-aligned glyph, + // so sample exactly that slice - a full cell would overlap the + // next character and read as bold. + float drawW = (advance > 0 && advance < glyphW) ? advance : glyphW; + var uv = new Rect((float)(col * glyphW) / fontTex.width, + 1f - (float)((row + 1) * glyphH) / fontTex.height, + drawW / fontTex.width, + (float)glyphH / fontTex.height); + GUI.DrawTextureWithTexCoords( + new Rect(cursor, r.y, drawW * scale, glyphH * scale), fontTex, uv, true); + } + cursor += advance * scale; + } + GUI.color = Color.white; + } + } +} diff --git a/Editor/PSXScreenPreview.cs.meta b/Editor/PSXScreenPreview.cs.meta new file mode 100644 index 0000000..d3f8c66 --- /dev/null +++ b/Editor/PSXScreenPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e86d6cb919918226887b527c458daa84 \ No newline at end of file diff --git a/Editor/PSXSkinnedObjectExporterEditor.cs b/Editor/PSXSkinnedObjectExporterEditor.cs index 99ae5a8..2f25781 100644 --- a/Editor/PSXSkinnedObjectExporterEditor.cs +++ b/Editor/PSXSkinnedObjectExporterEditor.cs @@ -80,7 +80,7 @@ public override void OnInspectorGUI() EditorGUILayout.Space(4); EditorGUILayout.LabelField("Estimated RAM", - $"{totalBytes / 1024f:F1} KB ({totalFrames} frames × {usedBones} bones × 24 B + {boneIndexBytes} B indices)"); + $"{totalBytes / 1024f:F1} KB ({totalFrames} frames x {usedBones} bones x 24 B + {boneIndexBytes} B indices)"); if (skinExp.AnimationClips.Length > 16) { diff --git a/Editor/PSXSplashInstaller.cs b/Editor/PSXSplashInstaller.cs index 3aa09d3..c9dbe03 100644 --- a/Editor/PSXSplashInstaller.cs +++ b/Editor/PSXSplashInstaller.cs @@ -16,7 +16,7 @@ namespace SplashEdit.EditorCode /// public static class PSXSplashInstaller { - // ───── Public config ───── + // ----- Public config ----- public static readonly string RepoOwner = "psxsplash"; public static readonly string RepoName = "psxsplash"; public static readonly string RepoUrl = "https://github.com/psxsplash/psxsplash.git"; @@ -26,7 +26,7 @@ public static class PSXSplashInstaller private static readonly string GitHubApiReleasesUrl = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases"; - // ───── Cached release list ───── + // ----- Cached release list ----- private static List _cachedReleases = new List(); private static bool _isFetchingReleases; @@ -49,9 +49,9 @@ static PSXSplashInstaller() FullInstallPath = Path.Combine(Application.dataPath, "psxsplash"); } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Queries - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// Is the native project cloned on disk? public static bool IsInstalled() @@ -83,13 +83,13 @@ public static string GetCurrentTag() } } - // ═══════════════════════════════════════════════════════════════ - // Fetch Releases (HTTP — no git required) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- + // Fetch Releases (HTTP - no git required) + // --------------------------------------------------------------- /// /// Fetches the list of releases from the GitHub REST API. - /// Does NOT require git — uses UnityWebRequest. + /// Does NOT require git - uses UnityWebRequest. /// public static async Task> FetchReleasesAsync() { @@ -124,9 +124,9 @@ public static async Task> FetchReleasesAsync() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Install / Clone at a specific release tag - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// /// Clones the repository at the specified release tag with --recursive. @@ -185,7 +185,7 @@ public static async Task SwitchToReleaseAsync(string tag, Action o { if (!IsInstalled()) { - UnityEngine.Debug.LogError("[PSXSplashInstaller] Not installed — clone first."); + UnityEngine.Debug.LogError("[PSXSplashInstaller] Not installed - clone first."); return false; } @@ -245,9 +245,9 @@ public static async Task FetchLatestAsync() } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // Git helpers - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// /// Checks whether git is available on the system PATH. @@ -369,9 +369,9 @@ private static async Task RunGitCommandAsync( } } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // HTTP helpers (no git needed) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- private static Task HttpGetAsync(string url) { @@ -396,9 +396,9 @@ private static Task HttpGetAsync(string url) return tcs.Task; } - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- // JSON parsing (minimal, avoids external dependency) - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// /// Minimal JSON parser for the GitHub releases API response. diff --git a/Editor/PSXTilemapPreview.cs b/Editor/PSXTilemapPreview.cs new file mode 100644 index 0000000..440d008 --- /dev/null +++ b/Editor/PSXTilemapPreview.cs @@ -0,0 +1,305 @@ +using System.Collections.Generic; +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// Draws a painted the way the console draws it - + /// in the scene view, in the world, and in the 2D screen preview. + /// + /// + /// Before this existed a tilemap was invisible everywhere except the Tile + /// Painter window, so laying anything out against the map - a spawn point, a + /// task station, a HUD element that must not cover the corridor the player + /// walks down - meant painting, exporting, building a disc and looking at a + /// television. The whole point of authoring in Unity is that the answer is on + /// screen while you are still authoring. + /// + /// World mapping matches the engine exactly: tile (0,0) is world (0,0), one + /// world unit is one pixel (Tile.MoveActor works in pos.x.integer() + /// pixels), the grid lies in the XZ plane and +Z is DOWN the screen. + /// + [InitializeOnLoad] + public static class PSXTilemapPreview + { + private const string k_ShowWorldKey = "PSXSplash.Tilemap.ShowInScene"; + + /// Draw the tilemap in the 3D scene view (menu-toggleable). + public static bool ShowInSceneView + { + get => EditorPrefs.GetBool(k_ShowWorldKey, true); + set { EditorPrefs.SetBool(k_ShowWorldKey, value); SceneView.RepaintAll(); } + } + + static PSXTilemapPreview() + { + SceneView.duringSceneGui += OnSceneGui; + } + + // --- mesh cache ----------------------------------------------------- + // + // A 64x64 map is 4096 quads. Rebuilding that every repaint would make the + // scene view crawl, so the mesh is cached and rebuilt only when the map's + // contents actually change. The change check walks the cells, which is + // cheap next to allocating 16k vertices. + + private class Entry + { + public Mesh Mesh; + public int Hash; + public Texture2D Texture; + } + + private static readonly Dictionary s_cache = new Dictionary(); + private static Material s_material; + + /// Throw away every cached mesh (the painter calls this). + public static void Invalidate() + { + foreach (var e in s_cache.Values) + if (e.Mesh != null) Object.DestroyImmediate(e.Mesh); + s_cache.Clear(); + SceneView.RepaintAll(); + } + + private static Material PreviewMaterial() + { + if (s_material != null) return s_material; + + // Unlit/Transparent is the closest built-in match for how the console + // draws a tile: no lighting, alpha cut straight through, no fog. The + // fallbacks matter because which built-in shaders are present depends + // on the render pipeline the project happens to use. + Shader shader = Shader.Find("Unlit/Transparent") + ?? Shader.Find("Sprites/Default") + ?? Shader.Find("Unlit/Texture"); + if (shader == null) return null; + + s_material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + return s_material; + } + + private static int ContentHash(PSXTilemap map) + { + unchecked + { + int h = 17; + h = h * 31 + map.Width; + h = h * 31 + map.Height; + h = h * 31 + (map.Tileset != null ? map.Tileset.GetInstanceID() : 0); + h = h * 31 + (map.Tileset != null ? map.Tileset.CellWidth * 397 + map.Tileset.CellHeight : 0); + foreach (var b in map.Brushes) h = h * 31 + b.tilesetCell * 7 + (b.walkable ? 1 : 0); + for (int y = 0; y < map.Height; y++) + for (int x = 0; x < map.Width; x++) + h = h * 31 + map.GetCell(x, y); + return h; + } + } + + /// The cached world-space mesh for a map, rebuilt when it changes. + public static Mesh MeshFor(PSXTilemap map) + { + if (map == null || map.Tileset == null || map.Tileset.SourceTexture == null) return null; + + int key = map.GetInstanceID(); + int hash = ContentHash(map); + if (s_cache.TryGetValue(key, out Entry e) && e.Mesh != null && e.Hash == hash) + return e.Mesh; + + if (e != null && e.Mesh != null) Object.DestroyImmediate(e.Mesh); + e = new Entry { Hash = hash, Texture = map.Tileset.SourceTexture, Mesh = BuildMesh(map) }; + s_cache[key] = e; + return e.Mesh; + } + + private static Mesh BuildMesh(PSXTilemap map) + { + PSXSpriteSheet set = map.Tileset; + Texture2D tex = set.SourceTexture; + int tw = map.TileWidth, th = map.TileHeight; + int cols = Mathf.Max(1, set.Columns); + + var verts = new List(); + var uvs = new List(); + var tris = new List(); + + float uStep = (float)set.CellWidth / tex.width; + float vStep = (float)set.CellHeight / tex.height; + + for (int y = 0; y < map.Height; y++) + { + for (int x = 0; x < map.Width; x++) + { + PSXTileBrush brush = map.BrushAt(x, y); + if (brush == null || brush.tilesetCell < 0) continue; + + int cell = brush.tilesetCell; + float u0 = (cell % cols) * uStep; + // Cells count from the TOP-left; Unity's V axis runs from the + // BOTTOM. Flip once here rather than in three draw sites. + float v1 = 1f - (cell / cols) * vStep; + float v0 = v1 - vStep; + + float x0 = x * tw, x1 = x0 + tw; + float z0 = y * th, z1 = z0 + th; + + int b = verts.Count; + verts.Add(new Vector3(x0, 0, z0)); + verts.Add(new Vector3(x1, 0, z0)); + verts.Add(new Vector3(x1, 0, z1)); + verts.Add(new Vector3(x0, 0, z1)); + + uvs.Add(new Vector2(u0, v1)); + uvs.Add(new Vector2(u0 + uStep, v1)); + uvs.Add(new Vector2(u0 + uStep, v0)); + uvs.Add(new Vector2(u0, v0)); + + tris.Add(b); tris.Add(b + 1); tris.Add(b + 2); + tris.Add(b); tris.Add(b + 2); tris.Add(b + 3); + } + } + + var mesh = new Mesh { hideFlags = HideFlags.HideAndDontSave, name = "PSXTilemapPreview" }; + // A 256x256 map is 65536 quads: past the 16-bit index limit, so say so + // rather than silently drawing the first 16384 tiles. + mesh.indexFormat = verts.Count > 65000 + ? UnityEngine.Rendering.IndexFormat.UInt32 + : UnityEngine.Rendering.IndexFormat.UInt16; + mesh.SetVertices(verts); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(tris, 0); + mesh.RecalculateBounds(); + return mesh; + } + + // --- 3D scene view --------------------------------------------------- + + private static void OnSceneGui(SceneView view) + { + if (!ShowInSceneView) return; + if (Event.current.type != EventType.Repaint) return; + + var renderers = Object.FindObjectsByType( + FindObjectsInactive.Exclude, FindObjectsSortMode.None); + foreach (var r in renderers) + DrawWorld(r); + } + + private static void DrawWorld(PSXTilemapRenderer renderer) + { + if (renderer == null || renderer.tilemap == null) return; + PSXTilemap map = renderer.tilemap; + + Mesh mesh = MeshFor(map); + Material mat = PreviewMaterial(); + if (mesh != null && mat != null) + { + mat.mainTexture = map.Tileset.SourceTexture; + mat.SetPass(0); + // Identity, not the renderer's transform: the engine ignores the + // GameObject's position entirely (tile 0,0 IS world 0,0), so a + // preview that followed the transform would lie about where the + // floor is the moment somebody nudged the object. + Graphics.DrawMeshNow(mesh, Matrix4x4.identity); + } + + bool selected = Selection.activeGameObject == renderer.gameObject; + if (selected) DrawGridOverlay(map); + } + + /// + /// Grid, map border and the solid cells, drawn only for the selected + /// tilemap so it does not fight the art the rest of the time. + /// + private static void DrawGridOverlay(PSXTilemap map) + { + int tw = map.TileWidth, th = map.TileHeight; + float w = map.Width * tw, h = map.Height * th; + + Handles.color = new Color(1f, 1f, 1f, 0.25f); + Handles.DrawAAPolyLine(2f, + new Vector3(0, 0, 0), new Vector3(w, 0, 0), + new Vector3(w, 0, h), new Vector3(0, 0, h), new Vector3(0, 0, 0)); + + // Solid cells get a red wash. "Where can I walk" is the question a + // level layout is actually answering, and it is invisible in the art. + Handles.color = new Color(1f, 0.25f, 0.25f, 0.18f); + for (int y = 0; y < map.Height; y++) + { + for (int x = 0; x < map.Width; x++) + { + PSXTileBrush b = map.BrushAt(x, y); + if (b != null && b.walkable) continue; + if (b == null && map.GetCell(x, y) < 0) continue; // unpainted: leave it clear + Handles.DrawAAConvexPolygon( + new Vector3(x * tw, 0, y * th), + new Vector3(x * tw + tw, 0, y * th), + new Vector3(x * tw + tw, 0, y * th + th), + new Vector3(x * tw, 0, y * th + th)); + } + } + + // Object tiles: the addressable ones a game looks up by kind and id. + Handles.color = new Color(0.3f, 1f, 0.9f, 0.9f); + for (int y = 0; y < map.Height; y++) + { + for (int x = 0; x < map.Width; x++) + { + PSXTileBrush b = map.BrushAt(x, y); + if (b == null || b.objectKind == 0) continue; + var c = new Vector3(x * tw + tw * 0.5f, 0, y * th + th * 0.5f); + Handles.DrawWireDisc(c, Vector3.up, Mathf.Min(tw, th) * 0.35f); + } + } + } + + // --- 2D screen preview ---------------------------------------------- + + /// + /// Draw the tiles visible through a 320x240 window at , + /// world pixels, into a GUI rect. This is the + /// same culling window TileSystem::renderOT computes. + /// + public static void DrawScreen(PSXTilemap map, Rect screen, float scale, + int viewX, int viewY, Vector2 resolution) + { + if (map == null || map.Tileset == null || map.Tileset.SourceTexture == null) return; + Texture2D tex = map.Tileset.SourceTexture; + PSXSpriteSheet set = map.Tileset; + + int tw = map.TileWidth, th = map.TileHeight; + if (tw <= 0 || th <= 0) return; + int cols = Mathf.Max(1, set.Columns); + + int x0 = Mathf.FloorToInt(viewX / (float)tw); + int y0 = Mathf.FloorToInt(viewY / (float)th); + int x1 = Mathf.FloorToInt((viewX + resolution.x - 1) / (float)tw); + int y1 = Mathf.FloorToInt((viewY + resolution.y - 1) / (float)th); + x0 = Mathf.Max(0, x0); y0 = Mathf.Max(0, y0); + x1 = Mathf.Min(map.Width - 1, x1); y1 = Mathf.Min(map.Height - 1, y1); + + float uStep = (float)set.CellWidth / tex.width; + float vStep = (float)set.CellHeight / tex.height; + + for (int y = y0; y <= y1; y++) + { + for (int x = x0; x <= x1; x++) + { + PSXTileBrush b = map.BrushAt(x, y); + if (b == null || b.tilesetCell < 0) continue; + + int cell = b.tilesetCell; + float u0 = (cell % cols) * uStep; + float v0 = 1f - (cell / cols + 1) * vStep; + + var r = new Rect(screen.x + (x * tw - viewX) * scale, + screen.y + (y * th - viewY) * scale, + tw * scale, th * scale); + GUI.DrawTextureWithTexCoords(r, tex, new Rect(u0, v0, uStep, vStep)); + } + } + } + } +} diff --git a/Editor/PSXTilemapPreview.cs.meta b/Editor/PSXTilemapPreview.cs.meta new file mode 100644 index 0000000..b70c95d --- /dev/null +++ b/Editor/PSXTilemapPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de7fa809ead9e5368d93a3c344053d76 \ No newline at end of file diff --git a/Editor/PSXTilemapRendererEditor.cs b/Editor/PSXTilemapRendererEditor.cs new file mode 100644 index 0000000..5a2df33 --- /dev/null +++ b/Editor/PSXTilemapRendererEditor.cs @@ -0,0 +1,73 @@ +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + [CustomEditor(typeof(PSXTilemapRenderer))] + public class PSXTilemapRendererEditor : Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + var renderer = (PSXTilemapRenderer)target; + + EditorGUILayout.Space(6); + using (new EditorGUI.DisabledScope(renderer.tilemap == null)) + { + if (GUILayout.Button("Open in Tile Painter", GUILayout.Height(28))) + TilePainterWindow.Open(renderer.tilemap); + } + + EditorGUILayout.Space(2); + using (new EditorGUILayout.HorizontalScope()) + { + bool show = GUILayout.Toggle(PSXTilemapPreview.ShowInSceneView, + new GUIContent("Draw in Scene View", + "Render the painted tiles on the XZ plane, where the engine puts them. " + + "Select this object to also see the grid, the solid cells and the object tiles."), + EditorStyles.miniButtonLeft, GUILayout.Height(20)); + if (show != PSXTilemapPreview.ShowInSceneView) + PSXTilemapPreview.ShowInSceneView = show; + + if (GUILayout.Button(new GUIContent("Refresh", + "Rebuild the cached preview mesh. Only needed if the map was changed " + + "from outside the Tile Painter."), + EditorStyles.miniButtonRight, GUILayout.Height(20), GUILayout.Width(70))) + PSXTilemapPreview.Invalidate(); + } + + if (renderer.tilemap == null) + { + EditorGUILayout.HelpBox( + "Assign a Tilemap asset (create one with PlayStation 1 > Tile Painter > New Tilemap), " + + "or this scene exports with no floor.", MessageType.Info); + return; + } + + var problems = renderer.tilemap.Validate(); + if (problems.Count > 0) + { + EditorGUILayout.HelpBox(string.Join("\n", problems), MessageType.Warning); + return; + } + + PSXTilemap map = renderer.tilemap; + EditorGUILayout.HelpBox( + $"{map.Width}x{map.Height} tiles, {map.TileWidth}x{map.TileHeight}px each.\n" + + // The world extent, spelled out, because everything else in the + // scene is placed against it: one world unit is one pixel, tile + // (0,0) is the origin, and the map runs along +X and +Z. + $"World extent: 0,0 to {map.Width * map.TileWidth},{map.Height * map.TileHeight} " + + "(one unit = one pixel, on the XZ plane).\n" + + $"Cells: {map.Width * map.Height * 2} bytes in the splashpack.", + MessageType.None); + + EditorGUILayout.HelpBox( + "The GameObject's transform is ignored on export - tile (0,0) is always world (0,0). " + + "The scene-view preview draws it there too, so what you see is where the player walks.", + MessageType.Info); + } + } +} diff --git a/Editor/PSXTilemapRendererEditor.cs.meta b/Editor/PSXTilemapRendererEditor.cs.meta new file mode 100644 index 0000000..b92bedc --- /dev/null +++ b/Editor/PSXTilemapRendererEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ba9e9c65340fae4bb83763ad3a01caa \ No newline at end of file diff --git a/Editor/PSXUIMenuItems.cs b/Editor/PSXUIMenuItems.cs new file mode 100644 index 0000000..159505a --- /dev/null +++ b/Editor/PSXUIMenuItems.cs @@ -0,0 +1,183 @@ +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; +using UnityEngine.UI; + +namespace SplashEdit.EditorCode +{ + /// + /// GameObject-menu builders for PSX UI, plus the tilemap preview toggle. + /// + /// + /// Every one of these sets the pivot to the top-left corner and snaps the + /// rect to whole pixels, because that is what makes an authored position mean + /// what it says: PSXUILayout bakes x = anchoredPosition.x - pivot.x*w, + /// so any other pivot turns "put it at 40,44" into arithmetic the author has + /// to do in their head. + /// + public static class PSXUIMenuItems + { + private const string k_GO = "GameObject/PlayStation 1/UI/"; + private const string k_Menu = "PlayStation 1/"; + + // ----- preview toggle ----- + + [MenuItem(k_Menu + "Show Tilemap in Scene View", false, 40)] + private static void ToggleTilemapPreview() => + PSXTilemapPreview.ShowInSceneView = !PSXTilemapPreview.ShowInSceneView; + + [MenuItem(k_Menu + "Show Tilemap in Scene View", true)] + private static bool ToggleTilemapPreviewValidate() + { + Menu.SetChecked(k_Menu + "Show Tilemap in Scene View", PSXTilemapPreview.ShowInSceneView); + return true; + } + + // ----- builders ----- + + [MenuItem(k_GO + "Canvas", false, 10)] + public static void CreateCanvas(MenuCommand cmd) + { + GameObject go = NewCanvas("PSX Canvas", cmd.context as GameObject); + Selection.activeGameObject = go; + } + + [MenuItem(k_GO + "Text", false, 20)] + public static void CreateText(MenuCommand cmd) => + Selection.activeGameObject = NewElement("Text", cmd, new Vector2(120, 19)); + + [MenuItem(k_GO + "Sprite (sheet cell)", false, 21)] + public static void CreateSprite(MenuCommand cmd) => + Selection.activeGameObject = NewElement("Sprite", cmd, new Vector2(16, 16)); + + [MenuItem(k_GO + "Box", false, 22)] + public static void CreateBox(MenuCommand cmd) => + Selection.activeGameObject = NewElement("Box", cmd, new Vector2(64, 24)); + + [MenuItem(k_GO + "Image", false, 23)] + public static void CreateImage(MenuCommand cmd) => + Selection.activeGameObject = NewElement("Image", cmd, new Vector2(64, 64)); + + [MenuItem(k_GO + "Progress Bar", false, 24)] + public static void CreateProgressBar(MenuCommand cmd) => + Selection.activeGameObject = NewElement("ProgressBar", cmd, new Vector2(120, 12)); + + /// + /// A nine-slice panel: eight edge/corner sprites round a middle, all from + /// one sheet, built in one click. + /// + /// + /// This is the shape every authored panel in a PS1 game has, and building + /// it by hand is nine components whose rects have to agree to the pixel - + /// exactly the arithmetic that used to live in Lua as + /// drawFrame(). Cell indices follow the conventional 3x3 block + /// starting at the top-left corner; retarget them in the inspector if the + /// sheet is laid out differently. + /// + [MenuItem(k_GO + "Nine-Slice Panel", false, 40)] + public static void CreateNineSlice(MenuCommand cmd) + { + GameObject parent = ResolveParent(cmd); + if (parent == null) return; + + var root = new GameObject("Panel", typeof(RectTransform)); + GameObjectUtility.SetParentAndAlign(root, parent); + RectTransform rrt = root.GetComponent(); + Configure(rrt, new Vector2(0, 0), new Vector2(240, 152)); + Undo.RegisterCreatedObjectUndo(root, "Create PSX Nine-Slice Panel"); + + // 16px is the cell size of every sheet this was written for, and the + // builder cannot know better until a sheet is assigned - the inspector + // "Snap size" button fixes each piece up afterwards. + const int c = 16; + float w = rrt.sizeDelta.x, h = rrt.sizeDelta.y; + + // name, x, y, w, h, cell + var pieces = new (string name, float x, float y, float w, float h, int cell)[] + { + ("middle", c, c, w - 2 * c, h - 2 * c, 4), + ("edge_top", c, 0, w - 2 * c, c, 1), + ("edge_bottom", c, h - c, w - 2 * c, c, 7), + ("edge_left", 0, c, c, h - 2 * c, 3), + ("edge_right", w - c, c, c, h - 2 * c, 5), + ("corner_tl", 0, 0, c, c, 0), + ("corner_tr", w - c, 0, c, c, 2), + ("corner_bl", 0, h - c, c, c, 6), + ("corner_br", w - c, h - c, c, c, 8), + }; + + // Created in this order deliberately: the middle is FIRST, so it is + // the first sibling and therefore behind everything, and the corners + // are last so they cover the seams where the edges meet. Draw order + // is sibling order (see UISystem::renderOT). + foreach (var p in pieces) + { + var go = new GameObject(p.name, typeof(RectTransform), typeof(PSXUISprite)); + GameObjectUtility.SetParentAndAlign(go, root); + Configure(go.GetComponent(), new Vector2(p.x, -p.y), new Vector2(p.w, p.h)); + + var so = new SerializedObject(go.GetComponent()); + so.FindProperty("elementName").stringValue = p.name; + so.FindProperty("cell").intValue = p.cell; + so.ApplyModifiedPropertiesWithoutUndo(); + } + + Selection.activeGameObject = root; + } + + // ----- helpers ----- + + private static GameObject NewCanvas(string name, GameObject context) + { + var go = new GameObject(name, typeof(RectTransform), typeof(Canvas), typeof(PSXCanvas)); + GameObjectUtility.SetParentAndAlign(go, context); + go.GetComponent().ConfigureCanvas(); + Undo.RegisterCreatedObjectUndo(go, "Create PSX Canvas"); + return go; + } + + /// + /// The canvas the new element belongs under, creating one if the selection + /// is not already inside a PSX canvas. An element outside a canvas is not + /// exported at all, and nothing says so. + /// + private static GameObject ResolveParent(MenuCommand cmd) + { + var ctx = cmd.context as GameObject ?? Selection.activeGameObject; + if (ctx != null && ctx.GetComponentInParent(true) != null) + return ctx; + + var existing = Object.FindFirstObjectByType(); + if (existing != null) return existing.gameObject; + + return NewCanvas("PSX Canvas", null); + } + + private static GameObject NewElement(string name, MenuCommand cmd, Vector2 size) + where T : Component + { + GameObject parent = ResolveParent(cmd); + var go = new GameObject(name, typeof(RectTransform), typeof(T)); + GameObjectUtility.SetParentAndAlign(go, parent); + Configure(go.GetComponent(), Vector2.zero, size); + Undo.RegisterCreatedObjectUndo(go, "Create PSX UI " + name); + return go; + } + + /// + /// Top-left pivot, top-left anchor, whole-pixel rect. Anchoring at the + /// canvas's top-left rather than its centre is what makes the inspector's + /// Pos X / Pos Y read as PS1 screen pixels straight off. + /// + private static void Configure(RectTransform rt, Vector2 anchoredPos, Vector2 size) + { + rt.anchorMin = new Vector2(0f, 1f); + rt.anchorMax = new Vector2(0f, 1f); + rt.pivot = new Vector2(0f, 1f); + rt.localScale = Vector3.one; + rt.localRotation = Quaternion.identity; + rt.sizeDelta = new Vector2(Mathf.Round(size.x), Mathf.Round(size.y)); + rt.anchoredPosition = new Vector2(Mathf.Round(anchoredPos.x), Mathf.Round(anchoredPos.y)); + } + } +} diff --git a/Editor/PSXUIMenuItems.cs.meta b/Editor/PSXUIMenuItems.cs.meta new file mode 100644 index 0000000..62f6644 --- /dev/null +++ b/Editor/PSXUIMenuItems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fa88ee62ec14c707a92d2c06efd37043 \ No newline at end of file diff --git a/Editor/QuantizedPreviewWindow.cs b/Editor/QuantizedPreviewWindow.cs index c1d3b2c..1f00d13 100644 --- a/Editor/QuantizedPreviewWindow.cs +++ b/Editor/QuantizedPreviewWindow.cs @@ -155,9 +155,9 @@ private void DrawCLUT() // Convert the CLUT colors from 5-bit to float values (0-1 range) Vector3 color = new Vector3( - clut[index].R / 31.0f, // Red: bits 0–4 - clut[index].G / 31.0f, // Green: bits 5–9 - clut[index].B / 31.0f // Blue: bits 10–14 + clut[index].R / 31.0f, // Red: bits 0-4 + clut[index].G / 31.0f, // Green: bits 5-9 + clut[index].B / 31.0f // Blue: bits 10-14 ); // Create a small color preview box for each color in the CLUT diff --git a/Editor/TilePainterWindow.cs b/Editor/TilePainterWindow.cs new file mode 100644 index 0000000..66b8bbc --- /dev/null +++ b/Editor/TilePainterWindow.cs @@ -0,0 +1,536 @@ +using System.Collections.Generic; +using System.IO; +using SplashEdit.RuntimeCode; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.EditorCode +{ + /// + /// A grid tile painter for . The design goal above all + /// others is ease of use: pick a brush on the left, paint on the right, and + /// the walls, floors and gameplay objects fall out of what you painted. There + /// is no per-cell property editing and no separate "collision layer" - a tile's + /// walkability and role come from its brush, so you decide once and paint many. + /// + public class TilePainterWindow : EditorWindow + { + private enum Tool { Paint, Erase, Fill, Pick } + + private PSXTilemap _map; + private int _brush = 0; + private Tool _tool = Tool.Paint; + + private float _zoom = 24f; // editor pixels per tile + private Vector2 _mapScroll; + private Vector2 _paletteScroll; + private int _hoverX = -1, _hoverY = -1; + + private bool _showGrid = true; + private bool _showWalkable = true; + private bool _showObjects = true; + + // A distinct overlay tint per object-kind value, derived from the value so + // the painter needs no table of game-specific kinds. + private static Color KindColor(int kind) + { + // Golden-angle hue spread: adjacent kinds get clearly different colours. + float h = (kind * 0.61803398875f) % 1f; + return Color.HSVToRGB(h, 0.7f, 1f); + } + + [MenuItem("PlayStation 1/Tile Painter")] + public static void ShowWindow() + { + var win = GetWindow("Tile Painter"); + win.minSize = new Vector2(760, 520); + win.wantsMouseMove = true; + } + + /// Open the painter already pointed at a specific map. + public static void Open(PSXTilemap map) + { + ShowWindow(); + var win = GetWindow(); + win._map = map; + win._brush = 0; + } + + private void OnGUI() + { + DrawToolbar(); + + if (_map == null) + { + EditorGUILayout.Space(20); + EditorGUILayout.HelpBox( + "Assign a Tilemap above, or click New Tilemap.\n\n" + + "A Tilemap needs a tileset (a PSXSplash Sprite Sheet). The sheet's " + + "Cell Width/Height becomes the tile size. Paint brushes onto the grid; " + + "walls, floors and objects come from each brush's settings.", + MessageType.Info); + return; + } + + EditorGUILayout.BeginHorizontal(); + DrawPalette(); // left column + DrawCanvas(); // main grid + EditorGUILayout.EndHorizontal(); + } + + // ------------------------------------------------------------------ + // Toolbar + // ------------------------------------------------------------------ + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + EditorGUI.BeginChangeCheck(); + _map = (PSXTilemap)EditorGUILayout.ObjectField(_map, typeof(PSXTilemap), false, GUILayout.Width(200)); + if (EditorGUI.EndChangeCheck()) _brush = 0; + + if (GUILayout.Button("New Tilemap", EditorStyles.toolbarButton, GUILayout.Width(90))) + CreateNewTilemap(); + + GUILayout.Space(12); + + if (_map != null) + { + using (new EditorGUI.DisabledScope(true)) + EditorGUILayout.LabelField($"tile {_map.TileWidth}x{_map.TileHeight}px", GUILayout.Width(90)); + + GUILayout.Label("Tool:", GUILayout.Width(34)); + _tool = (Tool)GUILayout.Toolbar((int)_tool, new[] { "Paint", "Erase", "Fill", "Pick" }, + EditorStyles.toolbarButton, GUILayout.Width(220)); + + GUILayout.Space(8); + _showGrid = GUILayout.Toggle(_showGrid, "Grid", EditorStyles.toolbarButton, GUILayout.Width(44)); + _showWalkable = GUILayout.Toggle(_showWalkable, "Walls", EditorStyles.toolbarButton, GUILayout.Width(50)); + _showObjects = GUILayout.Toggle(_showObjects, "Objects", EditorStyles.toolbarButton, GUILayout.Width(60)); + } + + GUILayout.FlexibleSpace(); + GUILayout.Label("Zoom", GUILayout.Width(38)); + _zoom = GUILayout.HorizontalSlider(_zoom, 8f, 48f, GUILayout.Width(120)); + + EditorGUILayout.EndHorizontal(); + + if (_map != null) + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + int w = _map.Width, h = _map.Height; + GUILayout.Label("Size", GUILayout.Width(34)); + int nw = EditorGUILayout.IntField(w, GUILayout.Width(50)); + GUILayout.Label("x", GUILayout.Width(12)); + int nh = EditorGUILayout.IntField(h, GUILayout.Width(50)); + if (GUILayout.Button("Resize", EditorStyles.toolbarButton, GUILayout.Width(60)) && (nw != w || nh != h)) + { + Undo.RecordObject(_map, "Resize Tilemap"); + _map.Resize(nw, nh); + EditorUtility.SetDirty(_map); + } + + GUILayout.Space(12); + if (GUILayout.Button("Fill All", EditorStyles.toolbarButton, GUILayout.Width(60))) + FillAll(_brush); + if (GUILayout.Button("Clear All", EditorStyles.toolbarButton, GUILayout.Width(64))) + FillAll(-1); + + GUILayout.FlexibleSpace(); + if (_hoverX >= 0) + { + var b = _map.BrushAt(_hoverX, _hoverY); + GUILayout.Label($"({_hoverX},{_hoverY}) {(b != null ? b.name : "empty")}", GUILayout.Width(200)); + } + EditorGUILayout.EndHorizontal(); + } + } + + // ------------------------------------------------------------------ + // Palette (left) + // ------------------------------------------------------------------ + + private void DrawPalette() + { + EditorGUILayout.BeginVertical(GUILayout.Width(240)); + EditorGUILayout.LabelField("Brushes", EditorStyles.boldLabel); + + var problems = _map.Validate(); + if (problems.Count > 0) + EditorGUILayout.HelpBox(string.Join("\n", problems), MessageType.Warning); + + _paletteScroll = EditorGUILayout.BeginScrollView(_paletteScroll, GUILayout.Height(200)); + for (int i = 0; i < _map.Brushes.Count; i++) + { + var brush = _map.Brushes[i]; + var row = EditorGUILayout.BeginHorizontal(); + bool selected = (i == _brush); + if (selected) EditorGUI.DrawRect(row, new Color(0.24f, 0.44f, 0.9f, 0.35f)); + + Rect thumb = GUILayoutUtility.GetRect(24, 24, GUILayout.Width(24), GUILayout.Height(24)); + DrawTileCell(thumb, brush.tilesetCell, Color.white); + + if (GUILayout.Button($"{brush.name}", EditorStyles.label)) + _brush = i; + + string badge = brush.walkable ? "" : "WALL "; + if (brush.objectKind != 0) badge += _map.KindLabel(brush.objectKind).ToUpper(); + GUILayout.Label(badge, EditorStyles.miniLabel, GUILayout.Width(80)); + + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Add Brush")) + { + Undo.RecordObject(_map, "Add Brush"); + _map.Brushes.Add(new PSXTileBrush { name = "brush " + _map.Brushes.Count }); + _brush = _map.Brushes.Count - 1; + EditorUtility.SetDirty(_map); + } + using (new EditorGUI.DisabledScope(_map.Brushes.Count == 0)) + if (GUILayout.Button("Remove")) + { + Undo.RecordObject(_map, "Remove Brush"); + _map.Brushes.RemoveAt(_brush); + _brush = Mathf.Clamp(_brush, 0, _map.Brushes.Count - 1); + EditorUtility.SetDirty(_map); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + DrawBrushEditor(); + + EditorGUILayout.EndVertical(); + } + + private void DrawBrushEditor() + { + if (_brush < 0 || _brush >= _map.Brushes.Count) return; + var b = _map.Brushes[_brush]; + + EditorGUILayout.LabelField("Selected Brush", EditorStyles.boldLabel); + EditorGUI.BeginChangeCheck(); + + string name = EditorGUILayout.TextField("Name", b.name); + bool walkable = EditorGUILayout.Toggle("Walkable", b.walkable); + + // Kind is an author-named byte: a popup of the tilemap's Object Kind + // Names when they exist, a plain number field otherwise. The engine + // never sees the names - they only make the dropdown readable. + int objectKind = b.objectKind; + var kindNames = _map.ObjectKindNames; + if (kindNames != null && kindNames.Count > 1) + { + int clamped = Mathf.Clamp(objectKind, 0, kindNames.Count - 1); + objectKind = EditorGUILayout.Popup("Kind", clamped, kindNames.ToArray()); + } + else + { + objectKind = EditorGUILayout.IntField( + new GUIContent("Kind", "0 = plain tile. Any other value tags this as an object. " + + "Add friendly names in the Tilemap asset's Object Kind Names list."), + objectKind); + } + + int objectId = b.objectId; + if (objectKind != 0) + { + objectId = EditorGUILayout.IntField( + new GUIContent("Object Id", "-1 auto-numbers this kind in reading order. " + + "Set it when the id matters (your game decides what it means)."), + b.objectId); + } + + Color col = EditorGUILayout.ColorField("Editor Tint", b.editorColor); + + EditorGUILayout.LabelField("Graphic (click a tile):"); + int tilesetCell = DrawTilesetPicker(b.tilesetCell); + + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_map, "Edit Brush"); + b.name = name; + b.walkable = walkable; + b.objectKind = objectKind; + b.objectId = objectId; + b.editorColor = col; + b.tilesetCell = tilesetCell; + EditorUtility.SetDirty(_map); + } + } + + /// A clickable grid of the tileset; returns the chosen cell index. + private int DrawTilesetPicker(int current) + { + var sheet = _map.Tileset; + if (sheet == null || sheet.SourceTexture == null) + { + EditorGUILayout.HelpBox("Assign a tileset on the Tilemap asset to pick graphics.", MessageType.None); + return current; + } + + int cols = sheet.Columns, rows = sheet.Rows; + const float cellPx = 22f; + Rect area = GUILayoutUtility.GetRect(cols * cellPx, rows * cellPx); + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + int idx = r * cols + c; + Rect cell = new Rect(area.x + c * cellPx, area.y + r * cellPx, cellPx - 1, cellPx - 1); + DrawTileCell(cell, idx, Color.white); + if (idx == current) + DrawRectOutline(cell, new Color(0.24f, 0.6f, 1f), 2f); + if (Event.current.type == EventType.MouseDown && cell.Contains(Event.current.mousePosition)) + { + current = idx; + // The caller wraps this in BeginChangeCheck/EndChangeCheck, + // which only fires for controls that raise GUI.changed. A + // hand-rolled click does not, so raise it ourselves or the + // new cell is computed and then thrown away. + GUI.changed = true; + Event.current.Use(); + Repaint(); + } + } + } + return current; + } + + // ------------------------------------------------------------------ + // Canvas (main grid) + // ------------------------------------------------------------------ + + private void DrawCanvas() + { + EditorGUILayout.BeginVertical(); + _mapScroll = EditorGUILayout.BeginScrollView(_mapScroll); + + int w = _map.Width, h = _map.Height; + Rect grid = GUILayoutUtility.GetRect(w * _zoom, h * _zoom, GUILayout.ExpandWidth(false), GUILayout.ExpandHeight(false)); + + // Background so empty cells read as "nothing", not as the window. + EditorGUI.DrawRect(grid, new Color(0.12f, 0.12f, 0.14f)); + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + Rect cell = new Rect(grid.x + x * _zoom, grid.y + y * _zoom, _zoom, _zoom); + var brush = _map.BrushAt(x, y); + if (brush != null && brush.tilesetCell >= 0) + DrawTileCell(cell, brush.tilesetCell, brush.editorColor); + + if (_showWalkable && brush != null && !brush.walkable) + EditorGUI.DrawRect(cell, new Color(1f, 0f, 0f, 0.28f)); + + if (_showObjects && brush != null && brush.objectKind != 0) + { + Rect dot = new Rect(cell.x + cell.width * 0.25f, cell.y + cell.height * 0.25f, + cell.width * 0.5f, cell.height * 0.5f); + EditorGUI.DrawRect(dot, KindColor(brush.objectKind)); + } + } + } + + if (_showGrid && _zoom >= 10f) DrawGridLines(grid, w, h); + + HandleMouse(grid, w, h); + + if (_hoverX >= 0 && _hoverX < w && _hoverY >= 0 && _hoverY < h) + { + Rect hov = new Rect(grid.x + _hoverX * _zoom, grid.y + _hoverY * _zoom, _zoom, _zoom); + DrawRectOutline(hov, Color.white, 1.5f); + } + + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + } + + private void HandleMouse(Rect grid, int w, int h) + { + Event e = Event.current; + if (!grid.Contains(e.mousePosition)) + { + if (e.type == EventType.MouseMove && (_hoverX != -1 || _hoverY != -1)) + { + _hoverX = _hoverY = -1; + Repaint(); + } + return; + } + + int x = Mathf.FloorToInt((e.mousePosition.x - grid.x) / _zoom); + int y = Mathf.FloorToInt((e.mousePosition.y - grid.y) / _zoom); + + if (e.type == EventType.MouseMove) + { + _hoverX = x; _hoverY = y; + Repaint(); + } + + bool paintButton = e.button == 0; + bool eraseButton = e.button == 1; + if ((e.type == EventType.MouseDown || e.type == EventType.MouseDrag) && (paintButton || eraseButton)) + { + ApplyTool(x, y, eraseButton); + e.Use(); + } + } + + private void ApplyTool(int x, int y, bool forceErase) + { + if (x < 0 || y < 0 || x >= _map.Width || y >= _map.Height) return; + + if (forceErase || _tool == Tool.Erase) + { + Paint(x, y, -1); + } + else if (_tool == Tool.Pick) + { + int b = _map.GetCell(x, y); + if (b >= 0) { _brush = b; Repaint(); } + } + else if (_tool == Tool.Fill) + { + FloodFill(x, y, _brush); + } + else // Paint + { + Paint(x, y, _brush); + } + } + + private void Paint(int x, int y, int brushIndex) + { + if (_map.GetCell(x, y) == brushIndex) return; + Undo.RecordObject(_map, "Paint Tile"); + _map.SetCell(x, y, brushIndex); + Commit(); + } + + private void FloodFill(int x, int y, int brushIndex) + { + int target = _map.GetCell(x, y); + if (target == brushIndex) return; + + Undo.RecordObject(_map, "Fill Tiles"); + var stack = new Stack(); + stack.Push(new Vector2Int(x, y)); + while (stack.Count > 0) + { + var p = stack.Pop(); + if (p.x < 0 || p.y < 0 || p.x >= _map.Width || p.y >= _map.Height) continue; + if (_map.GetCell(p.x, p.y) != target) continue; + _map.SetCell(p.x, p.y, brushIndex); + stack.Push(new Vector2Int(p.x + 1, p.y)); + stack.Push(new Vector2Int(p.x - 1, p.y)); + stack.Push(new Vector2Int(p.x, p.y + 1)); + stack.Push(new Vector2Int(p.x, p.y - 1)); + } + Commit(); + } + + private void FillAll(int brushIndex) + { + Undo.RecordObject(_map, "Fill All"); + for (int y = 0; y < _map.Height; y++) + for (int x = 0; x < _map.Width; x++) + _map.SetCell(x, y, brushIndex); + Commit(); + } + + /// + /// Mark the map changed and refresh everything looking at it. + /// + /// + /// The scene view is included on purpose: PSXTilemapPreview draws the map + /// where the player will walk, and a preview that only updates when you + /// happen to nudge the camera is worse than none - you stop trusting it. + /// + private void Commit() + { + EditorUtility.SetDirty(_map); + Repaint(); + SceneView.RepaintAll(); + } + + // ------------------------------------------------------------------ + // Drawing helpers + // ------------------------------------------------------------------ + + /// Blit tileset cell into . + private void DrawTileCell(Rect rect, int cell, Color tint) + { + var sheet = _map != null ? _map.Tileset : null; + if (sheet == null || sheet.SourceTexture == null || cell < 0) + { + EditorGUI.DrawRect(rect, new Color(0.2f, 0.2f, 0.2f)); + return; + } + int cols = sheet.Columns, rows = sheet.Rows; + if (cols <= 0 || rows <= 0) return; + int cx = cell % cols; + int cy = cell / cols; + // Unity UV origin is bottom-left; the sheet is addressed top-to-bottom, + // so flip the row. + float uw = 1f / cols, uh = 1f / rows; + var uv = new Rect(cx * uw, 1f - (cy + 1) * uh, uw, uh); + Color prev = GUI.color; + GUI.color = tint; + GUI.DrawTextureWithTexCoords(rect, sheet.SourceTexture, uv, true); + GUI.color = prev; + } + + private static void DrawGridLines(Rect grid, int w, int h) + { + Handles.BeginGUI(); + Handles.color = new Color(1f, 1f, 1f, 0.12f); + for (int x = 0; x <= w; x++) + { + float px = grid.x + x * (grid.width / w); + Handles.DrawLine(new Vector3(px, grid.y), new Vector3(px, grid.y + grid.height)); + } + for (int y = 0; y <= h; y++) + { + float py = grid.y + y * (grid.height / h); + Handles.DrawLine(new Vector3(grid.x, py), new Vector3(grid.x + grid.width, py)); + } + Handles.EndGUI(); + } + + private static void DrawRectOutline(Rect r, Color c, float t) + { + EditorGUI.DrawRect(new Rect(r.x, r.y, r.width, t), c); + EditorGUI.DrawRect(new Rect(r.x, r.yMax - t, r.width, t), c); + EditorGUI.DrawRect(new Rect(r.x, r.y, t, r.height), c); + EditorGUI.DrawRect(new Rect(r.xMax - t, r.y, t, r.height), c); + } + + // ------------------------------------------------------------------ + // New asset + // ------------------------------------------------------------------ + + private void CreateNewTilemap() + { + string path = EditorUtility.SaveFilePanelInProject( + "New Tilemap", "Tilemap", "asset", "Where should the tilemap asset live?"); + if (string.IsNullOrEmpty(path)) return; + + var map = ScriptableObject.CreateInstance(); + // Seed a sensible default palette so the window is usable immediately. + map.Brushes.Add(new PSXTileBrush { name = "floor", tilesetCell = 0, walkable = true, editorColor = Color.white }); + map.Brushes.Add(new PSXTileBrush { name = "wall", tilesetCell = 1, walkable = false, editorColor = new Color(0.7f, 0.7f, 0.7f) }); + map.EnsureCells(); + + AssetDatabase.CreateAsset(map, path); + AssetDatabase.SaveAssets(); + _map = map; + _brush = 0; + Selection.activeObject = map; + } + } +} diff --git a/Editor/TilePainterWindow.cs.meta b/Editor/TilePainterWindow.cs.meta new file mode 100644 index 0000000..41ec48e --- /dev/null +++ b/Editor/TilePainterWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e5a0a1e1d3619a4c9fc8ab773fec0ae \ No newline at end of file diff --git a/Editor/Timeline/PSXTimelineDrawer.cs b/Editor/Timeline/PSXTimelineDrawer.cs index e55fb09..7173063 100644 --- a/Editor/Timeline/PSXTimelineDrawer.cs +++ b/Editor/Timeline/PSXTimelineDrawer.cs @@ -310,7 +310,7 @@ public static void DrawTrackLanes(PSXTimelineState state) GUI.EndClip(); } - // ── Grid lines ── + // -- Grid lines -- private static void DrawGridLines(PSXTimelineState state, Rect localRect) { @@ -329,7 +329,7 @@ private static void DrawGridLines(PSXTimelineState state, Rect localRect) } } - // ── Value tracks (position, rotation, etc.) ── + // -- Value tracks (position, rotation, etc.) -- private static void DrawValueTrack(PSXTimelineState state, PSXCutsceneTrack track, int trackIdx, float centerY, Rect localRect) @@ -368,7 +368,7 @@ private static void DrawValueTrack(PSXTimelineState state, PSXCutsceneTrack trac } } - // ── Bool tracks (active/visible) ── + // -- Bool tracks (active/visible) -- private static void DrawBoolTrack(PSXTimelineState state, PSXCutsceneTrack track, int trackIdx, float laneY, float centerY, Rect localRect) @@ -407,7 +407,7 @@ private static void DrawBoolTrack(PSXTimelineState state, PSXCutsceneTrack track } } - // ── Audio event lane ── + // -- Audio event lane -- private static void DrawAudioEventLane(PSXTimelineState state, float y, Rect localRect) { @@ -437,7 +437,7 @@ private static void DrawAudioEventLane(PSXTimelineState state, float y, Rect loc } } - // ── Skin anim event lane ── + // -- Skin anim event lane -- private static void DrawSkinAnimEventLane(PSXTimelineState state, float y, Rect localRect) { @@ -564,7 +564,7 @@ private static void DrawBorder(Rect rect, Color color) EditorGUI.DrawRect(new Rect(rect.xMax - 1, rect.y, 1, rect.height), color); } - // ── Cached styles ── + // -- Cached styles -- private static GUIStyle _rulerLabelStyle; private static GUIStyle GetRulerLabelStyle() diff --git a/Editor/Timeline/PSXTimelineState.cs b/Editor/Timeline/PSXTimelineState.cs index c6f28a0..428ed89 100644 --- a/Editor/Timeline/PSXTimelineState.cs +++ b/Editor/Timeline/PSXTimelineState.cs @@ -10,11 +10,11 @@ namespace SplashEdit.EditorCode /// public class PSXTimelineState { - // ── Clip being edited ── + // -- Clip being edited -- public ScriptableObject Clip; public bool IsCutscene; - // ── View state ── + // -- View state -- public float PixelsPerFrame = 5f; public float ScrollX; public float ScrollY; @@ -28,7 +28,7 @@ public class PSXTimelineState public float InspectorHeight = DefaultInspectorHeight; public bool InspectorCollapsed; - // ── Selection ── + // -- Selection -- public int SelectedTrackIndex = -1; public int SelectedKeyframeIndex = -1; /// 0 = audio event, 1 = skin anim event @@ -36,30 +36,30 @@ public class PSXTimelineState public int SelectedEventIndex = -1; public HashSet<(int track, int kf)> MultiSelection = new HashSet<(int, int)>(); - // ── Transport / Playhead ── + // -- Transport / Playhead -- public float PlayheadFrame; public bool IsPlaying; public bool IsPreviewing; public double PlayStartEditorTime; public float PlayStartFrame; - // ── Drag state ── + // -- Drag state -- public bool IsDraggingKeyframe; public bool IsDraggingPlayhead; public int DragOriginalFrame; - // ── Toolbar action (set by drawer, consumed by window) ── + // -- Toolbar action (set by drawer, consumed by window) -- public enum ToolbarAction { None, Play, Pause, Stop, EndPreview } public ToolbarAction RequestedAction; - // ── Layout rects (computed each OnGUI) ── + // -- Layout rects (computed each OnGUI) -- public Rect ToolbarRect; public Rect TrackHeaderRect; public Rect TimeRulerRect; public Rect TimelineAreaRect; public Rect KeyframeInspRect; - // ── Accessors that work for both clip types ── + // -- Accessors that work for both clip types -- public int DurationFrames { @@ -120,7 +120,7 @@ public List AudioEvents } } - // ── Coordinate conversion ── + // -- Coordinate conversion -- /// Convert a frame number to a pixel X offset within the timeline area. public float FrameToPixelX(float frame) => frame * PixelsPerFrame - ScrollX; @@ -136,7 +136,7 @@ public List AudioEvents return (start, end); } - // ── Clip setup ── + // -- Clip setup -- /// /// Set the clip to edit. Caller must stop preview before calling this diff --git a/Editor/Timeline/PSXTimelineWindow.cs b/Editor/Timeline/PSXTimelineWindow.cs index 99119f4..b52dadf 100644 --- a/Editor/Timeline/PSXTimelineWindow.cs +++ b/Editor/Timeline/PSXTimelineWindow.cs @@ -645,7 +645,7 @@ private void ShowAddTrackMenu() var menu = new GenericMenu(); - // ── Camera tracks (cutscene only) ── + // -- Camera tracks (cutscene only) -- if (_state.IsCutscene) { menu.AddItem(new GUIContent("Camera/Position"), false, () => AddTrack(PSXTrackType.CameraPosition)); @@ -653,7 +653,7 @@ private void ShowAddTrackMenu() menu.AddItem(new GUIContent("Camera/FOV (H)"), false, () => AddTrack(PSXTrackType.CameraH)); } - // ── Object tracks: submenu per scene object ── + // -- Object tracks: submenu per scene object -- var objects = Object.FindObjectsByType(FindObjectsSortMode.None); if (objects.Length > 0) { @@ -675,7 +675,7 @@ private void ShowAddTrackMenu() menu.AddDisabledItem(new GUIContent("Object/(no PSXObjectExporters in scene)")); } - // ── UI Canvas Visible track ── + // -- UI Canvas Visible track -- var canvases = Object.FindObjectsByType(FindObjectsSortMode.None); if (canvases.Length > 0) { @@ -727,7 +727,7 @@ private void ShowAddTrackMenu() menu.AddDisabledItem(new GUIContent("UI Canvas/(no PSXCanvas in scene)")); } - // ── Rumble tracks ── + // -- Rumble tracks -- menu.AddItem(new GUIContent("Rumble/Small Motor"), false, () => AddTrack(PSXTrackType.RumbleSmall)); menu.AddItem(new GUIContent("Rumble/Large Motor"), false, () => AddTrack(PSXTrackType.RumbleLarge)); diff --git a/Editor/ToolchainChecker.cs b/Editor/ToolchainChecker.cs index f72ca36..cbac9f8 100644 --- a/Editor/ToolchainChecker.cs +++ b/Editor/ToolchainChecker.cs @@ -41,7 +41,7 @@ public static bool IsToolAvailable(string toolName) try { - // macOS GUI apps have restricted PATH — check common locations directly + // macOS GUI apps have restricted PATH - check common locations directly if (Application.platform == RuntimePlatform.OSXEditor) { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); diff --git a/Editor/ToolchainInstaller.cs b/Editor/ToolchainInstaller.cs index 0956c3f..d4a494a 100644 --- a/Editor/ToolchainInstaller.cs +++ b/Editor/ToolchainInstaller.cs @@ -137,7 +137,7 @@ await RunCommandAsync("powershell", // brew-install-path is needed because Homebrew 4.6.4+ removed // support for installing from local .rb formula files directly. // The resulting "nikitabobko/local-tap" is a LOCAL directory on - // the user's machine, not a GitHub repo — don't reference it + // the user's machine, not a GitHub repo - don't reference it // in install instructions. bool hasBrew = ToolchainChecker.IsToolAvailable("brew"); string installScript = @@ -158,7 +158,7 @@ await RunCommandAsync("powershell", " pcsx-redux/main/tools/macos-mips/mipsel-none-elf-gcc.rb\n" + " brew install-path ./mipsel-none-elf-binutils.rb\n" + " brew install-path ./mipsel-none-elf-gcc.rb\n\n" + - "Builds GCC from source — expect 15-30 minutes.\n" + + "Builds GCC from source - expect 15-30 minutes.\n" + "Then click Refresh in the Dependencies tab.", "OK"); GUIUtility.systemCopyBuffer = installScript; @@ -168,15 +168,15 @@ await RunCommandAsync("powershell", EditorUtility.DisplayDialog( "macOS: Install Homebrew + MIPS Cross-Compiler", "Homebrew (the macOS package manager) is required.\n\n" + - "Step 1 — Install Homebrew:\n" + + "Step 1 - Install Homebrew:\n" + " /bin/bash -c \"$(curl -fsSL\n" + " https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n\n" + - "Step 2 — Install the MIPS compiler (paste into Terminal):\n" + + "Step 2 - Install the MIPS compiler (paste into Terminal):\n" + " brew install nikitabobko/tap/brew-install-path\n" + " curl -LO \n" + " brew install-path ./mipsel-none-elf-binutils.rb\n" + " brew install-path ./mipsel-none-elf-gcc.rb\n\n" + - "Step 2 builds GCC from source — expect 15-30 minutes.\n" + + "Step 2 builds GCC from source - expect 15-30 minutes.\n" + "The Homebrew install command has been copied to your clipboard.\n" + "Then click Refresh in the Dependencies tab.", "OK"); diff --git a/Runtime/BVH.cs b/Runtime/BVH.cs index 7321c28..bd63418 100644 --- a/Runtime/BVH.cs +++ b/Runtime/BVH.cs @@ -293,7 +293,7 @@ private BVHNode BuildNode(List triangles, int depth) float leafCost = triangles.Count * INTERSECT_COST; if (bestAxis < 0 || bestCost >= leafCost) { - // No beneficial split found — make a leaf + // No beneficial split found - make a leaf node.triangles = triangles.Select(t => t.reference).ToList(); return node; } diff --git a/Runtime/ImageProcessing.cs b/Runtime/ImageProcessing.cs index 6c84c18..b327d16 100644 --- a/Runtime/ImageProcessing.cs +++ b/Runtime/ImageProcessing.cs @@ -28,18 +28,45 @@ public struct QuantizedResult public List Palette; } + /// + /// A source pixel is transparent below this alpha. The PS1 has no alpha + /// channel - a texel is either drawn or it is not - so a cutout has to + /// pick a threshold somewhere, and halfway is the least surprising place. + /// + public const float CutoutAlphaThreshold = 0.5f; + /// /// Quantizes the given texture into a limited number of colors and dithers it. /// /// The texture to be quantized. /// The maximum number of colors allowed in the quantized texture. + /// + /// Reserve palette index 0 for transparency and map every pixel with + /// alpha below to it. + /// + /// Needed by anything with a hole in it - a sprite, an icon, a decal. + /// Without it alpha is simply discarded (the colour channels of a fully + /// transparent pixel are quantized like any other), and the result is a + /// character in an opaque rectangle. Costs one palette entry: a 4-bit + /// cutout gets 15 colours, not 16. + /// /// A QuantizedResult containing the indices of the quantized colors and the palette of unique colors. - public static QuantizedResult Quantize(Texture2D texture, int maxColors) + public static QuantizedResult Quantize(Texture2D texture, int maxColors, bool cutout = false) { int width = texture.width, height = texture.height; Color[] pixels = texture.GetPixels(); int[,] indices = new int[width, height]; + if (cutout) + { + // Index 0 is the transparent one, so the opaque pixels share what + // is left. Quantizing the transparent pixels' colours alongside + // them would waste entries on pixels nobody will ever see, and + // would drag the palette toward whatever colour the artist left + // in the invisible background. + return QuantizeCutout(pixels, width, height, maxColors); + } + List uniqueColors = pixels.Select(c => new Vector3(c.r, c.g, c.b)).Distinct().ToList(); if (uniqueColors.Count <= maxColors) return ConvertToOutput(pixels, width, height); @@ -65,6 +92,74 @@ public static QuantizedResult Quantize(Texture2D texture, int maxColors) return new QuantizedResult { Indices = indices, Palette = palette }; } + /// + /// Quantize only the opaque pixels, into palette entries 1..maxColors-1. + /// Entry 0 is left for the caller to write as the PS1's transparent + /// colour (0x0000). + /// + private static QuantizedResult QuantizeCutout(Color[] pixels, int width, int height, int maxColors) + { + int[,] indices = new int[width, height]; + List palette = new List { Vector3.zero }; // [0] = transparent + + int opaqueBudget = maxColors - 1; + List opaqueColors = pixels + .Where(c => c.a >= CutoutAlphaThreshold) + .Select(c => new Vector3(c.r, c.g, c.b)) + .Distinct() + .ToList(); + + if (opaqueColors.Count == 0) + { + // Entirely transparent. Legal, if pointless; do not divide by it. + return new QuantizedResult { Indices = indices, Palette = palette }; + } + + bool exact = opaqueColors.Count <= opaqueBudget; + palette.AddRange(exact ? opaqueColors : KMeans(opaqueColors, opaqueBudget)); + + // Match against the opaque colours only. Including entry 0 would let + // a dark pixel snap to "transparent" and punch a hole in the sprite. + List opaquePalette = palette.GetRange(1, palette.Count - 1); + KDTree kdTree = exact ? null : new KDTree(opaquePalette); + Dictionary exactLookup = null; + if (exact) + { + exactLookup = new Dictionary(); + for (int i = 0; i < opaquePalette.Count; i++) exactLookup[opaquePalette[i]] = i; + } + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + Color src = pixels[y * width + x]; + if (src.a < CutoutAlphaThreshold) + { + indices[x, y] = 0; + continue; + } + + Vector3 oldColor = new Vector3(src.r, src.g, src.b); + int opaqueIndex = exact + ? exactLookup[oldColor] + : kdTree.FindNearestIndex(oldColor); + indices[x, y] = opaqueIndex + 1; // shift past the transparent entry + + if (!exact) + { + // Diffuse the error, but never into a transparent pixel: + // its colour is never drawn, so pushing error there just + // loses it. + Vector3 error = oldColor - opaquePalette[opaqueIndex]; + PropagateError(pixels, width, height, x, y, error); + } + } + } + + return new QuantizedResult { Indices = indices, Palette = palette }; + } + private static List KMeans(List colors, int k) { List centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList(); diff --git a/Runtime/PSXAgent.cs b/Runtime/PSXAgent.cs new file mode 100644 index 0000000..0fbc2cd --- /dev/null +++ b/Runtime/PSXAgent.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + // -- Agent state indices must match AgentState enum in scenemanager.hh ---------- + public enum PSXAgentState + { + Idle = 0, + Patrol = 1, + Seek = 2, + Flee = 3, + Attack = 4, + Wander = 5, + Investigate = 6, + Custom = 7, + } + + /// + /// Per-state animation clip binding for a PSX skinned-mesh agent. + /// Set Clip to null to leave that state's animation slot empty (0xFF in binary). + /// + [System.Serializable] + public struct PSXAgentStateAnim + { + [HideInInspector] public PSXAgentState state; + [Tooltip("Animation clip to play when entering this state. Leave empty for no animation.")] + public PSXAnimationClip clip; + } + + /// + /// Marks a PSXObject as a native nav agent and exports its AI defaults into the splashpack. + /// Vision, hearing, patrol waypoints, and per-state animation clips are all configured here + /// and baked to the 28-byte SPLASHPACKAgentV2 binary format at export time. + /// + [AddComponentMenu("PSX Splash/PSX Agent")] + [RequireComponent(typeof(PSXObjectExporter))] + [Icon("Packages/net.psxsplash.splashedit/Icons/PSXObjectExporter.png")] + public class PSXAgent : MonoBehaviour + { + // -- Movement ------------------------------------------------------------- + [Header("Movement")] + [Tooltip("Whether this agent starts active when the scene loads.")] + [SerializeField] private bool startEnabled = true; + + [Tooltip("Default native move speed in Unity units/second (same scale as PSXPlayer speed).")] + [Min(0.01f)] + [SerializeField] private float moveSpeed = 3.0f; + + [Tooltip("Distance in Unity units at which a move-to target is considered reached.")] + [Min(0f)] + [SerializeField] private float stopDistance = 0.2f; + + // -- Vision ---------------------------------------------------------------- + [Header("Vision")] + [Tooltip("Enable vision sensing. When enabled the agent can see targets within range and FOV.")] + [SerializeField] private bool hasVision = false; + + [Tooltip("Maximum line-of-sight range in Unity units. 0 disables vision even if hasVision is true.")] + [Min(0f)] + [SerializeField] private float visionRange = 8f; + + [Tooltip("Full cone FOV in degrees (e.g. 90 = 45 deg each side). " + + "360 = omnidirectional vision inside the range sphere.")] + [Range(1f, 360f)] + [SerializeField] private float visionFovDegrees = 90f; + + [Tooltip("Maximum number of nav-region portal hops used for portal-graph line-of-sight. " + + "0 = same region only. Higher values cost more CPU; 2-3 covers most interior scenes.")] + [Range(0, 8)] + [SerializeField] private int visionRegionDepth = 2; + + // -- Hearing ---------------------------------------------------------------- + [Header("Hearing")] + [Tooltip("Enable hearing sensing. Targets within hearingRange broadcast noise events to the agent.")] + [SerializeField] private bool hasHearing = false; + + [Tooltip("Maximum hearing radius in Unity units. No geometry check is applied; pure range only.")] + [Min(0f)] + [SerializeField] private float hearingRange = 6f; + + // -- Alert ------------------------------------------------------------------ + [Header("Alert")] + [Tooltip("Number of frames (at 30 fps) the agent stays 'alert' after losing sight/sound of a target " + + "before the onTargetLost Lua event fires and the state machine reverts.")] + [Range(0, 300)] + [SerializeField] private int alertTimeoutFrames = 60; + + // -- Patrol waypoints -------------------------------------------------------- + [Header("Patrol Waypoints")] + [Tooltip("World-space patrol waypoints visited in order. Leave empty for no patrol. " + + "When populated the AGENT_FLAG_HAS_PATROL bit is set and the native runtime " + + "cycles through these points in the PATROL state. Maximum 8 waypoints.")] + [SerializeField] private List patrolWaypoints = new List(); + + // -- Per-state animation ----------------------------------------------------- + [Header("State Animations")] + [Tooltip("Animation clip to play for each agent state. Leave a slot empty to keep the " + + "previous animation running when that state is entered.")] + [SerializeField] + private PSXAgentStateAnim[] stateAnims = new PSXAgentStateAnim[8] + { + new PSXAgentStateAnim { state = PSXAgentState.Idle }, + new PSXAgentStateAnim { state = PSXAgentState.Patrol }, + new PSXAgentStateAnim { state = PSXAgentState.Seek }, + new PSXAgentStateAnim { state = PSXAgentState.Flee }, + new PSXAgentStateAnim { state = PSXAgentState.Attack }, + new PSXAgentStateAnim { state = PSXAgentState.Wander }, + new PSXAgentStateAnim { state = PSXAgentState.Investigate }, + new PSXAgentStateAnim { state = PSXAgentState.Custom }, + }; + + // -- Public accessors -------------------------------------------------------- + public bool StartEnabled => startEnabled; + public float MoveSpeed => moveSpeed; + public float StopDistance => stopDistance; + public bool HasVision => hasVision; + public float VisionRange => visionRange; + public float VisionFovDegrees => visionFovDegrees; + public int VisionRegionDepth => visionRegionDepth; + public bool HasHearing => hasHearing; + public float HearingRange => hearingRange; + public int AlertTimeoutFrames => alertTimeoutFrames; + public IReadOnlyList PatrolWaypoints => patrolWaypoints; + public PSXAgentStateAnim[] StateAnims => stateAnims; + + /// + /// Half-FOV cosine in fp12 format, matching the visionCosAngle field in + /// SPLASHPACKAgentV2. cos(halfFovRad) * 4096, clamped to int16 range. + /// + public short VisionCosAngleFp12 + { + get + { + float halfRad = visionFovDegrees * 0.5f * Mathf.Deg2Rad; + float cosVal = Mathf.Cos(halfRad); + return (short)Mathf.Clamp(Mathf.RoundToInt(cosVal * 4096f), -4096, 4096); + } + } + + private void OnValidate() + { + moveSpeed = Mathf.Max(0.01f, moveSpeed); + stopDistance = Mathf.Max(0f, stopDistance); + visionRange = Mathf.Max(0f, visionRange); + hearingRange = Mathf.Max(0f, hearingRange); + + // Clamp waypoint list + if (patrolWaypoints != null && patrolWaypoints.Count > 8) + patrolWaypoints.RemoveRange(8, patrolWaypoints.Count - 8); + + // Keep state labels in sync (defensive - editor shouldn't drift these) + if (stateAnims != null && stateAnims.Length == 8) + for (int i = 0; i < 8; i++) + stateAnims[i].state = (PSXAgentState)i; + } + +#if UNITY_EDITOR + private void OnDrawGizmosSelected() + { + DrawGizmos(true); + } + private void OnDrawGizmos() + { + DrawGizmos(false); + } + + private void DrawGizmos(bool selected) + { + if (!selected && !hasVision && !hasHearing && (patrolWaypoints == null || patrolWaypoints.Count == 0)) + return; + + var t = transform; + var center = t.position + Vector3.up * 0.9f; + float alpha = selected ? 0.85f : 0.35f; + + // Vision cone (yellow) + if (hasVision && visionRange > 0.001f) + { + UnityEditor.Handles.color = new Color(1f, 0.95f, 0.2f, alpha * 0.18f); + float halfRad = visionFovDegrees * 0.5f * Mathf.Deg2Rad; + Vector3 left = Quaternion.AngleAxis(-visionFovDegrees * 0.5f, Vector3.up) * t.forward; + Vector3 right = Quaternion.AngleAxis( visionFovDegrees * 0.5f, Vector3.up) * t.forward; + UnityEditor.Handles.DrawSolidArc(center, Vector3.up, left, visionFovDegrees, visionRange); + UnityEditor.Handles.color = new Color(1f, 0.95f, 0.2f, alpha); + UnityEditor.Handles.DrawLine(center, center + left * visionRange); + UnityEditor.Handles.DrawLine(center, center + right * visionRange); + UnityEditor.Handles.DrawWireArc(center, Vector3.up, left, visionFovDegrees, visionRange); + } + + // Hearing radius (cyan) + if (hasHearing && hearingRange > 0.001f) + { + UnityEditor.Handles.color = new Color(0.2f, 0.9f, 1f, alpha * 0.12f); + UnityEditor.Handles.DrawSolidDisc(center, Vector3.up, hearingRange); + UnityEditor.Handles.color = new Color(0.2f, 0.9f, 1f, alpha); + UnityEditor.Handles.DrawWireDisc(center, Vector3.up, hearingRange); + } + + // Patrol waypoints (green dots + lines) + if (patrolWaypoints != null && patrolWaypoints.Count > 0) + { + UnityEditor.Handles.color = new Color(0.3f, 1f, 0.4f, alpha); + Vector3 prev = center; + for (int i = 0; i < patrolWaypoints.Count; i++) + { + Vector3 wp = patrolWaypoints[i]; + UnityEditor.Handles.DrawLine(prev, wp + Vector3.up * 0.1f); + UnityEditor.Handles.DrawSolidDisc(wp + Vector3.up * 0.1f, Vector3.up, 0.12f); + UnityEditor.Handles.Label(wp + Vector3.up * 0.45f, i.ToString(), + UnityEditor.EditorStyles.miniLabel); + prev = wp + Vector3.up * 0.1f; + } + // Close loop line back to start + if (patrolWaypoints.Count > 1) + UnityEditor.Handles.DrawLine(prev, patrolWaypoints[0] + Vector3.up * 0.1f); + } + } +#endif + } +} + diff --git a/Runtime/PSXAgent.cs.meta b/Runtime/PSXAgent.cs.meta new file mode 100644 index 0000000..68a2a41 --- /dev/null +++ b/Runtime/PSXAgent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e8622b9ef70dec4d8ccb85de1886656 \ No newline at end of file diff --git a/Runtime/PSXAnimationExporter.cs b/Runtime/PSXAnimationExporter.cs index 1545879..1252c83 100644 --- a/Runtime/PSXAnimationExporter.cs +++ b/Runtime/PSXAnimationExporter.cs @@ -296,7 +296,7 @@ public static void ExportAnimations( } } - // ── Skin anim events (v19) ── + // -- Skin anim events (v19) -- AlignToFourBytes(writer); long skinAnimEventsStart = writer.BaseStream.Position; diff --git a/Runtime/PSXAudioClip.cs b/Runtime/PSXAudioClip.cs index 61206e7..c79ee23 100644 --- a/Runtime/PSXAudioClip.cs +++ b/Runtime/PSXAudioClip.cs @@ -38,5 +38,17 @@ public class PSXAudioClip : MonoBehaviour [Tooltip("Default playback volume (0-127).")] [Range(0, 127)] public int DefaultVolume = 100; + + [Tooltip("Cut silence off the START of the clip at export.\n\n" + + "Off by default: this REWRITES your asset on the way to the " + + "disc, and a lead-in you put there on purpose is not the " + + "exporter's to remove.\n\n" + + "Turn it on for a clip whose file begins with dead air. That " + + "silence is latency - the gap between the frame that asks for " + + "the sound and the frame you hear it - and it costs SPU RAM " + + "for the whole round.\n\n" + + "The TAIL is always kept. On a looping clip it is the interval " + + "the sound repeats at.")] + public bool TrimLeadingSilence = false; } } diff --git a/Runtime/PSXCanvasData.cs b/Runtime/PSXCanvasData.cs index cb1ff6e..f772edd 100644 --- a/Runtime/PSXCanvasData.cs +++ b/Runtime/PSXCanvasData.cs @@ -34,7 +34,7 @@ public struct PSXUIElementData // 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) + // Anchors (8.8 fixed-point: 0=0.0, 128=0.5, 255~1.0) public byte AnchorMinX, AnchorMinY; public byte AnchorMaxX, AnchorMaxY; @@ -47,6 +47,15 @@ public struct PSXUIElementData public byte U0, V0, U1, V1; public byte BitDepthIndex; // 0=4bit, 1=8bit, 2=16bit + // Type-specific: Image authored from a sprite sheet (a PSXUISprite). + // These ride in what used to be the Image record's five padding bytes, + // so the record does not grow and a pack written before PSXUISprite + // existed reads CellW = 0, which the engine takes as "not sheet-backed" + // and refuses to SetFrame. Zero for a plain PSXUIImage. + public byte CellW, CellH; // cell size in texels + public byte SheetCols; // cells per row + public byte BaseU, BaseV; // texel of cell 0 within the texture page + // Type-specific: Progress public byte BgR, BgG, BgB; public byte ProgressValue; diff --git a/Runtime/PSXCollisionExporter.cs b/Runtime/PSXCollisionExporter.cs index 4b61693..1c5be42 100644 --- a/Runtime/PSXCollisionExporter.cs +++ b/Runtime/PSXCollisionExporter.cs @@ -7,7 +7,7 @@ namespace SplashEdit.RuntimeCode { /// - /// Surface flags for collision triangles — must match C++ SurfaceFlag enum + /// Surface flags for collision triangles - must match C++ SurfaceFlag enum /// [Flags] public enum PSXSurfaceFlag : byte diff --git a/Runtime/PSXCutsceneClip.cs b/Runtime/PSXCutsceneClip.cs index 35bac1c..483c31d 100644 --- a/Runtime/PSXCutsceneClip.cs +++ b/Runtime/PSXCutsceneClip.cs @@ -5,7 +5,7 @@ namespace SplashEdit.RuntimeCode { /// /// A cutscene asset containing keyframed tracks and audio events. - /// Create via right-click → Create → PSX → Cutscene Clip. + /// Create via right-click -> Create -> PSX -> Cutscene Clip. /// Reference these assets anywhere in the project; the exporter collects /// all PSXCutsceneClip assets via Resources.FindObjectsOfTypeAll. /// diff --git a/Runtime/PSXCutsceneExporter.cs b/Runtime/PSXCutsceneExporter.cs index 7816a41..1e49065 100644 --- a/Runtime/PSXCutsceneExporter.cs +++ b/Runtime/PSXCutsceneExporter.cs @@ -38,7 +38,7 @@ private static short DegreesToAngleRaw(float degrees) /// Binary writer positioned after all prior sections. /// Cutscene clips to export (may be null/empty). /// Scene object exporters for name validation. - /// Audio sources for clip name → index resolution. + /// Audio sources for clip name -> index resolution. /// GTE scaling factor. /// Returns the file position where the cutscene table starts. /// Optional log callback. @@ -65,7 +65,7 @@ public static void ExportCutscenes( cutscenes = trimmed; } - // Build audio source name → index lookup + // Build audio source name -> index lookup Dictionary audioNameToIndex = new Dictionary(); if (audioSources != null) { @@ -78,7 +78,7 @@ public static void ExportCutscenes( AlignToFourBytes(writer); - // ── Cutscene Table ── + // -- Cutscene Table -- cutsceneTableStart = writer.BaseStream.Position; // SPLASHPACKCutsceneEntry: 12 bytes each @@ -95,7 +95,7 @@ public static void ExportCutscenes( writer.Write((uint)0); // nameOffset placeholder } - // ── Per-cutscene data ── + // -- Per-cutscene data -- for (int ci = 0; ci < cutscenes.Length; ci++) { PSXCutsceneClip clip = cutscenes[ci]; @@ -182,7 +182,7 @@ public static void ExportCutscenes( skinAnimEventsOffsetPlaceholder = writer.BaseStream.Position; writer.Write((uint)0); // skinAnimEventsOffset placeholder - // ── Tracks ── + // -- Tracks -- AlignToFourBytes(writer); long tracksStart = writer.BaseStream.Position; @@ -207,7 +207,7 @@ public static void ExportCutscenes( writer.Write((uint)0); // keyframesOffset placeholder } - // ── Keyframe data (per track) ── + // -- Keyframe data (per track) -- for (int ti = 0; ti < trackCount; ti++) { PSXCutsceneTrack track = clip.Tracks[ti]; @@ -245,7 +245,7 @@ public static void ExportCutscenes( case PSXTrackType.CameraRotation: case PSXTrackType.ObjectRotation: { - // Rotation: degrees → psyqo::Angle raw (pi-units) + // Rotation: degrees -> psyqo::Angle raw (pi-units) // Negate X (pitch) and Z (roll) to compensate for the // Y-axis flip between Unity (Y-up) and PSX (Y-down). short rx = DegreesToAngleRaw(-kf.Value.x); @@ -340,7 +340,7 @@ public static void ExportCutscenes( } } - // ── Object / UI target name strings (per track) ── + // -- Object / UI target name strings (per track) -- for (int ti = 0; ti < trackCount; ti++) { PSXCutsceneTrack track = clip.Tracks[ti]; @@ -361,7 +361,7 @@ public static void ExportCutscenes( // else: objectNameOffset stays 0 } - // ── Audio events ── + // -- Audio events -- AlignToFourBytes(writer); long audioEventsStart = writer.BaseStream.Position; @@ -377,13 +377,13 @@ public static void ExportCutscenes( writer.Write((byte)0); // pad } - // ── Skin anim events (v19) ── + // -- Skin anim events (v19) -- AlignToFourBytes(writer); long skinAnimEventsStart = writer.BaseStream.Position; foreach (var evt in validSkinAnims) { - // Resolve target object → skinned mesh index + // Resolve target object -> skinned mesh index byte meshIdx = 0; byte clipIdx2 = 0; if (skinnedExporters != null) @@ -412,7 +412,7 @@ public static void ExportCutscenes( writer.Write((byte)0); // pad } - // ── Cutscene name string ── + // -- Cutscene name string -- string csName = clip.CutsceneName ?? "unnamed"; if (csName.Length > MAX_NAME_LEN) csName = csName.Substring(0, MAX_NAME_LEN); long nameStartPos = writer.BaseStream.Position; @@ -420,7 +420,7 @@ public static void ExportCutscenes( writer.Write(csNameBytes); writer.Write((byte)0); // null terminator - // ── Backfill SPLASHPACKCutscene offsets ── + // -- Backfill SPLASHPACKCutscene offsets -- { long curPos = writer.BaseStream.Position; @@ -446,7 +446,7 @@ public static void ExportCutscenes( writer.Seek((int)curPos, SeekOrigin.Begin); } - // ── Backfill cutscene table entry ── + // -- Backfill cutscene table entry -- { long curPos = writer.BaseStream.Position; writer.Seek((int)entryPositions[ci], SeekOrigin.Begin); diff --git a/Runtime/PSXFontAsset.cs b/Runtime/PSXFontAsset.cs index a51c04a..9a2f71d 100644 --- a/Runtime/PSXFontAsset.cs +++ b/Runtime/PSXFontAsset.cs @@ -64,12 +64,12 @@ public void GenerateBitmapFromFont() return; } - // ── Step 1: Populate the font atlas ── + // -- Step 1: Populate the font atlas -- string ascii = ""; for (int c = 0x20; c <= 0x7E; c++) ascii += (char)c; sourceFont.RequestCharactersInTexture(ascii, fontSize, FontStyle.Normal); - // ── Step 2: Get readable copy of atlas texture ── + // -- Step 2: Get readable copy of atlas texture -- // For non-dynamic fonts, the atlas may only be populated at the native size. // Try the requested size first, then fall back to size=0. Texture fontTex = sourceFont.material != null ? sourceFont.material.mainTexture : null; @@ -120,7 +120,7 @@ public void GenerateBitmapFromFont() return; } - // ── Step 3: Get character info ── + // -- Step 3: Get character info -- // Non-dynamic fonts only respond to size=0 or their native size. // Dynamic fonts respond to any size. CharacterInfo[] charInfos = new CharacterInfo[95]; @@ -179,7 +179,7 @@ public void GenerateBitmapFromFont() return; } - // ── Step 4: Choose glyph cell dimensions ── + // -- Step 4: Choose glyph cell dimensions -- // Constraints: // - glyphWidth must divide 256 (valid: 4, 8, 16, 32) // - ceil(95 / (256/glyphWidth)) * glyphHeight <= 256 (must fit in one texture page) @@ -273,7 +273,7 @@ public void GenerateBitmapFromFont() // This makes the native advance widths match the bitmap exactly for // proportional rendering. Characters wider than cell get clipped (rare). - // ── Step 5: Render glyphs into grid ── + // -- Step 5: Render glyphs into grid -- // Each glyph is LEFT-ALIGNED at native width for proportional rendering. // The advance widths from CharacterInfo match native glyph proportions. Texture2D bmp = new Texture2D(texW, texH, TextureFormat.RGBA32, false); @@ -387,7 +387,7 @@ public void GenerateBitmapFromFont() } } - // ── Step 6: Save ── + // -- Step 6: Save -- string path = AssetDatabase.GetAssetPath(this); if (string.IsNullOrEmpty(path)) { diff --git a/Runtime/PSXLoaderPackWriter.cs b/Runtime/PSXLoaderPackWriter.cs index ec57451..a375816 100644 --- a/Runtime/PSXLoaderPackWriter.cs +++ b/Runtime/PSXLoaderPackWriter.cs @@ -14,15 +14,15 @@ namespace SplashEdit.RuntimeCode /// uint16 version = 2 /// uint8 fontCount /// uint8 canvasCount (always 1) - /// uint16 resW — target PS1 resolution width - /// uint16 resH — target PS1 resolution height - /// uint8 atlasCount — number of texture atlases - /// uint8 clutCount — number of CLUTs - /// uint32 tableOffset — offset to UI table (font descs + canvas data) + /// uint16 resW - target PS1 resolution width + /// uint16 resH - target PS1 resolution height + /// uint8 atlasCount - number of texture atlases + /// uint8 clutCount - number of CLUTs + /// uint32 tableOffset - offset to UI table (font descs + canvas data) /// /// After header (at offset 16): - /// Atlas headers (12 bytes each × atlasCount) - /// CLUT headers (12 bytes each × clutCount) + /// Atlas headers (12 bytes each x atlasCount) + /// CLUT headers (12 bytes each x clutCount) /// Atlas pixel data (referenced by offsets in atlas headers) /// CLUT pixel data (referenced by offsets in CLUT headers) /// @@ -86,7 +86,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, clutCount++; } - // ── Header (16 bytes) ── + // -- Header (16 bytes) -- writer.Write((byte)'L'); writer.Write((byte)'P'); writer.Write(LOADER_PACK_VERSION); @@ -99,7 +99,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, long tableOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); // tableOffset placeholder - // ── Atlas headers (12 bytes each) ── + // -- Atlas headers (12 bytes each) -- List atlasOffsetPlaceholders = new List(); if (atlases != null) { @@ -114,7 +114,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } } - // ── CLUT headers (12 bytes each) ── + // -- CLUT headers (12 bytes each) -- List clutOffsetPlaceholders = new List(); if (atlases != null) { @@ -135,7 +135,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } } - // ── Atlas pixel data ── + // -- Atlas pixel data -- int atlasIdx = 0; if (atlases != null) { @@ -159,7 +159,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } } - // ── CLUT pixel data ── + // -- CLUT pixel data -- int clutIdx = 0; if (atlases != null) { @@ -187,7 +187,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } } - // ── Font pixel data (written BEFORE the UI table, alongside atlas/CLUT data) ── + // -- Font pixel data (written BEFORE the UI table, alongside atlas/CLUT data) -- // The C++ parser expects canvas descriptors immediately after font descriptors // (font pixel data is at absolute offsets, not inline). Write pixel data here // so it doesn't sit between font descriptors and canvas descriptors. @@ -211,11 +211,11 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } } - // ── UI table (same format as splashpack UI section) ── + // -- UI table (same format as splashpack UI section) -- AlignToFourBytes(writer); long uiTableStart = writer.BaseStream.Position; - // ── Font descriptors (112 bytes each) ── + // -- Font descriptors (112 bytes each) -- if (fonts != null) { for (int fi = 0; fi < fonts.Length; fi++) @@ -238,9 +238,9 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, } // Canvas descriptors now follow immediately after font descriptors - // (no font pixel data in between — it was written above). + // (no font pixel data in between - it was written above). - // ── Canvas descriptor (12 bytes) ── + // -- Canvas descriptor (12 bytes) -- // Must align here: the C++ parser aligns fontDataEnd to 4 bytes // when skipping past font pixel data to find the canvas descriptor. AlignToFourBytes(writer); @@ -259,7 +259,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, long canvasNameOffsetPos = writer.BaseStream.Position; writer.Write((uint)0); // nameOffset placeholder - // ── Element data (48 bytes per element) ── + // -- Element data (48 bytes per element) -- AlignToFourBytes(writer); long elemDataStart = writer.BaseStream.Position; @@ -351,7 +351,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, textContents.Add("__NAME__" + eName); } - // ── String data (text content + element names) ── + // -- String data (text content + element names) -- for (int si = 0; si < textOffsetPositions.Count; si++) { string content = textContents[si]; @@ -373,7 +373,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, writer.Seek((int)cur, SeekOrigin.Begin); } - // ── Canvas name ── + // -- Canvas name -- { AlignToFourBytes(writer); long namePos = writer.BaseStream.Position; @@ -387,7 +387,7 @@ public static bool Write(string path, GameObject prefab, Vector2 resolution, writer.Seek((int)cur, SeekOrigin.Begin); } - // ── Backfill header table offset ── + // -- Backfill header table offset -- { long cur = writer.BaseStream.Position; writer.Seek((int)tableOffsetPos, SeekOrigin.Begin); diff --git a/Runtime/PSXNavRegionBuilder.cs b/Runtime/PSXNavRegionBuilder.cs index d890ec8..8c8e95e 100644 --- a/Runtime/PSXNavRegionBuilder.cs +++ b/Runtime/PSXNavRegionBuilder.cs @@ -17,38 +17,38 @@ public enum NavSurfaceType : byte { Flat = 0, Ramp = 1, Stairs = 2 } /// public class PSXNavRegionBuilder { - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- // Agent parameters - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- public float AgentHeight = 1.8f; public float AgentRadius = 0.3f; public float MaxStepHeight = 0.35f; public float WalkableSlopeAngle = 46.0f; - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- // Voxelization parameters - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- public float CellSize = 0.05f; public float CellHeight = 0.025f; - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- // Region parameters (previously hardcoded) - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- public int MinRegionArea = 8; public int MergeRegionArea = 20; public float MaxSimplifyError = 1.3f; public float MaxEdgeLength = 12.0f; public NavPartitionMethod PartitionMethod = NavPartitionMethod.Watershed; - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- // Detail mesh parameters (decoupled from CellHeight) - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- public float DetailSampleDist = 6.0f; // Multiplier of CellSize public float DetailMaxError = 0.025f; // World units, independent of CellHeight - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- // Plane fit validation - // ──────────────────────────────────────────────────────────────── + // ---------------------------------------------------------------- public float MaxPlaneError = 0.15f; public const int MaxVertsPerRegion = 8; diff --git a/Runtime/PSXNavigationSettings.cs b/Runtime/PSXNavigationSettings.cs new file mode 100644 index 0000000..44ea035 --- /dev/null +++ b/Runtime/PSXNavigationSettings.cs @@ -0,0 +1,101 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Scene-level navigation bake settings. + /// Use this when a scene needs nav regions and AI support without requiring a PSXPlayer. + /// + [Icon("Packages/net.psxsplash.splashedit/Icons/PSXPlayer.png")] + public class PSXNavigationSettings : MonoBehaviour + { + [Header("Agent")] + [Tooltip("Reference agent height used when baking navigation.")] + [SerializeField] private float agentHeight = 1.8f; + + [Tooltip("Reference agent radius used when baking navigation.")] + [SerializeField] private float agentRadius = 0.5f; + + [Tooltip("Maximum height the baked agent can step up.")] + [SerializeField] private float maxStepHeight = 0.35f; + + [Tooltip("Maximum walkable slope angle in degrees.")] + [SerializeField] private float walkableSlopeAngle = 46.0f; + + [Header("Voxelization")] + [SerializeField] private float navCellSize = 0.05f; + [SerializeField] private float navCellHeight = 0.025f; + + [Header("Navigation - Advanced")] + [SerializeField] private int navMinRegionArea = 8; + [SerializeField] private int navMergeRegionArea = 20; + [SerializeField] private float navMaxSimplifyError = 1.3f; + [SerializeField] private float navMaxEdgeLength = 12.0f; + [SerializeField] private NavPartitionMethod navPartitionMethod = NavPartitionMethod.Watershed; + [SerializeField] private float navDetailSampleDist = 6.0f; + [SerializeField] private float navDetailMaxError = 0.025f; + [SerializeField] private float navMaxPlaneError = 0.15f; + + [Header("Start Region")] + [Tooltip("Fallback point used to choose the start nav region when no PSXPlayer exists.")] + [SerializeField] private Transform spawnAnchor; + + public float AgentHeight => agentHeight; + public float AgentRadius => agentRadius; + public float MaxStepHeight => maxStepHeight; + public float WalkableSlopeAngle => walkableSlopeAngle; + public float NavCellSize => navCellSize; + public float NavCellHeight => navCellHeight; + public int NavMinRegionArea => navMinRegionArea; + public int NavMergeRegionArea => navMergeRegionArea; + public float NavMaxSimplifyError => navMaxSimplifyError; + public float NavMaxEdgeLength => navMaxEdgeLength; + public NavPartitionMethod NavPartitionMethod => navPartitionMethod; + public float NavDetailSampleDist => navDetailSampleDist; + public float NavDetailMaxError => navDetailMaxError; + public float NavMaxPlaneError => navMaxPlaneError; + public Vector3 SpawnPoint => spawnAnchor != null ? spawnAnchor.position : transform.position; + + public void ApplyIndoorPreset() + { + navCellSize = 0.05f; + navCellHeight = 0.025f; + navMinRegionArea = 8; + navMergeRegionArea = 20; + navMaxSimplifyError = 1.3f; + navMaxEdgeLength = 12.0f; + navPartitionMethod = NavPartitionMethod.Watershed; + navDetailSampleDist = 6.0f; + navDetailMaxError = 0.025f; + navMaxPlaneError = 0.15f; + } + + public void ApplyTerrainPreset() + { + navCellSize = 0.08f; + navCellHeight = 0.04f; + navMinRegionArea = 4; + navMergeRegionArea = 6; + navMaxSimplifyError = 0.4f; + navMaxEdgeLength = 5.0f; + navPartitionMethod = NavPartitionMethod.Monotone; + navDetailSampleDist = 4.0f; + navDetailMaxError = 0.02f; + navMaxPlaneError = 0.1f; + } + + public void ApplyMultiLevelPreset() + { + navCellSize = 0.05f; + navCellHeight = 0.02f; + navMinRegionArea = 6; + navMergeRegionArea = 12; + navMaxSimplifyError = 0.8f; + navMaxEdgeLength = 8.0f; + navPartitionMethod = NavPartitionMethod.Layer; + navDetailSampleDist = 5.0f; + navDetailMaxError = 0.02f; + navMaxPlaneError = 0.12f; + } + } +} diff --git a/Runtime/PSXNavigationSettings.cs.meta b/Runtime/PSXNavigationSettings.cs.meta new file mode 100644 index 0000000..5bff56e --- /dev/null +++ b/Runtime/PSXNavigationSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7ee1563eea737e46af0c78394ef931d \ No newline at end of file diff --git a/Runtime/PSXPlayer.cs b/Runtime/PSXPlayer.cs index 1452a6a..48a3ab5 100644 --- a/Runtime/PSXPlayer.cs +++ b/Runtime/PSXPlayer.cs @@ -51,7 +51,7 @@ public class PSXPlayer : MonoBehaviour [Tooltip("Voxel height (smaller = more accurate vertical resolution)")] [SerializeField] private float navCellHeight = 0.025f; - [Header("Navigation — Advanced")] + [Header("Navigation - Advanced")] [Tooltip("Regions smaller than this (in voxels) are removed entirely. Raise to eliminate tiny slivers.")] [SerializeField] private int navMinRegionArea = 8; @@ -91,21 +91,21 @@ public class PSXPlayer : MonoBehaviour [Tooltip("Downward acceleration in world units per second squared (positive value)")] [SerializeField] private float gravity = 20.0f; - // Public accessors — Player dimensions + // Public accessors - Player dimensions public float PlayerHeight => playerHeight; public float PlayerRadius => playerRadius; - // Public accessors — Movement + // Public accessors - Movement public float MoveSpeed => moveSpeed; public float SprintSpeed => sprintSpeed; - // Public accessors — Navigation core + // Public accessors - Navigation core public float MaxStepHeight => maxStepHeight; public float WalkableSlopeAngle => walkableSlopeAngle; public float NavCellSize => navCellSize; public float NavCellHeight => navCellHeight; - // Public accessors — Navigation advanced + // Public accessors - Navigation advanced public int NavMinRegionArea => navMinRegionArea; public int NavMergeRegionArea => navMergeRegionArea; public float NavMaxSimplifyError => navMaxSimplifyError; @@ -115,7 +115,7 @@ public class PSXPlayer : MonoBehaviour public float NavDetailMaxError => navDetailMaxError; public float NavMaxPlaneError => navMaxPlaneError; - // Public accessors — Physics + // Public accessors - Physics public float JumpHeight => jumpHeight; public float Gravity => gravity; public Vector3 CamPoint { get; protected set; } diff --git a/Runtime/PSXRoom.cs b/Runtime/PSXRoom.cs index 12dbe4c..efa33a8 100644 --- a/Runtime/PSXRoom.cs +++ b/Runtime/PSXRoom.cs @@ -99,7 +99,7 @@ public struct PSXPortal /// public class PSXRoomBuilder { - private const int CELLS_PER_AXIS = 2; // 2×2×2 = 8 cells per room + private const int CELLS_PER_AXIS = 2; // 2x2x2 = 8 cells per room private PSXRoom[] _rooms; private List _portals = new List(); @@ -263,7 +263,7 @@ public void Build(PSXRoom[] rooms, PSXPortalLink[] portalLinks, } /// - /// Subdivide each room's triangle list into a coarse 3D grid (CELLS_PER_AXIS³). + /// Subdivide each room's triangle list into a coarse 3D grid (CELLS_PER_AXIS^3). /// Each cell gets its own tight AABB and contiguous tri-ref sublist. /// The room's tri-ref list is rewritten so cells point into it. /// @@ -532,7 +532,7 @@ private void BuildPortals(PSXPortalLink[] portalLinks) { if (portalLinks == null) return; - // Build a fast lookup: PSXRoom instance → index. + // Build a fast lookup: PSXRoom instance -> index. var roomIndex = new Dictionary(); for (int i = 0; i < _rooms.Length; i++) roomIndex[_rooms[i]] = i; @@ -542,28 +542,28 @@ private void BuildPortals(PSXPortalLink[] portalLinks) if (link == null) continue; if (link.RoomA == null || link.RoomB == null) { - Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references — skipped."); + Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references - skipped."); continue; } if (link.RoomA == link.RoomB) { - Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice — skipped."); + Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice - skipped."); continue; } if (!roomIndex.TryGetValue(link.RoomA, out int idxA)) { - Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom — skipped."); + Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom - skipped."); continue; } if (!roomIndex.TryGetValue(link.RoomB, out int idxB)) { - Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom — skipped."); + Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom - skipped."); continue; } // Auto-correct normal direction: ensure it points from roomA toward roomB. // The user may place the PSXPortalLink facing either way; the runtime - // backface cull assumes the normal always points A→B. + // backface cull assumes the normal always points A->B. Vector3 portalNormal = link.transform.forward; Vector3 portalRight = link.transform.right; Vector3 portalUp = link.transform.up; @@ -572,12 +572,12 @@ private void BuildPortals(PSXPortalLink[] portalLinks) Vector3 aToB = (roomBCenter - roomACenter).normalized; if (Vector3.Dot(portalNormal, aToB) < 0) { - // Normal faces toward A instead of B — flip it. + // Normal faces toward A instead of B - flip it. // Also flip the right axis to keep the coordinate system consistent - // (normal × right = up must stay right-handed). + // (normal x right = up must stay right-handed). portalNormal = -portalNormal; portalRight = -portalRight; - Debug.Log($"PSXPortalLink '{link.name}': normal auto-corrected to point from RoomA→RoomB."); + Debug.Log($"PSXPortalLink '{link.name}': normal auto-corrected to point from RoomA->RoomB."); } _portals.Add(new PSXPortal @@ -595,7 +595,7 @@ private void BuildPortals(PSXPortalLink[] portalLinks) /// /// Write room/portal data to the splashpack binary. - /// Layout: [RoomData × (N+1)] [PortalData × P] [TriangleRef × T] [RoomCell × C] + /// Layout: [RoomData x (N+1)] [PortalData x P] [TriangleRef x T] [RoomCell x C] /// public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling) { @@ -636,7 +636,7 @@ public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling) runningTriRefOffset += _roomTriRefs[i].Count; } - // Catch-all room (always rendered) — written as an extra "room" entry + // Catch-all room (always rendered) - written as an extra "room" entry { int catchAllRoomIdx = _rooms.Length; // index of catch-all in cell arrays writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling)); @@ -689,7 +689,7 @@ public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling) writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.z * 4096f), -32768, 32767)); } - // Triangle refs (4 bytes each) — rooms in order, then catch-all. + // Triangle refs (4 bytes each) - rooms in order, then catch-all. // After GenerateCells, tri-refs are already ordered by cell within each room. for (int i = 0; i < _rooms.Length; i++) { diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs index 13dfbba..d35c8c5 100644 --- a/Runtime/PSXSceneExporter.cs +++ b/Runtime/PSXSceneExporter.cs @@ -24,7 +24,8 @@ public class PSXSceneExporter : MonoBehaviour /// audio without directly referencing the Editor assembly. /// Signature: (AudioClip clip, int sampleRate, bool loop) => byte[] adpcm /// - public static Func AudioConvertDelegate; + // (clip, sampleRate, loop, trimLeadingSilence) -> raw SPU ADPCM + public static Func AudioConvertDelegate; public float GTEScaling = 100.0f; @@ -43,6 +44,13 @@ public class PSXSceneExporter : MonoBehaviour [Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")] public PSXSceneType SceneType = PSXSceneType.Exterior; + [Header("Networking")] + [Tooltip("Stable id for this scene on the network. Consoles must agree on it to share " + + "a room, so pick a name and DO NOT change it once players have the disc " + + "(e.g. \"mygame/lobby\"). Leave empty to fall back to the derived hash, which " + + "changes whenever you add an object to the scene.")] + public string SceneNetworkId = ""; + [Header("Cutscenes")] [Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")] public PSXCutsceneClip[] Cutscenes = new PSXCutsceneClip[0]; @@ -71,8 +79,9 @@ public class PSXSceneExporter : MonoBehaviour private PSXInteractable[] _interactables; private PSXAudioClip[] _audioSources; private PSXTriggerBox[] _triggerBoxes; + private PSXAgent[] _agents; - // ── Post-export data for memory analysis ── + // -- Post-export data for memory analysis -- /// Texture atlases from the last export (null before first export). public TextureAtlas[] LastExportAtlases => _atlases; /// Custom font data from the last export. @@ -102,6 +111,7 @@ public int LastExportTriangleCount // Phase 6: UI canvases private PSXCanvasData[] _canvases; private PSXFontData[] _fonts; + private List _spriteSheets; private PSXData _psxData; @@ -170,7 +180,7 @@ public void ExportToPath(string outputPath) // FindObjectsByType, others don't. We filter them out, then add back // exactly once at the end. // Also exclude any PSXObjectExporter that lives on or under a - // PSXSkinnedObjectExporter — only the proxy should represent those meshes. + // PSXSkinnedObjectExporter - only the proxy should represent those meshes. var proxySet = new System.Collections.Generic.HashSet(); foreach (var skinExp in _skinnedExporters) { @@ -196,13 +206,13 @@ public void ExportToPath(string outputPath) } if (underSkinned) { - Debug.Log($"[Export] Skipping PSXObjectExporter on '{exp.name}' — covered by PSXSkinnedObjectExporter proxy"); + Debug.Log($"[Export] Skipping PSXObjectExporter on '{exp.name}' - covered by PSXSkinnedObjectExporter proxy"); continue; } exportersList.Add(exp); } - // Append proxies at the end — guaranteed exactly once + // Append proxies at the end - guaranteed exactly once exportersList.AddRange(proxySet); _exporters = exportersList.ToArray(); try @@ -219,6 +229,7 @@ public void ExportToPath(string outputPath) _interactables = FindObjectsByType(FindObjectsSortMode.None); _audioSources = FindObjectsByType(FindObjectsSortMode.None); _triggerBoxes = FindObjectsByType(FindObjectsSortMode.None); + _agents = FindObjectsByType(FindObjectsSortMode.None); // Collect UI image textures for VRAM packing alongside 3D textures PSXUIImage[] uiImages = FindObjectsByType(FindObjectsSortMode.None); @@ -235,6 +246,31 @@ public void ExportToPath(string outputPath) } } + // Sprite sheets ride the SAME atlas as UI images and 3D textures, so + // they go into the same packing list. CollectSheets throws on bad + // authoring (a grid that does not divide the texture, a duplicate + // name, an animation running off the end) - better here, naming the + // asset, than as an assert on the console. + _spriteSheets = PSXSpriteExporter.CollectSheets(); + // The tilemap's tileset must live in the SAME sheet table, so the + // engine can resolve it by index. Pull it in here if no PSXSprite + // already referenced it - otherwise the map would export with a + // dangling tileset index. Distinctness keeps it from packing twice. + PSXSpriteSheet tilesetSheet = PSXTilemapExporter.CollectTileset(); + if (tilesetSheet != null && !_spriteSheets.Contains(tilesetSheet)) + _spriteSheets.Add(tilesetSheet); + foreach (PSXSpriteSheet sheet in _spriteSheets) + { + Utils.SetTextureImporterFormat(sheet.SourceTexture, true); + // cutout: a sprite is a character, not a rectangle. Without this + // the quantizer throws the alpha away and every sprite ships + // inside an opaque box. + PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(sheet.SourceTexture, sheet.BitDepth, cutout: true); + tex.OriginalTexture = sheet.SourceTexture; + sheet.PackedTexture = tex; + uiTextures.Add(tex); + } + EditorUtility.ClearProgressBar(); PackTextures(uiTextures); @@ -243,6 +279,7 @@ public void ExportToPath(string outputPath) _canvases = PSXUIExporter.CollectCanvases(selectedResolution, out _fonts); PSXPlayer player = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault(); + PSXNavigationSettings navSettings = FindObjectsByType(FindObjectsSortMode.None).FirstOrDefault(); if (player != null) { player.FindNavmesh(); @@ -255,6 +292,17 @@ public void ExportToPath(string outputPath) _gravity = player.Gravity; _playerRot = player.transform.rotation; } + else + { + _playerPos = navSettings != null ? navSettings.SpawnPoint : Vector3.zero; + _playerRot = Quaternion.identity; + _playerHeight = navSettings != null ? navSettings.AgentHeight : 1.8f; + _playerRadius = navSettings != null ? navSettings.AgentRadius : 0.5f; + _moveSpeed = 3.0f; + _sprintSpeed = 8.0f; + _jumpHeight = 2.0f; + _gravity = 20.0f; + } _bvh = new BVH(_exporters.ToList()); _bvh.Build(); @@ -274,7 +322,24 @@ public void ExportToPath(string outputPath) _navRegionBuilder = new PSXNavRegionBuilder(); _navRegionBuilder.AgentRadius = _playerRadius; _navRegionBuilder.AgentHeight = _playerHeight; - if (player != null) + if (navSettings != null) + { + _navRegionBuilder.AgentRadius = navSettings.AgentRadius; + _navRegionBuilder.AgentHeight = navSettings.AgentHeight; + _navRegionBuilder.MaxStepHeight = navSettings.MaxStepHeight; + _navRegionBuilder.WalkableSlopeAngle = navSettings.WalkableSlopeAngle; + _navRegionBuilder.CellSize = navSettings.NavCellSize; + _navRegionBuilder.CellHeight = navSettings.NavCellHeight; + _navRegionBuilder.MinRegionArea = navSettings.NavMinRegionArea; + _navRegionBuilder.MergeRegionArea = navSettings.NavMergeRegionArea; + _navRegionBuilder.MaxSimplifyError = navSettings.NavMaxSimplifyError; + _navRegionBuilder.MaxEdgeLength = navSettings.NavMaxEdgeLength; + _navRegionBuilder.PartitionMethod = navSettings.NavPartitionMethod; + _navRegionBuilder.DetailSampleDist = navSettings.NavDetailSampleDist; + _navRegionBuilder.DetailMaxError = navSettings.NavDetailMaxError; + _navRegionBuilder.MaxPlaneError = navSettings.NavMaxPlaneError; + } + else if (player != null) { _navRegionBuilder.MaxStepHeight = player.MaxStepHeight; _navRegionBuilder.WalkableSlopeAngle = player.WalkableSlopeAngle; @@ -309,7 +374,8 @@ public void ExportToPath(string outputPath) if (walkoffZones != null && walkoffZones.Length > 0) _navRegionBuilder.WalkoffZones = walkoffZones; - _navRegionBuilder.Build(_exporters, _playerPos); + Vector3 navSpawn = player != null ? _playerPos : (navSettings != null ? navSettings.SpawnPoint : _playerPos); + _navRegionBuilder.Build(_exporters, navSpawn); if (_navRegionBuilder.RegionCount == 0) Debug.LogWarning("No nav regions! Enable 'Generate Navigation' on your floor meshes."); @@ -414,7 +480,8 @@ void ExportFile(string outputPath = null) { if (AudioConvertDelegate == null) throw new InvalidOperationException("AudioConvertDelegate not set. Ensure PSXAudioConverter registers it."); - byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop); + byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop, + src.TrimLeadingSilence); list.Add(new AudioClipExport { adpcmData = adpcm, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName }); } else @@ -467,6 +534,7 @@ void ExportFile(string outputPath = null) canvases = _canvases, fonts = _fonts, triggerBoxes = _triggerBoxes, + agents = _agents, bakedSkinData = _bakedSkinData, skinnedExporters = _skinnedExporters, memCardEnabled = _psxData != null && _psxData.MemCardEnabled, @@ -474,6 +542,12 @@ void ExportFile(string outputPath = null) memCardProduct = _psxData != null ? _psxData.MemCardProduct : "SLUS-00000", memCardTitle = _psxData != null ? _psxData.MemCardTitle : "PSXSPLASH SAVE", memCardIcons = _psxData != null ? _psxData.MemCardIcons : null, + spriteSheets = _spriteSheets != null ? _spriteSheets.ToArray() : new PSXSpriteSheet[0], + sceneNetworkId = SceneNetworkId, + // Flattened against the FINAL sheet list (tileset already added + // above), so the tileset index the map stores is the one the + // writer emits. Null when the scene has no tilemap. + tilemap = PSXTilemapExporter.Flatten(_spriteSheets), }; PSXSceneWriter.Write(path, in scene, (msg, type) => diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs index 63afd42..74e5b78 100644 --- a/Runtime/PSXSceneWriter.cs +++ b/Runtime/PSXSceneWriter.cs @@ -50,6 +50,7 @@ public struct SceneData // Trigger boxes (v16) public PSXTriggerBox[] triggerBoxes; + public PSXAgent[] agents; // Skinned mesh data (v18) public PSXSkinnedMeshExporter.BakedSkinData[] bakedSkinData; @@ -77,9 +78,21 @@ public struct SceneData public string memCardProduct; // up to 10 chars, e.g. "SLUS-00000" public string memCardTitle; // ASCII title shown in the BIOS public Texture2D[] memCardIcons; // 1..3 frames, each exactly 16x16 + + // Sprites (v22) + public PSXSpriteSheet[] spriteSheets; + + // Authored network scene id (v22). Hashed into the pack; consoles + // must agree on it to share a room. Empty means "not authored" and + // the runtime falls back to its derived hash. + public string sceneNetworkId; + + // Tilemap (v23). Null when the scene has no tilemap, which writes the + // header offset as 0 - exactly what a v22 pack left there. + public PSXTilemapData tilemap; } - // ─── Offset bookkeeping ─── + // --- Offset bookkeeping --- private sealed class OffsetData { @@ -87,15 +100,35 @@ private sealed class OffsetData public readonly List DataOffsets = new List(); } - // ═══════════════════════════════════════════════════════════════ + /// + /// Write a null-terminated name at the current position and backfill the + /// placeholder that should point at it. Names are capped at 24 chars to + /// match every other name in the format. + /// + private static void WriteNameAndBackfill(BinaryWriter writer, string name, long placeholderPos) + { + string s = name ?? ""; + if (s.Length > 24) s = s.Substring(0, 24); + + long namePos = writer.BaseStream.Position; + writer.Write(Encoding.UTF8.GetBytes(s)); + writer.Write((byte)0); + + long curPos = writer.BaseStream.Position; + writer.Seek((int)placeholderPos, SeekOrigin.Begin); + writer.Write((uint)namePos); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + // --------------------------------------------------------------- // Public API - // ═══════════════════════════════════════════════════════════════ + // --------------------------------------------------------------- /// /// Serialize the scene to splashpack v20 format: three separate files. - /// - path → .splashpack (live data only) - /// - path.Replace(…).vram → VRAM bulk data (atlas pixels + CLUTs + font pixels) - /// - path.Replace(…).spu → SPU bulk data (audio ADPCM) + /// - path -> .splashpack (live data only) + /// - path.Replace(...).vram -> VRAM bulk data (atlas pixels + CLUTs + font pixels) + /// - path.Replace(...).spu -> SPU bulk data (audio ADPCM) /// /// Absolute file path to write (splashpack). /// Pre-built scene data. @@ -160,12 +193,22 @@ public static void Write(string path, in SceneData scene, Action spriteSheetData = new List(); + List spriteAnimData = new List(); + if (scene.spriteSheets != null && scene.spriteSheets.Length > 0) + { + PSXSpriteExporter.Flatten(new List(scene.spriteSheets), + out spriteSheetData, out spriteAnimData); + } + + // ------------------------------------------------------ + // Header (144 bytes - splashpack v23) + // ------------------------------------------------------ writer.Write('S'); writer.Write('P'); - writer.Write((ushort)21); + writer.Write((ushort)23); writer.Write((ushort)luaFiles.Count); writer.Write((ushort)scene.exporters.Length); writer.Write((ushort)scene.atlases.Length); @@ -289,7 +332,7 @@ public static void Write(string path, in SceneData scene, Action skinnedProxySet = new HashSet(); if (scene.skinnedExporters != null) @@ -340,7 +398,7 @@ public static void Write(string path, in SceneData scene, Action() : null; + int goIndex = (exp != null && exporterIndex.TryGetValue(exp, out int idx)) ? idx : 0xFFFF; + + int waypointCount = 0; + byte flags = 0; + if (agent != null) + { + if (agent.StartEnabled) flags |= 0x01; + if (agent.HasVision && agent.VisionRange > 0f) flags |= 0x02; + if (agent.HasHearing && agent.HearingRange > 0f) flags |= 0x04; + waypointCount = Mathf.Min(agent.PatrolWaypoints?.Count ?? 0, 8); + if (waypointCount > 0) flags |= 0x08; + } + + // Resolve per-state clip indices (0xFF = none) + byte[] clipIndices = new byte[8]; + for (int s = 0; s < 8; s++) clipIndices[s] = 0xFF; + if (agent != null && agent.StateAnims != null && scene.animations != null) + { + for (int s = 0; s < 8 && s < agent.StateAnims.Length; s++) + { + var clip = agent.StateAnims[s].clip; + if (clip == null) continue; + for (int c = 0; c < scene.animations.Length; c++) + { + if (scene.animations[c] == clip) + { + clipIndices[s] = (byte)(c < 255 ? c : 0xFE); + break; + } + } + } + } + + float movePerFrame = agent != null ? agent.MoveSpeed / 30f / gte : 0f; + float stopDistNorm = agent != null ? agent.StopDistance / gte : 0f; + float visionNorm = agent != null ? agent.VisionRange / gte : 0f; + float hearingNorm = agent != null ? agent.HearingRange / gte : 0f; + short cosAngle = agent != null ? agent.VisionCosAngleFp12 : (short)0; + ushort alertFrames = (ushort)(agent != null ? Mathf.Min(agent.AlertTimeoutFrames, 65535) : 0); + byte regionDepth = (byte)(agent != null ? Mathf.Min(agent.VisionRegionDepth, 8) : 0); + + // --- struct fields (28 bytes total) --- + writer.Write((ushort)goIndex); // +0 + writer.Write(flags); // +2 + writer.Write((byte)waypointCount); // +3 + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(movePerFrame * 4096f), 0, 65535)); // +4 + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(stopDistNorm * 4096f), 0, 65535)); // +6 + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(visionNorm * 4096f), 0, 65535)); // +8 + writer.Write(cosAngle); // +10 + writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(hearingNorm * 4096f), 0, 65535)); // +12 + writer.Write(alertFrames); // +14 + writer.Write(clipIndices[0]); writer.Write(clipIndices[1]); // +16 + writer.Write(clipIndices[2]); writer.Write(clipIndices[3]); // +18 + writer.Write(clipIndices[4]); writer.Write(clipIndices[5]); // +20 + writer.Write(clipIndices[6]); writer.Write(clipIndices[7]); // +22 + writer.Write(regionDepth); // +24 + writer.Write((byte)0); writer.Write((byte)0); writer.Write((byte)0); // +25 reserved + // total = 28 + } + + // Pass 2 - write waypoint payload for all agents (packed int32 XYZ fp12) + foreach (PSXAgent agent in scene.agents) + { + if (agent == null) continue; + var wps = agent.PatrolWaypoints; + if (wps == null) continue; + int count = Mathf.Min(wps.Count, 8); + for (int w = 0; w < count; w++) + { + Vector3 worldPos = wps[w]; + writer.Write((int)Mathf.RoundToInt(worldPos.x / gte * 4096f)); + writer.Write((int)Mathf.RoundToInt(worldPos.y / gte * 4096f)); + writer.Write((int)Mathf.RoundToInt(worldPos.z / gte * 4096f)); + } + } + } + + // ------------------------------------------------------ // Nav region data (version 7+) - // ────────────────────────────────────────────────────── + // ------------------------------------------------------ if (scene.navRegionBuilder.RegionCount > 0) { AlignToFourBytes(writer); scene.navRegionBuilder.WriteToBinary(writer, gte); } - // ────────────────────────────────────────────────────── + // ------------------------------------------------------ // Room/portal data (version 11, interior scenes) // Must be in the sequential cursor section (after nav regions, // before atlas metadata) so the C++ reader can find it. - // ────────────────────────────────────────────────────── + // ------------------------------------------------------ if (scene.roomBuilder != null && scene.roomBuilder.RoomCount > 0) { AlignToFourBytes(writer); @@ -479,9 +623,9 @@ public static void Write(string path, in SceneData scene, Action audioDataOffsetPositions = new List(); if (audioClipCount > 0 && scene.audioClips != null) { @@ -683,9 +827,9 @@ public static void Write(string path, in SceneData scene, Action 0) { PSXCutsceneExporter.ExportCutscenes( @@ -708,9 +852,9 @@ public static void Write(string path, in SceneData scene, Action 0) { PSXAnimationExporter.ExportAnimations( @@ -731,7 +875,7 @@ public static void Write(string path, in SceneData scene, Action 0 && scene.bakedSkinData != null) { PSXSkinnedMeshExporter.ExportSkinData( @@ -758,21 +902,21 @@ public static void Write(string path, in SceneData scene, Action fontDataOffsetPositions = new List(); if ((uiCanvasCount > 0 && scene.canvases != null) || uiFontCount > 0) { AlignToFourBytes(writer); long uiTableStart = writer.BaseStream.Position; - // ── Font descriptors (112 bytes each) ── + // -- Font descriptors (112 bytes each) -- // Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2) // dataOffset(4) dataSize(4) if (scene.fonts != null) @@ -799,14 +943,14 @@ public static void Write(string path, in SceneData scene, Action canvasDataOffsetPos = new List(); List canvasNameOffsetPos = new List(); for (int ci = 0; ci < uiCanvasCount; ci++) @@ -883,7 +1027,17 @@ public static void Write(string path, in SceneData scene, Action 0) + { + // The engine reads the 20-byte sheet / 12-byte anim records as + // packed structs whose first field is a uint32 (nameOffset), so + // the table MUST start 4-byte aligned - an unaligned `lw` is a + // hardware LoadAddressError, not a silent misread. The offset is + // otherwise whatever the preceding variable-length data (name + // strings, etc.) leaves it at, which is even only by luck: a + // scene that grew from 0x7B14 to 0xBFF5 crashed exactly here. + // Record sizes are multiples of 4, so aligning the start aligns + // every record; the trailing name strings are read byte-wise. + AlignToFourBytes(writer); + long spriteTableStart = writer.BaseStream.Position; + List sheetNameOffsetPos = new List(); + List animNameOffsetPos = new List(); + + foreach (var s in spriteSheetData) + { + sheetNameOffsetPos.Add(writer.BaseStream.Position); + writer.Write((uint)0); // nameOffset placeholder + writer.Write(s.TexpageX); + writer.Write(s.TexpageY); + writer.Write(s.U0); + writer.Write(s.V0); + writer.Write(s.ClutX); + writer.Write(s.ClutY); + writer.Write(s.CellW); + writer.Write(s.CellH); + writer.Write(s.Cols); + writer.Write(s.Rows); + writer.Write(s.BitDepthIndex); + writer.Write((byte)0); // pad0 + writer.Write((ushort)0); // pad1 + } + + foreach (var a in spriteAnimData) + { + animNameOffsetPos.Add(writer.BaseStream.Position); + writer.Write((uint)0); // nameOffset placeholder + writer.Write(a.Sheet); + writer.Write(a.FirstFrame); + writer.Write(a.FrameCount); + writer.Write(a.FrameDuration); + writer.Write((byte)(a.Loop ? 1 : 0)); + writer.Write((byte)0); // pad0 + writer.Write((ushort)0); // pad1 + } + + // Name strings, then backfill every offset that points at one. + for (int i = 0; i < spriteSheetData.Count; i++) + WriteNameAndBackfill(writer, spriteSheetData[i].Name, sheetNameOffsetPos[i]); + for (int i = 0; i < spriteAnimData.Count; i++) + WriteNameAndBackfill(writer, spriteAnimData[i].Name, animNameOffsetPos[i]); + + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)spriteTableOffsetPos, SeekOrigin.Begin); + writer.Write((uint)spriteTableStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + log?.Invoke($"{spriteSheetData.Count} sprite sheets " + + $"({spriteAnimData.Count} animations) written.", LogType.Log); + } + + // ------------------------------------------------------ + // Tilemap (v23) + // ------------------------------------------------------ + // Layout must match tilesystem.cpp's SPLASHPACKTilemap (20 B), + // TileCell (2 B) and TileObject (6 B), which static_assert their + // sizes. The header's first field is a uint16, and the object + // records read uint16 coords with `lh`, so the whole chunk must + // start 4-byte aligned - the same LoadAddressError trap the sprite + // table has. Cells and objects are read straight from here, so the + // order below IS the runtime's memory layout. + if (scene.tilemap != null) + { + PSXTilemapData tm = scene.tilemap; + AlignToFourBytes(writer); + long tilemapStart = writer.BaseStream.Position; + + long cellsOffsetPos, objectsOffsetPos; + + // SPLASHPACKTilemap header (20 bytes). + writer.Write(tm.Width); + writer.Write(tm.Height); + writer.Write(tm.TileW); + writer.Write(tm.TileH); + writer.Write(tm.TilesetSheet); + writer.Write((byte)0); // pad0 + writer.Write((ushort)tm.Objects.Count); + writer.Write((ushort)0); // pad1 + cellsOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // cellsOffset placeholder + objectsOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // objectsOffset placeholder + + // Cells: 2 bytes each (tile, flags), row-major. Byte-addressed, + // so no alignment needed, but the header above left us 4-aligned. + long cellsStart = writer.BaseStream.Position; + writer.Write(tm.Cells); + + // Objects: 6 bytes each (kind, id, tileX u16, tileY u16). The + // u16 fields want 2-byte alignment; cells are an even count of + // bytes (2 per cell) so we are still even here, and each record + // is 6 bytes, keeping every tileX/tileY 2-aligned. + long objectsStart = writer.BaseStream.Position; + foreach (var o in tm.Objects) + { + writer.Write(o.Kind); + writer.Write(o.Id); + writer.Write(o.TileX); + writer.Write(o.TileY); + } + + long curPos = writer.BaseStream.Position; + writer.Seek((int)cellsOffsetPos, SeekOrigin.Begin); + writer.Write((uint)cellsStart); + writer.Seek((int)objectsOffsetPos, SeekOrigin.Begin); + writer.Write((uint)(tm.Objects.Count > 0 ? objectsStart : 0)); + writer.Seek((int)tilemapTableOffsetPos, SeekOrigin.Begin); + writer.Write((uint)tilemapStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + + log?.Invoke($"Tilemap {tm.Width}x{tm.Height} tile={tm.TileW}x{tm.TileH} " + + $"sheet={tm.TilesetSheet} objects={tm.Objects.Count} written.", LogType.Log); + } + + // ------------------------------------------------------ + // NO MORE DEAD ZONE - pixel/audio data goes into separate files. // pixelDataOffset is written as 0 to signal v20 format. - // ══════════════════════════════════════════════════════ + // ------------------------------------------------------ // Backfill pixelDataOffset as 0 (signals: no dead zone in this file) { @@ -1014,7 +1304,7 @@ public static void Write(string path, in SceneData scene, Action 0 (data is in .spu file) foreach (var pos in audioDataOffsetPositions) { long curPos = writer.BaseStream.Position; @@ -1023,7 +1313,7 @@ public static void Write(string path, in SceneData scene, Action 0 (data is in .vram file) foreach (var pos in fontDataOffsetPositions) { long curPos = writer.BaseStream.Position; @@ -1047,16 +1337,16 @@ public static void Write(string path, in SceneData scene, Action Clips = new List(); } - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ // Proxy creation - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ /// /// Creates a proxy GameObject with MeshFilter + MeshRenderer + PSXObjectExporter @@ -169,9 +169,9 @@ private static void SetPrivateField(object obj, string fieldName, object value) if (field != null) field.SetValue(obj, value); } - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ // Humanoid import auto-detection - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ /// /// Checks whether any assigned animation clip on this skinned exporter is a @@ -256,9 +256,9 @@ public static void EnsureHumanoidImportIfNeeded(PSXSkinnedObjectExporter skinExp #endif } - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ // Bone baking - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ /// /// Bake all animation clips for a PSXSkinnedObjectExporter. @@ -313,10 +313,10 @@ public static BakedSkinData BakeSkinData( BoneCount = boneCount, }; - // ── Compute per-vertex bone indices (hard skinning: highest weight wins) ── + // -- Compute per-vertex bone indices (hard skinning: highest weight wins) -- result.BoneIndices = ComputePerTriangleBoneIndices(mesh, smr); - // ── Bake animation clips ── + // -- Bake animation clips -- // Capture objectInverse and uniformScale ONCE before sampling. // This must be fixed so that bone world-space movement (root motion) // is captured relative to the original object position. @@ -326,7 +326,7 @@ public static BakedSkinData BakeSkinData( Debug.Log($"[SkinBake] gteScaling={gteScaling}"); Debug.Log($"[SkinBake] AnimationClips count={skinExp.AnimationClips?.Length ?? 0}, TargetFPS={skinExp.TargetFPS}"); - // ── Find or create an Animator on the correct bone hierarchy root ── + // -- Find or create an Animator on the correct bone hierarchy root -- // The Avatar's bone paths (e.g. "Armature/hips") are relative to the // FBX root transform. skinExp may be a child/sibling of that root, // NOT the root itself. We probe each ancestor of rootBone with a @@ -780,9 +780,9 @@ private static BakedBoneMatrix ConvertToPSXBoneMatrix(Matrix4x4 m, float gteScal }; } - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ // Binary serialization - // ════════════════════════════════════════════════════════════ + // ------------------------------------------------------------ /// /// Write the skin data section to the splashpack binary. @@ -803,7 +803,7 @@ public static void ExportSkinData( AlignToFourBytes(writer); skinTableStart = writer.BaseStream.Position; - // ── Skin table: 12 bytes per entry ── + // -- Skin table: 12 bytes per entry -- long[] entryPositions = new long[count]; for (int i = 0; i < count; i++) { @@ -816,7 +816,7 @@ public static void ExportSkinData( writer.Write((uint)0); // nameOffset placeholder } - // ── Per-mesh skin data blocks ── + // -- Per-mesh skin data blocks -- for (int si = 0; si < count; si++) { var skin = skinData[si]; diff --git a/Runtime/PSXSprite.cs b/Runtime/PSXSprite.cs new file mode 100644 index 0000000..23af2aa --- /dev/null +++ b/Runtime/PSXSprite.cs @@ -0,0 +1,28 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Marks a sheet as used by this scene, so the exporter packs it into VRAM + /// and lists it in the splashpack. + /// + /// + /// Sprite INSTANCES are created at runtime from Lua (Sprite.Create), not + /// authored here. That is the opposite of the UI, and deliberately so: a game + /// spawns and destroys sprites as players join, bullets fire and props die, + /// and none of that is known at export time. What must be authored is the + /// data - the sheets and their animations - because that is what has to be + /// resident in VRAM. + /// + /// Put one of these anywhere in the scene for each sheet the scene uses. + /// + [DisallowMultipleComponent] + [AddComponentMenu("PSX/PSX Sprite Sheet Ref")] + public class PSXSprite : MonoBehaviour + { + [Tooltip("The sheet this scene needs in VRAM. Referenced from Lua by its sheet name.")] + [SerializeField] private PSXSpriteSheet sheet; + + public PSXSpriteSheet Sheet => sheet; + } +} diff --git a/Runtime/PSXSprite.cs.meta b/Runtime/PSXSprite.cs.meta new file mode 100644 index 0000000..c45d225 --- /dev/null +++ b/Runtime/PSXSprite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2af3656393d8a64d9e21a246e89b9f5 \ No newline at end of file diff --git a/Runtime/PSXSpriteExporter.cs b/Runtime/PSXSpriteExporter.cs new file mode 100644 index 0000000..1879248 --- /dev/null +++ b/Runtime/PSXSpriteExporter.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// Flattened sheet record, ready for the binary writer. + public class PSXSpriteSheetData + { + public string Name; + public byte TexpageX, TexpageY; + public ushort ClutX, ClutY; + public byte U0, V0; + public byte CellW, CellH; + public byte Cols, Rows; + public byte BitDepthIndex; // 0 = 4bpp, 1 = 8bpp, 2 = 16bpp + } + + /// Flattened animation record, ready for the binary writer. + public class PSXSpriteAnimData + { + public string Name; + public byte Sheet; + public byte FirstFrame; + public byte FrameCount; + public byte FrameDuration; + public bool Loop; + } + + /// + /// Collects the scene's sprite sheets and flattens them for export. + /// + public static class PSXSpriteExporter + { + // Must match SPRITE_MAX_SHEETS / SPRITE_MAX_ANIMS in the engine's + // spritesystem.hh, which asserts on overflow at load. Failing here, with + // the sheet's name in hand, beats an assert on the console. + public const int MaxSheets = 16; + public const int MaxAnims = 64; + + /// + /// Find every distinct sheet the scene references. Called BEFORE VRAM + /// packing so the textures can join the atlas. + /// + public static List CollectSheets() + { + var refs = Object.FindObjectsByType(FindObjectsSortMode.None); + + // A PSXUISprite references a sheet exactly as a PSXSprite does, and a + // canvas built out of them may be the ONLY thing in the scene that + // uses that sheet - a task panel with no world sprites at all. Left + // out here the sheet never joins the atlas, and every element drawn + // from it exports with a null PackedTexture: a screen of garbage + // texels, with nothing in the log to say why. + // + // Inactive ones count. PSXUIExporter walks canvases with + // GetComponentsInChildren(true), so a deactivated element is still + // exported; if the search here skipped it the element would ship with + // UVs into an unpacked sheet. + var uiRefs = Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + + // Distinct: two objects referencing one sheet must not pack it twice. + var sheets = refs.Select(r => r.Sheet) + .Concat(uiRefs.Select(r => r.Sheet)) + .Where(s => s != null) + .Distinct() + .ToList(); + + var problems = new List(); + var names = new HashSet(); + foreach (var sheet in sheets) + { + foreach (var p in sheet.Validate()) + problems.Add($"[SpriteSheet '{sheet.name}'] {p}"); + + if (!names.Add(sheet.SheetName)) + problems.Add($"[SpriteSheet] Two sheets are both named '{sheet.SheetName}'; " + + "Sprite.SheetIndex would only ever find the first."); + } + + if (sheets.Count > MaxSheets) + problems.Add($"[SpriteSheet] Scene uses {sheets.Count} sheets; the runtime pool holds {MaxSheets}."); + + int animTotal = sheets.Sum(s => s.Animations.Count); + if (animTotal > MaxAnims) + problems.Add($"[SpriteSheet] Scene defines {animTotal} animations; the runtime pool holds {MaxAnims}."); + + if (problems.Count > 0) + throw new System.InvalidOperationException( + "Sprite sheet export failed:\n " + string.Join("\n ", problems)); + + return sheets; + } + + /// + /// Flatten packed sheets into writer records. Called AFTER VRAM packing, + /// once PackedTexture has real coordinates. + /// + public static void Flatten(List sheets, + out List sheetData, + out List animData) + { + sheetData = new List(); + animData = new List(); + + for (int i = 0; i < sheets.Count; i++) + { + PSXSpriteSheet sheet = sheets[i]; + PSXTexture2D tex = sheet.PackedTexture; + if (tex == null) + { + Debug.LogWarning($"[SpriteSheet] '{sheet.SheetName}' has NULL PackedTexture!"); + continue; + } + + // A 4bpp texture stores four texels per VRAM hword, so the + // packer's X is in hwords and the U axis needs expanding - the + // same conversion PSXUIExporter does for UI images. + int expander = 16 / (int)tex.BitDepth; + + sheetData.Add(new PSXSpriteSheetData + { + Name = sheet.SheetName, + TexpageX = tex.TexpageX, + TexpageY = tex.TexpageY, + ClutX = (ushort)tex.ClutPackingX, + ClutY = (ushort)tex.ClutPackingY, + U0 = (byte)(tex.PackingX * expander), + V0 = (byte)tex.PackingY, + CellW = (byte)sheet.CellWidth, + CellH = (byte)sheet.CellHeight, + Cols = (byte)sheet.Columns, + Rows = (byte)sheet.Rows, + BitDepthIndex = tex.BitDepth switch + { + PSXBPP.TEX_4BIT => (byte)0, + PSXBPP.TEX_8BIT => (byte)1, + PSXBPP.TEX_16BIT => (byte)2, + _ => (byte)2 + }, + }); + + foreach (var anim in sheet.Animations) + { + animData.Add(new PSXSpriteAnimData + { + Name = anim.animationName, + Sheet = (byte)i, + FirstFrame = (byte)Mathf.Clamp(anim.firstFrame, 0, 255), + FrameCount = (byte)Mathf.Clamp(anim.frameCount, 0, 255), + FrameDuration = (byte)Mathf.Clamp(anim.frameDuration, 1, 255), + Loop = anim.loop, + }); + } + + Debug.Log($"[SpriteSheet] '{sheet.SheetName}' bpp={(int)tex.BitDepth} " + + $"grid={sheet.Columns}x{sheet.Rows} cell={sheet.CellWidth}x{sheet.CellHeight} " + + $"tpage=({tex.TexpageX},{tex.TexpageY}) uv0=({(byte)(tex.PackingX * expander)},{(byte)tex.PackingY}) " + + $"anims={sheet.Animations.Count}"); + } + } + + /// + /// FNV-1a 32, the authored scene id's hash. Matches the runtime's + /// expectation that the id is opaque and stable - the same string always + /// produces the same number, on any machine, in any Unity version. + /// + public static uint HashSceneId(string id) + { + if (string.IsNullOrEmpty(id)) return 0; + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + uint hash = offsetBasis; + foreach (char c in id) + { + hash ^= (byte)c; + hash *= prime; + } + // 0 is the sentinel for "not authored", so never hand it back as a + // real hash; a scene id that happens to collide with it would + // silently fall back to the derived hash. + return hash == 0 ? 1u : hash; + } + } +} diff --git a/Runtime/PSXSpriteExporter.cs.meta b/Runtime/PSXSpriteExporter.cs.meta new file mode 100644 index 0000000..585a425 --- /dev/null +++ b/Runtime/PSXSpriteExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb5e9ab74714f094a9e7b869b59de0dc \ No newline at end of file diff --git a/Runtime/PSXSpriteSheet.cs b/Runtime/PSXSpriteSheet.cs new file mode 100644 index 0000000..484d267 --- /dev/null +++ b/Runtime/PSXSpriteSheet.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A named run of consecutive cells on a sheet. + /// + [Serializable] + public class PSXSpriteAnimation + { + [Tooltip("Name used to reference this animation from Lua (max 24 chars).")] + public string animationName = "idle"; + + [Tooltip("First cell of the run, counting left-to-right then top-to-bottom from 0.")] + public int firstFrame = 0; + + [Tooltip("How many consecutive cells the animation covers.")] + public int frameCount = 1; + + [Tooltip("How many vsync frames each cell is held. At 60Hz, 6 gives 10 fps.")] + public int frameDuration = 6; + + [Tooltip("Restart at the first frame when the run ends; otherwise hold the last.")] + public bool loop = true; + } + + /// + /// A sprite sheet: one texture cut into a uniform grid of cells, plus the + /// animations defined over that grid. + /// + /// + /// The texture is packed into the same VRAM atlas as UI images and 3D + /// textures, so a sheet costs exactly what its pixels cost and nothing more. + /// + /// Cells are addressed by index, counting left-to-right then top-to-bottom, + /// which is the order every sprite tool on earth exports in. + /// + [CreateAssetMenu(fileName = "SpriteSheet", menuName = "PSXSplash/Sprite Sheet")] + public class PSXSpriteSheet : ScriptableObject + { + [Tooltip("Name used to reference this sheet from Lua (max 24 chars).")] + [SerializeField] private string sheetName = "sheet"; + + [Tooltip("Source texture. Quantized and packed into VRAM alongside UI and 3D textures.")] + [SerializeField] private Texture2D sourceTexture; + + [Tooltip("Bit depth for VRAM storage. 4-bit is the cheapest and suits flat 2D art.")] + [SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_4BIT; + + [Tooltip("Cell width in pixels. The texture width should be a whole multiple of this.")] + [SerializeField] private int cellWidth = 16; + + [Tooltip("Cell height in pixels. The texture height should be a whole multiple of this.")] + [SerializeField] private int cellHeight = 16; + + [Tooltip("Animations defined over this sheet's cells.")] + [SerializeField] private List animations = new List(); + + public string SheetName => sheetName; + public Texture2D SourceTexture => sourceTexture; + public PSXBPP BitDepth => bitDepth; + public int CellWidth => Mathf.Max(1, cellWidth); + public int CellHeight => Mathf.Max(1, cellHeight); + public List Animations => animations; + + /// Cells per row, derived from the source texture. + public int Columns => sourceTexture != null ? Mathf.Max(1, sourceTexture.width / CellWidth) : 1; + + /// Rows of cells, derived from the source texture. + public int Rows => sourceTexture != null ? Mathf.Max(1, sourceTexture.height / CellHeight) : 1; + + /// Total addressable cells. + public int CellCount => Columns * Rows; + + /// + /// Filled in by the exporter after VRAM packing, so the writer can emit + /// tpage/clut/UV coordinates. + /// + [NonSerialized] public PSXTexture2D PackedTexture; + + /// + /// Everything wrong with this sheet, in the words a person can act on. + /// The inspector shows it and the exporter refuses to ship it. + /// + public List Validate() + { + var problems = new List(); + + if (string.IsNullOrWhiteSpace(sheetName)) + problems.Add("Sheet name is empty - Lua has no way to reference this sheet."); + else if (sheetName.Length > 24) + problems.Add($"Sheet name '{sheetName}' is {sheetName.Length} chars; the limit is 24."); + + if (sourceTexture == null) + { + problems.Add("No source texture."); + return problems; // the rest is unknowable without it + } + + if (cellWidth <= 0 || cellHeight <= 0) + { + problems.Add("Cell size must be positive."); + return problems; + } + + // A cell grid that does not divide the texture silently shifts every + // frame after the first, which reads as "my animation is subtly + // wrong" rather than as an error. Say it plainly instead. + if (sourceTexture.width % CellWidth != 0) + problems.Add($"Texture width {sourceTexture.width} is not a multiple of cell width {CellWidth}; " + + $"the rightmost {sourceTexture.width % CellWidth}px would be unreachable."); + if (sourceTexture.height % CellHeight != 0) + problems.Add($"Texture height {sourceTexture.height} is not a multiple of cell height {CellHeight}; " + + $"the bottom {sourceTexture.height % CellHeight}px would be unreachable."); + + // The runtime addresses cells and UVs with bytes. + if (CellCount > 255) + problems.Add($"{CellCount} cells exceeds the 255 the runtime can address."); + if (CellWidth > 255 || CellHeight > 255) + problems.Add("Cell size must fit in a byte (max 255)."); + + var seen = new HashSet(); + foreach (var anim in animations) + { + if (string.IsNullOrWhiteSpace(anim.animationName)) + { + problems.Add("An animation has no name."); + continue; + } + if (anim.animationName.Length > 24) + problems.Add($"Animation name '{anim.animationName}' is {anim.animationName.Length} chars; the limit is 24."); + if (!seen.Add(anim.animationName)) + problems.Add($"Duplicate animation name '{anim.animationName}' - lookups return the first only."); + if (anim.frameCount < 1) + problems.Add($"Animation '{anim.animationName}' has no frames."); + if (anim.firstFrame < 0 || anim.firstFrame + anim.frameCount > CellCount) + problems.Add($"Animation '{anim.animationName}' covers cells " + + $"{anim.firstFrame}..{anim.firstFrame + anim.frameCount - 1}, " + + $"but the sheet only has {CellCount} (0..{CellCount - 1})."); + if (anim.frameDuration < 1) + problems.Add($"Animation '{anim.animationName}' has a frame duration below 1; " + + "the runtime clamps this to 1 (advance every frame)."); + } + + return problems; + } + } +} diff --git a/Runtime/PSXSpriteSheet.cs.meta b/Runtime/PSXSpriteSheet.cs.meta new file mode 100644 index 0000000..ca16342 --- /dev/null +++ b/Runtime/PSXSpriteSheet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59d9300751bf4164d845973ba2459642 \ No newline at end of file diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs index da2152a..ee8746b 100644 --- a/Runtime/PSXTexture2D.cs +++ b/Runtime/PSXTexture2D.cs @@ -122,7 +122,18 @@ public class PSXTexture2D /// The input Texture2D. /// The desired bit depth for the PSX texture. /// The created PSXTexture2D. - public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth) + /// + /// Keep the source's transparency. Paletted textures otherwise DISCARD + /// alpha entirely - the quantizer only ever looked at RGB, and any + /// palette entry that landed on pure black was deliberately bumped to + /// opaque near-black. Fine for a UI panel, which is a rectangle; fatal + /// for a sprite, which is a character in a box. + /// + /// With this on, palette entry 0 is the PS1's transparent colour + /// (0x0000) and every pixel below the alpha threshold maps to it. Costs + /// one palette entry. + /// + public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth, bool cutout = false) { PSXTexture2D psxTex = new PSXTexture2D(); Utils.SetTextureImporterFormat(inputTexture, true); @@ -175,7 +186,7 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi psxTex._maxColors = (int)Mathf.Pow(2, (int)bitDepth); - TextureQuantizer.QuantizedResult result = TextureQuantizer.Quantize(inputTexture, psxTex._maxColors); + TextureQuantizer.QuantizedResult result = TextureQuantizer.Quantize(inputTexture, psxTex._maxColors, cutout); int targetCount = 4 - (result.Palette.Count % 4); if (targetCount != 0) { @@ -183,16 +194,22 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi int prevCount = result.Palette.Count; result.Palette.AddRange(new Vector3[targetCount]); } - - foreach (Vector3 color in result.Palette) + + for (int i = 0; i < result.Palette.Count; i++) { + Vector3 color = result.Palette[i]; Color pixel = new Color(color.x, color.y, color.z); VRAMPixel vramPixel = new VRAMPixel { R = (ushort)(pixel.r * 31), G = (ushort)(pixel.g * 31), B = (ushort)(pixel.b * 31) }; + // On a cutout, entry 0 is MEANT to be 0x0000 - that is the whole + // mechanism. Bumping it here would make the sprite's background + // opaque and undo the work. + bool transparentEntry = cutout && i == 0; + // PS1: palette entry 0x0000 is transparent. Any non-transparent palette // color that quantizes to pure black must be bumped to near-black (1,1,1) // with bit15 set to avoid the hardware treating it as see-through. - if (vramPixel.Pack() == 0x0000) + if (!transparentEntry && vramPixel.Pack() == 0x0000) { vramPixel.R = 1; vramPixel.G = 1; diff --git a/Runtime/PSXTilemap.cs b/Runtime/PSXTilemap.cs new file mode 100644 index 0000000..8ae15db --- /dev/null +++ b/Runtime/PSXTilemap.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// One paintable tile "type": the graphic to draw, whether you can stand on + /// it, and an optional opaque gameplay tag. The whole point of a palette is + /// that you decide what a "wall" or a "vent" is ONCE, then paint it everywhere + /// - no per-cell property editing, which is the thing that makes tile editors + /// miserable. + /// + [Serializable] + public class PSXTileBrush + { + [Tooltip("Shown in the palette. Purely for your own sanity while painting.")] + public string name = "floor"; + + [Tooltip("Which cell of the tileset to draw, counting left-to-right then " + + "top-to-bottom from 0. -1 draws nothing (a hole in the floor).")] + public int tilesetCell = 0; + + [Tooltip("Can a player stand here? Off = solid wall.")] + public bool walkable = true; + + [Tooltip("Opaque gameplay tag. 0 = a plain floor/wall (not an object). Any " + + "other value makes this an addressable object the game can find by " + + "kind+id. The ENGINE attaches no meaning to it - your game does. " + + "Name the values in the Tilemap's Object Kind Names list.")] + public int objectKind = 0; + + [Tooltip("Object id for kinds that need a specific one. Leave -1 and the " + + "exporter numbers each kind for you in reading order (1, 2, 3...).")] + public int objectId = -1; + + [Tooltip("Editor tint so this brush is easy to tell apart on the palette " + + "and (faintly) on the map. Not exported.")] + public Color editorColor = Color.white; + } + + /// + /// A hand-painted tile map: one tileset, a grid of cells, and the palette of + /// brushes the cells refer to. Each cell stores a brush INDEX (or -1 for + /// empty), so changing a brush's walkability or graphic updates every cell + /// that uses it at once. + /// + /// + /// Exported as splashpack v23. The tileset is a , + /// so its pixels ride the very same VRAM atlas as UI, 3D textures and sprites + /// - a tilemap costs exactly its cells (2 bytes each) plus its objects. + /// + [CreateAssetMenu(fileName = "Tilemap", menuName = "PSXSplash/Tilemap")] + public class PSXTilemap : ScriptableObject + { + [Tooltip("The tileset. Its cell grid IS the tile grid - tile size comes " + + "from the sheet's Cell Width/Height.")] + [SerializeField] private PSXSpriteSheet tileset; + + [Tooltip("Map width in tiles.")] + [SerializeField] private int width = 32; + + [Tooltip("Map height in tiles.")] + [SerializeField] private int height = 32; + + [Tooltip("The paintable tile types.")] + [SerializeField] private List brushes = new List(); + + [Tooltip("Friendly names for object-kind values, purely for authoring. " + + "Index = the kind value: element 0 is 'none', element 1 is what " + + "kind 1 means in YOUR game, and so on. The engine never sees these " + + "- they only label the dropdown in the painter.")] + [SerializeField] private List objectKindNames = new List { "(none)" }; + + /// Flat row-major grid; each entry is a brush index or -1 (empty). + [SerializeField] private int[] cells = new int[0]; + + public PSXSpriteSheet Tileset => tileset; + public int Width => Mathf.Max(1, width); + public int Height => Mathf.Max(1, height); + public List Brushes => brushes; + + public int TileWidth => tileset != null ? tileset.CellWidth : 16; + public int TileHeight => tileset != null ? tileset.CellHeight : 16; + + public List ObjectKindNames => objectKindNames; + + /// Friendly label for a kind value, or the number if unnamed. + public string KindLabel(int kind) + { + if (objectKindNames != null && kind >= 0 && kind < objectKindNames.Count) + return objectKindNames[kind]; + return kind == 0 ? "(none)" : "kind " + kind; + } + + /// Brush index at (x, y), or -1 for an empty or out-of-range cell. + public int GetCell(int x, int y) + { + EnsureCells(); + if (x < 0 || y < 0 || x >= Width || y >= Height) return -1; + return cells[y * Width + x]; + } + + /// Paint (x, y) with a brush index (or -1 to erase). + public void SetCell(int x, int y, int brushIndex) + { + EnsureCells(); + if (x < 0 || y < 0 || x >= Width || y >= Height) return; + cells[y * Width + x] = brushIndex; + } + + public PSXTileBrush BrushAt(int x, int y) + { + int b = GetCell(x, y); + return (b >= 0 && b < brushes.Count) ? brushes[b] : null; + } + + /// Resize the grid, preserving the overlapping region. + public void Resize(int newWidth, int newHeight) + { + newWidth = Mathf.Clamp(newWidth, 1, 256); + newHeight = Mathf.Clamp(newHeight, 1, 256); + EnsureCells(); + + var next = new int[newWidth * newHeight]; + for (int i = 0; i < next.Length; i++) next[i] = -1; + int copyW = Mathf.Min(Width, newWidth); + int copyH = Mathf.Min(Height, newHeight); + for (int y = 0; y < copyH; y++) + for (int x = 0; x < copyW; x++) + next[y * newWidth + x] = cells[y * Width + x]; + + cells = next; + width = newWidth; + height = newHeight; + } + + /// Make sure matches width*height; fill new cells empty. + public void EnsureCells() + { + int need = Width * Height; + if (cells != null && cells.Length == need) return; + + var next = new int[need]; + for (int i = 0; i < need; i++) next[i] = -1; + if (cells != null) + { + int n = Mathf.Min(cells.Length, need); + Array.Copy(cells, next, n); + } + cells = next; + } + + /// + /// Everything wrong with this map, in words a person can act on. The + /// inspector shows it and the exporter refuses to ship it. + /// + public List Validate() + { + var problems = new List(); + + if (tileset == null) + { + problems.Add("No tileset assigned - there is nothing to draw the tiles from."); + } + else + { + int cellCount = tileset.CellCount; + for (int i = 0; i < brushes.Count; i++) + { + var b = brushes[i]; + if (b.tilesetCell >= cellCount) + problems.Add($"Brush '{b.name}' points at tileset cell {b.tilesetCell}, " + + $"but the tileset only has {cellCount} (0..{cellCount - 1})."); + if (b.tilesetCell < -1) + problems.Add($"Brush '{b.name}' has a negative tileset cell that isn't -1."); + } + } + + if (Width > 256 || Height > 256) + problems.Add($"Map is {Width}x{Height}; each side must be 256 or less (the runtime uses 16-bit coords, but this cap keeps a map's cells under 128 KB)."); + + // Every painted cell must reference a real brush. + EnsureCells(); + int badRefs = 0; + foreach (int c in cells) + if (c >= brushes.Count) badRefs++; + if (badRefs > 0) + problems.Add($"{badRefs} cell(s) reference a brush that no longer exists - repaint or remove them."); + + return problems; + } + } +} diff --git a/Runtime/PSXTilemap.cs.meta b/Runtime/PSXTilemap.cs.meta new file mode 100644 index 0000000..cfc69ed --- /dev/null +++ b/Runtime/PSXTilemap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8521dc98f544aad489f0b7fc7ba8e3b5 \ No newline at end of file diff --git a/Runtime/PSXTilemapExporter.cs b/Runtime/PSXTilemapExporter.cs new file mode 100644 index 0000000..326e120 --- /dev/null +++ b/Runtime/PSXTilemapExporter.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// One addressable object lifted out of the map, ready for the writer. + public class PSXTileObjectData + { + public byte Kind; + public byte Id; + public ushort TileX; + public ushort TileY; + } + + /// Flattened tilemap, ready for the binary writer (splashpack v23). + public class PSXTilemapData + { + public ushort Width; + public ushort Height; + public byte TileW; + public byte TileH; + public byte TilesetSheet; // index into the sprite sheet table + public byte[] Cells; // 2 bytes per cell: tile, flags (row-major) + public List Objects = new List(); + } + + /// + /// Collects the scene's tilemap and flattens it for export. Kept separate from + /// the sprite exporter, but its tileset joins the sprite sheet table so the + /// engine can resolve it by index - see . + /// + public static class PSXTilemapExporter + { + public const int MaxObjects = 128; // must match TILE_MAX_OBJECTS in tilesystem.hh + private const byte TileEmpty = 0xFF; + private const byte FlagWalkable = 0x01; + + /// The scene's tilemap renderer, or null. Errors if there are two. + public static PSXTilemapRenderer FindRenderer() + { + var renderers = Object.FindObjectsByType(FindObjectsSortMode.None); + if (renderers.Length == 0) return null; + if (renderers.Length > 1) + throw new System.InvalidOperationException( + "Tilemap export failed: a scene may have at most one PSXTilemapRenderer, " + + $"but this scene has {renderers.Length}."); + return renderers[0]; + } + + /// + /// The tileset the scene's tilemap needs packed into the VRAM atlas, or + /// null if the scene has no tilemap. The sprite collector calls this so + /// the tileset is packed and indexed like any other sheet. + /// + public static PSXSpriteSheet CollectTileset() + { + var r = FindRenderer(); + return (r != null && r.tilemap != null) ? r.tilemap.Tileset : null; + } + + /// + /// Flatten the scene's tilemap. Returns null when there is no tilemap. + /// is the already-collected sheet list, in the + /// exact order they are written, so the tileset's index is stable. + /// + public static PSXTilemapData Flatten(List sheetList) + { + var r = FindRenderer(); + if (r == null || r.tilemap == null) return null; + + PSXTilemap map = r.tilemap; + + var problems = map.Validate(); + if (problems.Count > 0) + throw new System.InvalidOperationException( + "Tilemap export failed:\n " + string.Join("\n ", problems)); + + int sheetIndex = sheetList != null ? sheetList.IndexOf(map.Tileset) : -1; + if (sheetIndex < 0) + throw new System.InvalidOperationException( + $"Tilemap export failed: tileset '{(map.Tileset != null ? map.Tileset.SheetName : "")}' " + + "was not packed into the sprite sheet table. This is an exporter wiring bug - " + + "PSXTilemapExporter.CollectTileset() must run before sheet packing."); + + map.EnsureCells(); + int w = map.Width, h = map.Height; + + var data = new PSXTilemapData + { + Width = (ushort)w, + Height = (ushort)h, + TileW = (byte)Mathf.Clamp(map.TileWidth, 1, 255), + TileH = (byte)Mathf.Clamp(map.TileHeight, 1, 255), + TilesetSheet = (byte)sheetIndex, + Cells = new byte[w * h * 2], + }; + + // Auto-numbering per kind, in reading order, so the first tile of a + // kind is id 1 - predictable and needing no manual ids. Keyed by the + // opaque kind byte; the exporter never interprets what it means. + var nextId = new Dictionary(); + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + PSXTileBrush b = map.BrushAt(x, y); + int ci = (y * w + x) * 2; + + if (b == null || b.tilesetCell < 0) + { + data.Cells[ci] = TileEmpty; + data.Cells[ci + 1] = 0; + continue; + } + + data.Cells[ci] = (byte)Mathf.Clamp(b.tilesetCell, 0, 254); + data.Cells[ci + 1] = (byte)(b.walkable ? FlagWalkable : 0); + + // Kind 0 is a plain tile; anything else is an addressable object. + if (b.objectKind != 0) + { + int kind = Mathf.Clamp(b.objectKind, 0, 255); + int id; + if (b.objectId >= 0) + { + id = b.objectId; + } + else + { + if (!nextId.TryGetValue(kind, out int n)) n = 1; + id = n; + nextId[kind] = n + 1; + } + + data.Objects.Add(new PSXTileObjectData + { + Kind = (byte)kind, + Id = (byte)Mathf.Clamp(id, 0, 255), + TileX = (ushort)x, + TileY = (ushort)y, + }); + } + } + } + + if (data.Objects.Count > MaxObjects) + throw new System.InvalidOperationException( + $"Tilemap export failed: {data.Objects.Count} addressable object tiles, " + + $"but the runtime pool holds {MaxObjects}. Reduce task/vent/etc. tiles."); + + return data; + } + } +} diff --git a/Runtime/PSXTilemapExporter.cs.meta b/Runtime/PSXTilemapExporter.cs.meta new file mode 100644 index 0000000..57ffcb9 --- /dev/null +++ b/Runtime/PSXTilemapExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0801f7dcc93e8a94384cc9bbe28a9042 \ No newline at end of file diff --git a/Runtime/PSXTilemapRenderer.cs b/Runtime/PSXTilemapRenderer.cs new file mode 100644 index 0000000..3c42233 --- /dev/null +++ b/Runtime/PSXTilemapRenderer.cs @@ -0,0 +1,23 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Drop one of these on a GameObject in a scene to say "this scene has a + /// tilemap." The exporter finds it, packs the tileset into the VRAM atlas + /// alongside the sprites, and writes the map into the splashpack (v23). + /// + /// + /// A scene may have at most one. Position is ignored: the map's origin is + /// tile (0,0) at world (0,0), which is the same space the sprite system and + /// the actors move in, so a bound avatar and the floor it stands on line up + /// for free. + /// + [AddComponentMenu("PSXSplash/PSX Tilemap Renderer")] + public class PSXTilemapRenderer : MonoBehaviour + { + [Tooltip("The tile map asset this scene draws. Author it with " + + "PlayStation 1 > Tile Painter.")] + public PSXTilemap tilemap; + } +} diff --git a/Runtime/PSXTilemapRenderer.cs.meta b/Runtime/PSXTilemapRenderer.cs.meta new file mode 100644 index 0000000..af9cb51 --- /dev/null +++ b/Runtime/PSXTilemapRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1079404698ca1494d8e7ceb3a7054aa2 \ No newline at end of file diff --git a/Runtime/PSXUIBudget.cs b/Runtime/PSXUIBudget.cs new file mode 100644 index 0000000..b19d185 --- /dev/null +++ b/Runtime/PSXUIBudget.cs @@ -0,0 +1,35 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// The runtime's UI pool sizes, mirrored here so authoring can be checked + /// before a disc is built. + /// + /// + /// These MUST match UI_MAX_ELEMENTS / UI_MAX_CANVASES / + /// UI_TEXT_BUF in psxsplash/src/uisystem.hh. + /// + /// The overflow behaviour is what makes a mirrored constant worth keeping: + /// loadFromSplashpack CLAMPS a canvas's element count against the + /// remaining pool and carries on. Nothing is logged, nothing asserts - the + /// elements past the cap simply do not exist, and from the Lua side + /// UI.FindElement returns -1 for them. Half a panel goes missing and + /// the only clue is which half. + /// + public static class PSXUIBudget + { + /// Total elements across every canvas in one scene. + public const int MaxElements = 256; + + /// Canvases in one scene. + public const int MaxCanvases = 24; + + /// Characters of an element's text buffer, null terminator included. + public const int TextBuffer = 64; + + /// Longest string UI.SetText can hold. + public const int MaxTextLength = TextBuffer - 1; + + /// Longest canvas or element name the splashpack stores. + public const int MaxNameLength = 24; + } +} diff --git a/Runtime/PSXUIBudget.cs.meta b/Runtime/PSXUIBudget.cs.meta new file mode 100644 index 0000000..ad7c657 --- /dev/null +++ b/Runtime/PSXUIBudget.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f69012f15d6be5f258d451340031f161 \ No newline at end of file diff --git a/Runtime/PSXUIExporter.cs b/Runtime/PSXUIExporter.cs index 996d67f..2772999 100644 --- a/Runtime/PSXUIExporter.cs +++ b/Runtime/PSXUIExporter.cs @@ -18,7 +18,7 @@ public static class PSXUIExporter /// converting RectTransform coordinates to PS1 pixel space. /// Also collects and deduplicates custom fonts. /// - /// Target PS1 resolution (e.g. 320×240). + /// Target PS1 resolution (e.g. 320x240). /// Output: collected custom font data (max 3). /// Array of canvas data ready for binary writing. public static PSXCanvasData[] CollectCanvases(Vector2 resolution, out PSXFontData[] fonts) @@ -181,15 +181,48 @@ private static PSXCanvasData[] CollectCanvasesInternal(PSXCanvas[] canvases, Vec }); } + WarnOnBudget(result); return result.ToArray(); } - // ─── Coordinate baking helpers ─── + /// + /// Say plainly when a scene has authored more UI than the runtime pool + /// holds. The loader clamps and carries on silently, so without this the + /// symptom is "the last canvas I added does not exist" with nothing in any + /// log to connect it to a limit. + /// + private static void WarnOnBudget(List canvases) + { + int total = 0; + foreach (var cv in canvases) total += cv.Elements?.Length ?? 0; + + if (total > PSXUIBudget.MaxElements) + Debug.LogError($"[UIExporter] {total} UI elements across {canvases.Count} canvases, but the " + + $"runtime pool holds {PSXUIBudget.MaxElements}. The last " + + $"{total - PSXUIBudget.MaxElements} will be DROPPED at load with no error."); + + if (canvases.Count > PSXUIBudget.MaxCanvases) + Debug.LogError($"[UIExporter] {canvases.Count} canvases, but the runtime pool holds " + + $"{PSXUIBudget.MaxCanvases}. The rest will be dropped at load."); + + foreach (var cv in canvases) + { + if ((cv.Elements?.Length ?? 0) <= 255) continue; + Debug.LogError($"[UIExporter] Canvas '{cv.Name}' has {cv.Elements.Length} elements; the " + + "splashpack stores that count in one byte, so it must be 255 or fewer."); + } + } + + // --- Coordinate baking helpers --- /// /// Convert a RectTransform into PS1 pixel-space layout values. - /// Handles anchor-based positioning and Y inversion. /// + /// + /// The arithmetic lives in so the scene-view + /// preview computes the identical rect. This wrapper only unpacks it into + /// the out-parameters the collectors below were written against. + /// private static void BakeLayout( RectTransform rt, RectTransform canvasRect, float scaleX, float scaleY, Vector2 resolution, @@ -197,47 +230,10 @@ private static void BakeLayout( 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); - } + PSXUILayout.Baked b = PSXUILayout.Bake(rt, scaleX, scaleY); + x = b.X; y = b.Y; w = b.W; h = b.H; + anchorMinX = b.AnchorMinX; anchorMinY = b.AnchorMinY; + anchorMaxX = b.AnchorMaxX; anchorMaxY = b.AnchorMaxY; } private static string TruncateName(string name, int maxLen = 24) @@ -246,7 +242,7 @@ private static string TruncateName(string name, int maxLen = 24) return name.Length > maxLen ? name.Substring(0, maxLen) : name; } - // ─── Collectors ─── + // --- Collectors --- /// /// Walk the hierarchy depth-first in sibling order, collecting every @@ -259,7 +255,7 @@ private static void CollectAllElementsInHierarchyOrder( List elements, List uniqueFonts) { - // GetComponentsInChildren iterates depth-first in sibling order — + // GetComponentsInChildren iterates depth-first in sibling order - // exactly the hierarchy ordering we want. Transform[] allTransforms = root.GetComponentsInChildren(true); foreach (Transform t in allTransforms) @@ -269,6 +265,13 @@ private static void CollectAllElementsInHierarchyOrder( // Check each supported component type on this transform. // A single GameObject should only have one PSX UI component, // but we check all to be safe. + PSXUISprite spr = t.GetComponent(); + if (spr != null) + { + CollectSingleSprite(spr, canvasRect, scaleX, scaleY, resolution, elements); + continue; + } + PSXUIImage img = t.GetComponent(); if (img != null) { @@ -365,6 +368,94 @@ private static void CollectSingleImage( elements.Add(data); } + /// + /// One cell of a sprite sheet, exported as an Image element plus the + /// sheet's cell grid so the runtime can re-point it with UI.SetFrame. + /// + /// + /// The sheet's pixels are already in the atlas - PSXSpriteExporter packed + /// them - so this costs no VRAM beyond the sheet itself, however many + /// elements draw from it. That is the whole reason a panel can be built + /// out of forty of these when forty PSXUIImages would be forty textures. + /// + private static void CollectSingleSprite( + PSXUISprite spr, RectTransform canvasRect, + float scaleX, float scaleY, Vector2 resolution, + List elements) + { + RectTransform rt = spr.GetComponent(); + if (rt == null) return; + + 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 = spr.StartVisible, + Name = TruncateName(spr.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(spr.Tint.r * 255f), 0, 255), + ColorG = (byte)Mathf.Clamp(Mathf.RoundToInt(spr.Tint.g * 255f), 0, 255), + ColorB = (byte)Mathf.Clamp(Mathf.RoundToInt(spr.Tint.b * 255f), 0, 255), + }; + + PSXSpriteSheet sheet = spr.Sheet; + PSXTexture2D tex = sheet != null ? sheet.PackedTexture : null; + if (sheet == null || tex == null) + { + // Not fatal: the element still exists, still has its name, and + // Lua's UI.FindElement still resolves it. It just draws nothing + // recognisable - which is a far better handover than an export + // that refuses to run because one icon is unassigned. + Debug.LogWarning($"[UISprite] '{spr.ElementName}' has " + + (sheet == null ? "no sheet assigned." : "a sheet that was not packed - " + + "is it referenced by a PSXSprite or another PSXUISprite in this scene?")); + elements.Add(data); + return; + } + + // A 4bpp texture stores four texels per VRAM halfword, so the packer's + // X is in halfwords and the U axis needs expanding - the same + // conversion PSXSpriteExporter.Flatten does for the sheet table. + int expander = 16 / (int)tex.BitDepth; + int baseU = tex.PackingX * expander; + int baseV = tex.PackingY; + + int cols = Mathf.Max(1, sheet.Columns); + int cell = spr.ResolvedCell; + int u0 = baseU + (cell % cols) * sheet.CellWidth; + int v0 = baseV + (cell / cols) * sheet.CellHeight; + + data.TexpageX = tex.TexpageX; + data.TexpageY = tex.TexpageY; + data.ClutX = (ushort)tex.ClutPackingX; + data.ClutY = (ushort)tex.ClutPackingY; + data.U0 = (byte)u0; + data.V0 = (byte)v0; + // U1/V1 are the LAST texel, inclusive - without the -1 a 256-wide + // sheet overflows the byte to 0 and the element samples a sliver. + data.U1 = (byte)(u0 + sheet.CellWidth - 1); + data.V1 = (byte)(v0 + sheet.CellHeight - 1); + data.BitDepthIndex = tex.BitDepth switch + { + PSXBPP.TEX_4BIT => 0, + PSXBPP.TEX_8BIT => 1, + PSXBPP.TEX_16BIT => 2, + _ => 2 + }; + data.CellW = (byte)sheet.CellWidth; + data.CellH = (byte)sheet.CellHeight; + data.SheetCols = (byte)Mathf.Clamp(cols, 1, 255); + data.BaseU = (byte)baseU; + data.BaseV = (byte)baseV; + + elements.Add(data); + } + private static void CollectSingleBox( PSXUIBox box, RectTransform canvasRect, float scaleX, float scaleY, Vector2 resolution, @@ -489,7 +580,7 @@ private static void CollectSingleProgressBar( }); } - // ─── Legacy per-type collectors (kept for reference, no longer called) ─── + // --- Legacy per-type collectors (kept for reference, no longer called) --- private static void CollectImages( Transform root, RectTransform canvasRect, @@ -520,7 +611,7 @@ private static void CollectImages( }; // Image texture data is filled in after VRAM packing by - // FillImageTextureData() — see PSXSceneExporter integration + // FillImageTextureData() - see PSXSceneExporter integration if (img.PackedTexture != null) { PSXTexture2D tex = img.PackedTexture; diff --git a/Runtime/PSXUILayout.cs b/Runtime/PSXUILayout.cs new file mode 100644 index 0000000..957e4d1 --- /dev/null +++ b/Runtime/PSXUILayout.cs @@ -0,0 +1,128 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// The one place a RectTransform becomes PS1 screen pixels. + /// + /// + /// Both the exporter and the scene-view preview go through here, and that is + /// the point: a preview that computes positions its own way is a preview that + /// can agree with itself and disagree with the console. The layout bugs this + /// project has actually shipped - rows overlapping by three pixels, a panel + /// starting six pixels outside the plate it belonged in - were all "the + /// numbers were worked out twice". + /// + /// mirrors what the exporter writes into the splashpack. + /// then mirrors uisystem.cpp's resolveLayout, + /// which is what the hardware actually draws - anchors applied, clamped to + /// the framebuffer. Preview with Resolve; export with Bake. + /// + public static class PSXUILayout + { + /// An element's baked record, in the units the splashpack stores. + public struct Baked + { + /// Offset from the anchor, in PS1 pixels, Y already inverted. + public short X, Y; + /// Size in PS1 pixels, or the anchor-span inset when stretched. + public short W, H; + /// Anchors in 8.8 fixed point (0 = 0.0, 128 = 0.5, 255 ~ 1.0). + public byte AnchorMinX, AnchorMinY, AnchorMaxX, AnchorMaxY; + } + + /// + /// Convert a RectTransform into the values the exporter writes. + /// Handles anchor-based positioning and the Y flip (Unity counts up from + /// the bottom, the PS1 counts down from the top). + /// + public static Baked Bake(RectTransform rt, float scaleX, float scaleY) + { + var b = new Baked(); + + b.AnchorMinX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMin.x * 255f), 0, 255); + b.AnchorMinY = (byte)Mathf.Clamp(Mathf.RoundToInt((1f - rt.anchorMax.y) * 255f), 0, 255); // Y invert + b.AnchorMaxX = (byte)Mathf.Clamp(Mathf.RoundToInt(rt.anchorMax.x * 255f), 0, 255); + b.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 a single anchor point. anchoredPosition + // is the offset from that anchor, measured at the pivot. + 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; + + px -= rt.pivot.x * pw; + py -= (1f - rt.pivot.y) * ph; // pivot Y inverted + + b.X = (short)Mathf.RoundToInt(px); + b.Y = (short)Mathf.RoundToInt(py); + b.W = (short)Mathf.Max(1, Mathf.RoundToInt(pw)); + b.H = (short)Mathf.Max(1, Mathf.RoundToInt(ph)); + } + else + { + // Stretched: x/y are the offset from the anchor start and w/h the + // combined inset (negative shrinks). + 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 + + b.X = (short)Mathf.RoundToInt(leftOff); + b.Y = (short)Mathf.RoundToInt(topOff); + b.W = (short)Mathf.RoundToInt(rightOff - leftOff); + b.H = (short)Mathf.RoundToInt(bottomOff - topOff); + } + + return b; + } + + /// + /// The rect the hardware draws, in PS1 pixels from the top-left. + /// A line-for-line mirror of UISystem::resolveLayout, clamp + /// included: an element pushed off the edge is drawn CLIPPED on a PS1, + /// not moved, and a preview that quietly moves it hides the bug. + /// + public static RectInt Resolve(Baked b, Vector2 resolution) + { + int resW = Mathf.RoundToInt(resolution.x); + int resH = Mathf.RoundToInt(resolution.y); + + int ax = (b.AnchorMinX * resW) >> 8; + int ay = (b.AnchorMinY * resH) >> 8; + int x = ax + b.X; + int y = ay + b.Y; + + int w = b.W; + int h = b.H; + if (b.AnchorMaxX != b.AnchorMinX) w = ((b.AnchorMaxX * resW) >> 8) - ax + b.W; + if (b.AnchorMaxY != b.AnchorMinY) h = ((b.AnchorMaxY * resH) >> 8) - ay + b.H; + + if (x < 0) { w += x; x = 0; } + if (y < 0) { h += y; y = 0; } + if (w <= 0) w = 1; + if (h <= 0) h = 1; + if (x + w > resW) w = resW - x; + if (y + h > resH) h = resH - y; + + return new RectInt(x, y, w, h); + } + + /// + /// Bake and resolve in one step, for a RectTransform under a canvas of + /// the given size. This is what a preview wants. + /// + public static RectInt ScreenRect(RectTransform rt, RectTransform canvasRect, Vector2 resolution) + { + float canvasW = canvasRect != null ? canvasRect.rect.width : resolution.x; + float canvasH = canvasRect != null ? canvasRect.rect.height : resolution.y; + if (canvasW <= 0) canvasW = resolution.x; + if (canvasH <= 0) canvasH = resolution.y; + return Resolve(Bake(rt, resolution.x / canvasW, resolution.y / canvasH), resolution); + } + } +} diff --git a/Runtime/PSXUILayout.cs.meta b/Runtime/PSXUILayout.cs.meta new file mode 100644 index 0000000..f3036be --- /dev/null +++ b/Runtime/PSXUILayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38955ca71ceb96ba510544eaa5fa9681 \ No newline at end of file diff --git a/Runtime/PSXUISprite.cs b/Runtime/PSXUISprite.cs new file mode 100644 index 0000000..27c4b9e --- /dev/null +++ b/Runtime/PSXUISprite.cs @@ -0,0 +1,123 @@ +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// One cell of a sprite sheet, drawn as a UI element. + /// + /// + /// This is the piece that lets a game's SCREEN FURNITURE be authored instead + /// of assembled by script. A owns a texture, so a + /// panel built from forty of them is forty textures in the atlas; a + /// PSXUISprite points at a cell of a sheet that is already resident, so a + /// whole panel costs the sheet it was drawn from and nothing else. + /// + /// It exports as a plain Image element - the engine draws it with the same + /// two triangles - plus the sheet's cell grid, which is what makes + /// UI.SetFrame(handle, cell) work: a digit, a chevron or a lamp + /// changes cell at run time without the Lua ever creating anything. + /// + /// Put one on a child of a . The RectTransform is the + /// rect it draws into, in PS1 pixels. + /// + [RequireComponent(typeof(RectTransform))] + [DisallowMultipleComponent] + [AddComponentMenu("PSX/UI/PSX UI Sprite")] + public class PSXUISprite : MonoBehaviour + { + /// + /// The PS1's tint identity. Hardware computes texel * colour / 128, + /// so 128 (0.5 in Unity's 0-1 colour) leaves the art exactly as drawn and + /// 255 doubles it. Unity's white is therefore NOT "no tint", which is the + /// single easiest thing to get wrong here. + /// + public static readonly Color TintIdentity = new Color(0.5f, 0.5f, 0.5f, 1f); + + [Tooltip("Name used to reference this element from Lua: " + + "UI.FindElement(canvas, \"name\"). Max 24 chars.")] + [SerializeField] private string elementName = "sprite"; + + [Tooltip("The sheet this cell comes from. Its texture is packed into the " + + "VRAM atlas once, however many elements draw from it.")] + [SerializeField] private PSXSpriteSheet sheet; + + [Tooltip("Which cell to draw, counting left-to-right then top-to-bottom " + + "from 0. Change it at run time with UI.SetFrame().")] + [SerializeField] private int cell = 0; + + [Tooltip("Tint. 128 grey is NO tint - the PS1 computes texel * colour / 128, " + + "so white is double brightness. Use the Reset button in the inspector.")] + [SerializeField] private Color tint = new Color(0.5f, 0.5f, 0.5f, 1f); + + [Tooltip("Whether this element is visible when the scene first loads.")] + [SerializeField] private bool startVisible = true; + + /// Element name for Lua access. + public string ElementName => elementName; + + /// The sheet this element draws a cell of. + public PSXSpriteSheet Sheet => sheet; + + /// Cell index, clamped to the sheet on export. + public int Cell => cell; + + /// Tint colour (RGB; 128 grey = the art's own colours). + public Color Tint => tint; + + /// Initial visibility flag. + public bool StartVisible => startVisible; + + /// + /// Cell index actually exported: clamped into the sheet so a stale index + /// draws the sheet's last cell rather than sampling whatever is packed + /// beside it in VRAM. + /// + public int ResolvedCell + { + get + { + if (sheet == null) return 0; + return Mathf.Clamp(cell, 0, Mathf.Max(0, sheet.CellCount - 1)); + } + } + + /// + /// The cell's rect in the sheet's source texture, in pixels from the + /// TOP-LEFT. Used by the scene-view preview and by the exporter. + /// + public RectInt CellRect + { + get + { + if (sheet == null) return new RectInt(0, 0, 16, 16); + int c = ResolvedCell; + int cols = Mathf.Max(1, sheet.Columns); + return new RectInt((c % cols) * sheet.CellWidth, + (c / cols) * sheet.CellHeight, + sheet.CellWidth, sheet.CellHeight); + } + } + + /// Resize the RectTransform to the sheet's natural cell size. + public void SnapToCellSize() + { + if (sheet == null) return; + var rt = GetComponent(); + if (rt == null) return; + rt.sizeDelta = new Vector2(sheet.CellWidth, sheet.CellHeight); + } + + private void Reset() + { + // A UI element is authored in PS1 pixels from the top-left of the + // screen; that only works if the pivot is the top-left corner, which + // is also what PSXUIExporter's coordinate bake assumes. + var rt = GetComponent(); + if (rt != null) + { + rt.pivot = new Vector2(0f, 1f); + rt.sizeDelta = new Vector2(16, 16); + } + } + } +} diff --git a/Runtime/PSXUISprite.cs.meta b/Runtime/PSXUISprite.cs.meta new file mode 100644 index 0000000..f5dbed4 --- /dev/null +++ b/Runtime/PSXUISprite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 29eca857313c556a0adc1c0d4e4556e8 \ No newline at end of file diff --git a/Runtime/PSXUIText.cs b/Runtime/PSXUIText.cs index 972dfe3..4dc47ae 100644 --- a/Runtime/PSXUIText.cs +++ b/Runtime/PSXUIText.cs @@ -48,7 +48,7 @@ public class PSXUIText : MonoBehaviour /// /// Resolve the effective font for this text element. - /// Checks: fontOverride → parent PSXCanvas.DefaultFont → null (system font). + /// Checks: fontOverride -> parent PSXCanvas.DefaultFont -> null (system font). /// public PSXFontAsset GetEffectiveFont() { diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs index d639342..8825371 100644 --- a/Runtime/TexturePacker.cs +++ b/Runtime/TexturePacker.cs @@ -54,7 +54,7 @@ public VRAMPacker(List framebuffers, List reservedAreas) _reservedAreas.Add(framebuffers[0]); _reservedAreas.Add(framebuffers[1]); - // Reserve the font column (x=960-1023) — custom fonts and the system font + // Reserve the font column (x=960-1023) - custom fonts and the system font // are placed here by PSXUIExporter, outside the packer's control. _reservedAreas.Add(new Rect(960, 0, 64, VramHeight)); diff --git a/Runtime/Utils.cs b/Runtime/Utils.cs index c13e694..95c9589 100644 --- a/Runtime/Utils.cs +++ b/Runtime/Utils.cs @@ -402,6 +402,44 @@ public static void SetTextureImporterFormat(Texture2D texture, bool isReadable) tImporter.isReadable = isReadable; needReimport = true; } + + // Pixel art must reach the quantizer EXACTLY as authored. + // + // npotScale is the one that silently ruins sprite sheets: Unity + // defaults to scaling a non-power-of-two texture to the nearest + // POT, so a 64x96 sheet is resampled to 64x128 and every 16px cell + // row slides out of the grid - which reads as "the bottom of my + // sprite is cut off" rather than as an import setting. Compression + // and mipmaps are just as fatal at this size: both invent colours + // the 4-bit palette then has to spend entries on. + if (tImporter.npotScale != TextureImporterNPOTScale.None) + { + tImporter.npotScale = TextureImporterNPOTScale.None; + needReimport = true; + } + if (tImporter.textureCompression != TextureImporterCompression.Uncompressed) + { + tImporter.textureCompression = TextureImporterCompression.Uncompressed; + needReimport = true; + } + if (tImporter.mipmapEnabled) + { + tImporter.mipmapEnabled = false; + needReimport = true; + } + if (tImporter.filterMode != FilterMode.Point) + { + tImporter.filterMode = FilterMode.Point; + needReimport = true; + } + // Cutout sprites rely on alpha 0 becoming the transparent palette + // entry; without this Unity can hand us premultiplied edges. + if (!tImporter.alphaIsTransparency) + { + tImporter.alphaIsTransparency = true; + needReimport = true; + } + if (needReimport) { AssetDatabase.ImportAsset(assetPath); From 30e317bd9912399ad5fbb4888d33e65f09fd1192 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:44:55 -0700 Subject: [PATCH 2/7] Fail the export when a sprite sheet was not packed, instead of skipping it Anims and the tilemap index sheets by their position in the unfiltered list, so a skipped sheet pointed everything after it at the wrong one. Also drop the per-sheet Debug.Log. --- Runtime/PSXSpriteExporter.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Runtime/PSXSpriteExporter.cs b/Runtime/PSXSpriteExporter.cs index 1879248..be77219 100644 --- a/Runtime/PSXSpriteExporter.cs +++ b/Runtime/PSXSpriteExporter.cs @@ -108,11 +108,11 @@ public static void Flatten(List sheets, { PSXSpriteSheet sheet = sheets[i]; PSXTexture2D tex = sheet.PackedTexture; + // Anims and the tilemap refer to sheets by their index in this + // list, so skipping one would shift every sheet after it. if (tex == null) - { - Debug.LogWarning($"[SpriteSheet] '{sheet.SheetName}' has NULL PackedTexture!"); - continue; - } + throw new System.InvalidOperationException( + $"[SpriteSheet] '{sheet.SheetName}' was not packed into VRAM."); // A 4bpp texture stores four texels per VRAM hword, so the // packer's X is in hwords and the U axis needs expanding - the @@ -153,11 +153,6 @@ public static void Flatten(List sheets, Loop = anim.loop, }); } - - Debug.Log($"[SpriteSheet] '{sheet.SheetName}' bpp={(int)tex.BitDepth} " + - $"grid={sheet.Columns}x{sheet.Rows} cell={sheet.CellWidth}x{sheet.CellHeight} " + - $"tpage=({tex.TexpageX},{tex.TexpageY}) uv0=({(byte)(tex.PackingX * expander)},{(byte)tex.PackingY}) " + - $"anims={sheet.Animations.Count}"); } } From d26f7f5d960f73258cdf284e6f26d6b84f454744 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:46:35 -0700 Subject: [PATCH 3/7] Honour cutout on 16bpp textures Only the paletted path applied it, so a 16bpp sheet was transparent only where a texel happened to pack to 0x0000. --- Runtime/PSXTexture2D.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs index ee8746b..437a4c4 100644 --- a/Runtime/PSXTexture2D.cs +++ b/Runtime/PSXTexture2D.cs @@ -131,7 +131,8 @@ public class PSXTexture2D /// /// With this on, palette entry 0 is the PS1's transparent colour /// (0x0000) and every pixel below the alpha threshold maps to it. Costs - /// one palette entry. + /// one palette entry. At 16bpp those pixels are written as 0x0000 + /// directly. /// public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bitDepth, bool cutout = false) { @@ -159,6 +160,16 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi for (int x = 0; x < width; x++) // Start from right column, move leftward { Color pixel = inputTexture.GetPixel(x, height - y - 1); + + // Cutout at 16bpp: anything below the threshold becomes the + // PS1's transparent colour, the same rule the paletted path + // applies through palette entry 0. + if (cutout && pixel.a < TextureQuantizer.CutoutAlphaThreshold) + { + psxTex.ImageData[x, y] = new VRAMPixel(); + continue; + } + VRAMPixel vramPixel = new VRAMPixel { R = (ushort)(pixel.r * 31), From 6a63e2095c313181dd964f6dcce226f18edd65e5 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:47:34 -0700 Subject: [PATCH 4/7] Include cutout in the VRAM dedup key A texture used both by a sprite sheet (cutout) and by a mesh or UI image was packed once, with whichever quantization ran first. --- Runtime/PSXTexture2D.cs | 4 ++++ Runtime/TexturePacker.cs | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs index 437a4c4..eb6bf57 100644 --- a/Runtime/PSXTexture2D.cs +++ b/Runtime/PSXTexture2D.cs @@ -112,6 +112,9 @@ public class PSXTexture2D public ushort ClutPackingX; public ushort ClutPackingY; + /// Whether this was quantized with cutout transparency. + public bool Cutout { get; private set; } + private int _maxColors; public VRAMPixel[,] ImageData { get; set; } @@ -146,6 +149,7 @@ public static PSXTexture2D CreateFromTexture2D(Texture2D inputTexture, PSXBPP bi psxTex.Height = inputTexture.height; psxTex.BitDepth = bitDepth; + psxTex.Cutout = cutout; if (bitDepth == PSXBPP.TEX_16BIT) diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs index 8825371..6079a4a 100644 --- a/Runtime/TexturePacker.cs +++ b/Runtime/TexturePacker.cs @@ -64,7 +64,7 @@ public VRAMPacker(List framebuffers, List reservedAreas) /// /// Packs the textures from the provided PSXObjectExporter array into VRAM. /// Each exporter now holds a list of textures. - /// Duplicates (textures with the same underlying OriginalTexture and BitDepth) across all exporters are merged. + /// Duplicates (textures with the same underlying OriginalTexture, BitDepth and cutout) across all exporters are merged. /// Returns the processed objects and the final VRAM pixel array. /// /// Array of PSXObjectExporter objects to process. @@ -85,7 +85,9 @@ public VRAMPacker(List framebuffers, List reservedAreas) // List to track unique textures and their indices List uniqueTextures = new List(); - Dictionary<(int, PSXBPP), int> textureToIndexMap = new Dictionary<(int, PSXBPP), int>(); + // Cutout is part of the key: the same source quantized with and without it + // gives different pixels, so the two must not be merged. + Dictionary<(int, PSXBPP, bool), int> textureToIndexMap = new Dictionary<(int, PSXBPP, bool), int>(); // Track duplicates so we can propagate packing data after placement List<(PSXTexture2D duplicate, int uniqueIndex)> duplicates = new List<(PSXTexture2D, int)>(); @@ -113,7 +115,7 @@ public VRAMPacker(List framebuffers, List reservedAreas) // Process each texture in descending order of area. foreach (var texture in group.OrderByDescending(tex => tex.QuantizedWidth * tex.Height)) { - var textureKey = (texture.OriginalTexture.GetInstanceID(), texture.BitDepth); + var textureKey = (texture.OriginalTexture.GetInstanceID(), texture.BitDepth, texture.Cutout); // Check if we've already processed this texture if (textureToIndexMap.TryGetValue(textureKey, out int existingIndex)) @@ -152,7 +154,7 @@ public VRAMPacker(List framebuffers, List reservedAreas) for (int i = 0; i < obj.Textures.Count; i++) { - var textureKey = (obj.Textures[i].OriginalTexture.GetInstanceID(), obj.Textures[i].BitDepth); + var textureKey = (obj.Textures[i].OriginalTexture.GetInstanceID(), obj.Textures[i].BitDepth, obj.Textures[i].Cutout); if (textureToIndexMap.TryGetValue(textureKey, out int newIndex)) { oldToNewIndexMap[i] = newIndex; From 4df3a94f730d9ad555b10c6d630f355bf6aef359 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:49:27 -0700 Subject: [PATCH 5/7] Resolve an agent on a skinned object to its proxy, and fail the export otherwise The writer fell back to 0xFFFF, which the runtime's gameObjectIndex + 1 wraps to actor 0, the player. A PSXAgent on a skinned object always hit this, because its PSXObjectExporter is skipped for the skinned proxy. --- Runtime/PSXSceneWriter.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs index 74e5b78..d8b9dc4 100644 --- a/Runtime/PSXSceneWriter.cs +++ b/Runtime/PSXSceneWriter.cs @@ -525,7 +525,21 @@ public static void Write(string path, in SceneData scene, Action() : null; - int goIndex = (exp != null && exporterIndex.TryGetValue(exp, out int idx)) ? idx : 0xFFFF; + int goIndex = (exp != null && exporterIndex.TryGetValue(exp, out int idx)) ? idx : -1; + // A skinned object's own PSXObjectExporter is skipped in favour of + // the skinned proxy, so look the proxy up instead. + if (goIndex < 0 && agent != null) + { + var skin = agent.GetComponent(); + if (skin != null && skin.ProxyExporter != null && + exporterIndex.TryGetValue(skin.ProxyExporter, out int proxyIdx)) + goIndex = proxyIdx; + } + // The runtime maps gameObjectIndex + 1 to an actor id, so 0xFFFF + // would wrap to actor 0, the player. + if (goIndex < 0) + throw new System.InvalidOperationException( + $"[Agent] '{(agent != null ? agent.name : "null")}' is not on an exported object."); int waypointCount = 0; byte flags = 0; From 0d5ac0b60db2468873debe11ae2f8734dfae222e Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:50:24 -0700 Subject: [PATCH 6/7] Drop the unused UnityEngine.UI import from PSXUIMenuItems The Editor asmdef does not reference uGUI and nothing in the file uses it. --- Editor/PSXUIMenuItems.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Editor/PSXUIMenuItems.cs b/Editor/PSXUIMenuItems.cs index 159505a..4084b83 100644 --- a/Editor/PSXUIMenuItems.cs +++ b/Editor/PSXUIMenuItems.cs @@ -1,7 +1,6 @@ using SplashEdit.RuntimeCode; using UnityEditor; using UnityEngine; -using UnityEngine.UI; namespace SplashEdit.EditorCode { From f389200f95ae0594b93bfbdf6f9b0540aab2f325 Mon Sep 17 00:00:00 2001 From: Rix Date: Tue, 29 Sep 2026 01:50:24 -0700 Subject: [PATCH 7/7] Restore the meaning of three editor labels the ASCII pass mangled The refresh button, the path-OK label and the console's auto-scroll toggle read "(cycle)", "yes Path..." and "Autov". --- Editor/Core/PSXConsoleWindow.cs | 2 +- Editor/Core/SplashControlPanel.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Editor/Core/PSXConsoleWindow.cs b/Editor/Core/PSXConsoleWindow.cs index 33d7a6e..70df3d9 100644 --- a/Editor/Core/PSXConsoleWindow.cs +++ b/Editor/Core/PSXConsoleWindow.cs @@ -293,7 +293,7 @@ private void DrawToolbar() _wrapLines = GUILayout.Toggle(_wrapLines, "Wrap", EditorStyles.toolbarButton, GUILayout.Width(40)); // Auto-scroll - _autoScroll = GUILayout.Toggle(_autoScroll, "Autov", EditorStyles.toolbarButton, GUILayout.Width(50)); + _autoScroll = GUILayout.Toggle(_autoScroll, new GUIContent("Auto", "Scroll to the newest line"), EditorStyles.toolbarButton, GUILayout.Width(50)); // Clear if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45))) diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs index 215ea24..ca736c4 100644 --- a/Editor/Core/SplashControlPanel.cs +++ b/Editor/Core/SplashControlPanel.cs @@ -297,7 +297,7 @@ private void DrawNativeProjectSection() else { _selectedReleaseIndex = EditorGUILayout.Popup(_selectedReleaseIndex, _releaseDisplayNames); - if (GUILayout.Button("(cycle)", EditorStyles.miniButton, GUILayout.Width(22))) + if (GUILayout.Button(new GUIContent("R", "Refresh the release list"), EditorStyles.miniButton, GUILayout.Width(22))) FetchGitHubReleases(); } EditorGUILayout.EndHorizontal(); @@ -412,7 +412,7 @@ private void DrawNativeProjectSection() { var prevColor = GUI.contentColor; GUI.contentColor = PSXEditorStyles.Success; - GUILayout.Label("yes Path is set and valid", EditorStyles.miniLabel); + GUILayout.Label("Path is set and valid", EditorStyles.miniLabel); GUI.contentColor = prevColor; }