From bb8e0804f5d63e70a0ac0cdbd96fc90939e89f86 Mon Sep 17 00:00:00 2001 From: Jan Racek Date: Tue, 24 Mar 2026 15:50:35 +0100 Subject: [PATCH] Cutscene sytstem --- Editor/PSXCutsceneEditor.cs | 706 ++++++++++++++++++++++++++++ Editor/PSXCutsceneEditor.cs.meta | 2 + Runtime/PSXAudioEvent.cs | 27 ++ Runtime/PSXAudioEvent.cs.meta | 2 + Runtime/PSXCutsceneClip.cs | 27 ++ Runtime/PSXCutsceneClip.cs.meta | 2 + Runtime/PSXCutsceneExporter.cs | 333 +++++++++++++ Runtime/PSXCutsceneExporter.cs.meta | 2 + Runtime/PSXCutsceneTrack.cs | 22 + Runtime/PSXCutsceneTrack.cs.meta | 2 + Runtime/PSXInterpMode.cs | 15 + Runtime/PSXInterpMode.cs.meta | 2 + Runtime/PSXKeyframe.cs | 26 + Runtime/PSXKeyframe.cs.meta | 2 + Runtime/PSXSceneExporter.cs | 6 + Runtime/PSXSceneWriter.cs | 37 +- Runtime/PSXTrackType.cs | 14 + Runtime/PSXTrackType.cs.meta | 2 + 18 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 Editor/PSXCutsceneEditor.cs create mode 100644 Editor/PSXCutsceneEditor.cs.meta create mode 100644 Runtime/PSXAudioEvent.cs create mode 100644 Runtime/PSXAudioEvent.cs.meta create mode 100644 Runtime/PSXCutsceneClip.cs create mode 100644 Runtime/PSXCutsceneClip.cs.meta create mode 100644 Runtime/PSXCutsceneExporter.cs create mode 100644 Runtime/PSXCutsceneExporter.cs.meta create mode 100644 Runtime/PSXCutsceneTrack.cs create mode 100644 Runtime/PSXCutsceneTrack.cs.meta create mode 100644 Runtime/PSXInterpMode.cs create mode 100644 Runtime/PSXInterpMode.cs.meta create mode 100644 Runtime/PSXKeyframe.cs create mode 100644 Runtime/PSXKeyframe.cs.meta create mode 100644 Runtime/PSXTrackType.cs create mode 100644 Runtime/PSXTrackType.cs.meta diff --git a/Editor/PSXCutsceneEditor.cs b/Editor/PSXCutsceneEditor.cs new file mode 100644 index 0000000..7bf0419 --- /dev/null +++ b/Editor/PSXCutsceneEditor.cs @@ -0,0 +1,706 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + [CustomEditor(typeof(PSXCutsceneClip))] + public class PSXCutsceneEditor : Editor + { + // ── Preview state ── + private bool _showAudioEvents = true; + private bool _previewing; + private bool _playing; + private float _previewFrame; + private double _playStartEditorTime; + private float _playStartFrame; + private HashSet _firedAudioEventIndices = new HashSet(); + + // Saved scene-view state so we can restore after preview + private bool _hasSavedSceneView; + private Vector3 _savedPivot; + private Quaternion _savedRotation; + private float _savedSize; + + // Saved object transforms + private Dictionary _savedObjectPositions = new Dictionary(); + private Dictionary _savedObjectRotations = new Dictionary(); + private Dictionary _savedObjectActive = new Dictionary(); + + // Audio preview + private Dictionary _audioClipCache = new Dictionary(); + + private void OnEnable() + { + EditorApplication.update += OnEditorUpdate; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + if (_previewing) StopPreview(); + } + + private void OnEditorUpdate() + { + if (!_playing) return; + + PSXCutsceneClip clip = (PSXCutsceneClip)target; + double elapsed = EditorApplication.timeSinceStartup - _playStartEditorTime; + _previewFrame = _playStartFrame + (float)(elapsed * 30.0); + + if (_previewFrame >= clip.DurationFrames) + { + _previewFrame = clip.DurationFrames; + _playing = false; + } + + ApplyPreview(clip); + Repaint(); + } + + public override void OnInspectorGUI() + { + PSXCutsceneClip clip = (PSXCutsceneClip)target; + Undo.RecordObject(clip, "Edit Cutscene"); + + // ── Header ── + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Cutscene Settings", EditorStyles.boldLabel); + + clip.CutsceneName = EditorGUILayout.TextField("Cutscene Name", clip.CutsceneName); + if (!string.IsNullOrEmpty(clip.CutsceneName) && clip.CutsceneName.Length > 24) + EditorGUILayout.HelpBox("Name exceeds 24 characters and will be truncated on export.", MessageType.Warning); + + clip.DurationFrames = EditorGUILayout.IntField("Duration (frames)", clip.DurationFrames); + if (clip.DurationFrames < 1) clip.DurationFrames = 1; + + float seconds = clip.DurationFrames / 30f; + EditorGUILayout.LabelField($" = {seconds:F2} seconds at 30fps", EditorStyles.miniLabel); + + // ── Preview Controls ── + EditorGUILayout.Space(6); + DrawPreviewControls(clip); + + // Collect scene references for validation + var exporterNames = new HashSet(); + var audioNames = new HashSet(); + var exporters = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (var e in exporters) + exporterNames.Add(e.gameObject.name); + var audioSources = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (var a in audioSources) + if (!string.IsNullOrEmpty(a.ClipName)) + audioNames.Add(a.ClipName); + + // ── Tracks ── + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("Tracks", EditorStyles.boldLabel); + + if (clip.Tracks == null) clip.Tracks = new List(); + + int removeTrackIdx = -1; + for (int ti = 0; ti < clip.Tracks.Count; ti++) + { + var track = clip.Tracks[ti]; + EditorGUILayout.BeginVertical("box"); + + EditorGUILayout.BeginHorizontal(); + track.TrackType = (PSXTrackType)EditorGUILayout.EnumPopup("Type", track.TrackType); + if (GUILayout.Button("Remove", GUILayout.Width(65))) + removeTrackIdx = ti; + EditorGUILayout.EndHorizontal(); + + bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; + EditorGUI.BeginDisabledGroup(isCameraTrack); + track.ObjectName = EditorGUILayout.TextField("Object Name", isCameraTrack ? "(camera)" : track.ObjectName); + EditorGUI.EndDisabledGroup(); + + // Validation + if (!isCameraTrack && !string.IsNullOrEmpty(track.ObjectName) && !exporterNames.Contains(track.ObjectName)) + EditorGUILayout.HelpBox($"No PSXObjectExporter found for '{track.ObjectName}' in scene.", MessageType.Error); + + // ── Keyframes ── + if (track.Keyframes == null) track.Keyframes = new List(); + + EditorGUI.indentLevel++; + EditorGUILayout.LabelField($"Keyframes ({track.Keyframes.Count})", EditorStyles.miniLabel); + + int removeKfIdx = -1; + for (int ki = 0; ki < track.Keyframes.Count; ki++) + { + var kf = track.Keyframes[ki]; + + // Row 1: frame number + interp mode + buttons + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Frame", GUILayout.Width(42)); + kf.Frame = EditorGUILayout.IntField(kf.Frame, GUILayout.Width(60)); + kf.Interp = (PSXInterpMode)EditorGUILayout.EnumPopup(kf.Interp, GUILayout.Width(80)); + GUILayout.FlexibleSpace(); + + // Capture from scene + if (isCameraTrack) + { + if (GUILayout.Button("Capture Cam", GUILayout.Width(90))) + { + var sv = SceneView.lastActiveSceneView; + if (sv != null) + kf.Value = track.TrackType == PSXTrackType.CameraPosition + ? sv.camera.transform.position : sv.camera.transform.eulerAngles; + else Debug.LogWarning("No active Scene View."); + } + } + else if (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY) + { + if (GUILayout.Button("From Sel.", GUILayout.Width(70))) + { + var sel = Selection.activeGameObject; + if (sel != null) + kf.Value = track.TrackType == PSXTrackType.ObjectPosition + ? sel.transform.position : new Vector3(0, sel.transform.eulerAngles.y, 0); + else Debug.LogWarning("No GameObject selected."); + } + } + + if (GUILayout.Button("\u2212", GUILayout.Width(22))) + removeKfIdx = ki; + EditorGUILayout.EndHorizontal(); + + // Row 2: value on its own line + EditorGUI.indentLevel++; + switch (track.TrackType) + { + case PSXTrackType.ObjectActive: + bool active = EditorGUILayout.Toggle("Active", kf.Value.x > 0.5f); + kf.Value = new Vector3(active ? 1f : 0f, 0, 0); + break; + case PSXTrackType.ObjectRotationY: + float yRot = EditorGUILayout.FloatField("Y\u00b0", kf.Value.y); + kf.Value = new Vector3(0, yRot, 0); + break; + default: + kf.Value = EditorGUILayout.Vector3Field("Value", kf.Value); + break; + } + EditorGUI.indentLevel--; + + if (ki < track.Keyframes.Count - 1) + { + EditorGUILayout.Space(1); + var rect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(rect, new Color(0.5f, 0.5f, 0.5f, 0.3f)); + } + } + + if (removeKfIdx >= 0) track.Keyframes.RemoveAt(removeKfIdx); + + // Add keyframe buttons + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("+ Keyframe", GUILayout.Width(90))) + { + int frame = track.Keyframes.Count > 0 ? track.Keyframes[track.Keyframes.Count - 1].Frame + 15 : 0; + track.Keyframes.Add(new PSXKeyframe { Frame = frame, Value = Vector3.zero }); + } + if (isCameraTrack) + { + if (GUILayout.Button("+ from Scene Cam", GUILayout.Width(130))) + { + var sv = SceneView.lastActiveSceneView; + Vector3 val = Vector3.zero; + if (sv != null) + val = track.TrackType == PSXTrackType.CameraPosition + ? sv.camera.transform.position : sv.camera.transform.eulerAngles; + int frame = track.Keyframes.Count > 0 ? track.Keyframes[track.Keyframes.Count - 1].Frame + 15 : 0; + track.Keyframes.Add(new PSXKeyframe { Frame = frame, Value = val }); + } + } + else if (track.TrackType == PSXTrackType.ObjectPosition || track.TrackType == PSXTrackType.ObjectRotationY) + { + if (GUILayout.Button("+ from Selected", GUILayout.Width(120))) + { + var sel = Selection.activeGameObject; + Vector3 val = Vector3.zero; + if (sel != null) + val = track.TrackType == PSXTrackType.ObjectPosition + ? sel.transform.position : new Vector3(0, sel.transform.eulerAngles.y, 0); + int frame = track.Keyframes.Count > 0 ? track.Keyframes[track.Keyframes.Count - 1].Frame + 15 : 0; + track.Keyframes.Add(new PSXKeyframe { Frame = frame, Value = val }); + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUI.indentLevel--; + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(2); + } + + if (removeTrackIdx >= 0) clip.Tracks.RemoveAt(removeTrackIdx); + + if (clip.Tracks.Count < 8) + { + if (GUILayout.Button("+ Add Track")) + clip.Tracks.Add(new PSXCutsceneTrack()); + } + else + { + EditorGUILayout.HelpBox("Maximum 8 tracks per cutscene.", MessageType.Info); + } + + // ── Audio Events ── + EditorGUILayout.Space(8); + _showAudioEvents = EditorGUILayout.Foldout(_showAudioEvents, "Audio Events", true); + if (_showAudioEvents) + { + if (clip.AudioEvents == null) clip.AudioEvents = new List(); + + int removeEventIdx = -1; + for (int ei = 0; ei < clip.AudioEvents.Count; ei++) + { + var evt = clip.AudioEvents[ei]; + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Frame", GUILayout.Width(42)); + evt.Frame = EditorGUILayout.IntField(evt.Frame, GUILayout.Width(60)); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("\u2212", GUILayout.Width(22))) + removeEventIdx = ei; + EditorGUILayout.EndHorizontal(); + + evt.ClipName = EditorGUILayout.TextField("Clip Name", evt.ClipName); + if (!string.IsNullOrEmpty(evt.ClipName) && !audioNames.Contains(evt.ClipName)) + EditorGUILayout.HelpBox($"No PSXAudioSource with ClipName '{evt.ClipName}' in scene.", MessageType.Error); + + evt.Volume = EditorGUILayout.IntSlider("Volume", evt.Volume, 0, 128); + evt.Pan = EditorGUILayout.IntSlider("Pan", evt.Pan, 0, 127); + + EditorGUILayout.EndVertical(); + } + + if (removeEventIdx >= 0) clip.AudioEvents.RemoveAt(removeEventIdx); + + if (clip.AudioEvents.Count < 64) + { + if (GUILayout.Button("+ Add Audio Event")) + clip.AudioEvents.Add(new PSXAudioEvent()); + } + else + { + EditorGUILayout.HelpBox("Maximum 64 audio events per cutscene.", MessageType.Info); + } + } + + if (GUI.changed) + EditorUtility.SetDirty(clip); + } + + // ===================================================================== + // Preview Controls + // ===================================================================== + + private void DrawPreviewControls(PSXCutsceneClip clip) + { + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel); + + // Transport bar + EditorGUILayout.BeginHorizontal(); + + bool wasPlaying = _playing; + if (_playing) + { + if (GUILayout.Button("\u275A\u275A Pause", GUILayout.Width(70))) + _playing = false; + } + else + { + if (GUILayout.Button("\u25B6 Play", GUILayout.Width(70))) + { + if (!_previewing) StartPreview(clip); + _playing = true; + _playStartEditorTime = EditorApplication.timeSinceStartup; + _playStartFrame = _previewFrame; + _firedAudioEventIndices.Clear(); + // Mark already-passed events so they won't fire again + if (clip.AudioEvents != null) + for (int i = 0; i < clip.AudioEvents.Count; i++) + if (clip.AudioEvents[i].Frame < (int)_previewFrame) + _firedAudioEventIndices.Add(i); + } + } + + if (GUILayout.Button("\u25A0 Stop", GUILayout.Width(60))) + { + _playing = false; + _previewFrame = 0; + if (_previewing) StopPreview(); + } + + if (_previewing) + { + GUI.color = new Color(1f, 0.4f, 0.4f); + if (GUILayout.Button("End Preview", GUILayout.Width(90))) + { + _playing = false; + StopPreview(); + } + GUI.color = Color.white; + } + + EditorGUILayout.EndHorizontal(); + + // Timeline scrubber + EditorGUI.BeginChangeCheck(); + float newFrame = EditorGUILayout.Slider("Frame", _previewFrame, 0, clip.DurationFrames); + if (EditorGUI.EndChangeCheck()) + { + if (!_previewing) StartPreview(clip); + _previewFrame = newFrame; + _playing = false; + _firedAudioEventIndices.Clear(); + ApplyPreview(clip); + } + + float previewSec = _previewFrame / 30f; + EditorGUILayout.LabelField( + $" {(int)_previewFrame} / {clip.DurationFrames} ({previewSec:F2}s / {seconds(clip):F2}s)", + EditorStyles.miniLabel); + + if (_previewing) + EditorGUILayout.HelpBox( + "PREVIEWING: Scene View camera & objects are being driven. " + + "Click \u201cEnd Preview\u201d or \u201cStop\u201d to restore original positions.", + MessageType.Warning); + + EditorGUILayout.EndVertical(); + } + + private static float seconds(PSXCutsceneClip clip) => clip.DurationFrames / 30f; + + // ===================================================================== + // Preview Lifecycle + // ===================================================================== + + private void StartPreview(PSXCutsceneClip clip) + { + if (_previewing) return; + _previewing = true; + _firedAudioEventIndices.Clear(); + + // Save scene view camera + var sv = SceneView.lastActiveSceneView; + if (sv != null) + { + _hasSavedSceneView = true; + _savedPivot = sv.pivot; + _savedRotation = sv.rotation; + _savedSize = sv.size; + } + + // Save object transforms + _savedObjectPositions.Clear(); + _savedObjectRotations.Clear(); + _savedObjectActive.Clear(); + if (clip.Tracks != null) + { + foreach (var track in clip.Tracks) + { + if (string.IsNullOrEmpty(track.ObjectName)) continue; + bool isCam = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; + if (isCam) continue; + + var go = GameObject.Find(track.ObjectName); + if (go == null) continue; + + if (!_savedObjectPositions.ContainsKey(track.ObjectName)) + { + _savedObjectPositions[track.ObjectName] = go.transform.position; + _savedObjectRotations[track.ObjectName] = go.transform.rotation; + _savedObjectActive[track.ObjectName] = go.activeSelf; + } + } + } + + // Build audio clip lookup + _audioClipCache.Clear(); + var audioSources = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (var a in audioSources) + if (!string.IsNullOrEmpty(a.ClipName) && a.Clip != null) + _audioClipCache[a.ClipName] = a.Clip; + } + + private void StopPreview() + { + if (!_previewing) return; + _previewing = false; + _playing = false; + + // Restore scene view camera + if (_hasSavedSceneView) + { + var sv = SceneView.lastActiveSceneView; + if (sv != null) + { + sv.pivot = _savedPivot; + sv.rotation = _savedRotation; + sv.size = _savedSize; + sv.Repaint(); + } + _hasSavedSceneView = false; + } + + // Restore object transforms + foreach (var kvp in _savedObjectPositions) + { + var go = GameObject.Find(kvp.Key); + if (go == null) continue; + go.transform.position = kvp.Value; + if (_savedObjectRotations.ContainsKey(kvp.Key)) + go.transform.rotation = _savedObjectRotations[kvp.Key]; + if (_savedObjectActive.ContainsKey(kvp.Key)) + go.SetActive(_savedObjectActive[kvp.Key]); + } + + _savedObjectPositions.Clear(); + _savedObjectRotations.Clear(); + _savedObjectActive.Clear(); + + SceneView.RepaintAll(); + Repaint(); + } + + // ===================================================================== + // Apply Preview at Current Frame + // ===================================================================== + + private void ApplyPreview(PSXCutsceneClip clip) + { + if (!_previewing) return; + float frame = _previewFrame; + + var sv = SceneView.lastActiveSceneView; + Vector3? camPos = null; + Quaternion? camRot = null; + + if (clip.Tracks != null) + { + foreach (var track in clip.Tracks) + { + // Compute initial value for pre-first-keyframe blending + Vector3 initialVal = Vector3.zero; + switch (track.TrackType) + { + case PSXTrackType.CameraPosition: + if (sv != null) + // Recover position from saved pivot/rotation/size + initialVal = _savedPivot - _savedRotation * Vector3.forward * _savedSize; + break; + case PSXTrackType.CameraRotation: + initialVal = _savedRotation.eulerAngles; + break; + case PSXTrackType.ObjectPosition: + if (_savedObjectPositions.ContainsKey(track.ObjectName ?? "")) + initialVal = _savedObjectPositions[track.ObjectName]; + break; + case PSXTrackType.ObjectRotationY: + if (_savedObjectRotations.ContainsKey(track.ObjectName ?? "")) + initialVal = new Vector3(0, _savedObjectRotations[track.ObjectName].eulerAngles.y, 0); + break; + case PSXTrackType.ObjectActive: + if (_savedObjectActive.ContainsKey(track.ObjectName ?? "")) + initialVal = new Vector3(_savedObjectActive[track.ObjectName] ? 1f : 0f, 0, 0); + break; + } + + Vector3 val = EvaluateTrack(track, frame, initialVal); + + switch (track.TrackType) + { + case PSXTrackType.CameraPosition: + camPos = val; + break; + case PSXTrackType.CameraRotation: + camRot = Quaternion.Euler(val); + break; + case PSXTrackType.ObjectPosition: + { + var go = GameObject.Find(track.ObjectName); + if (go != null) go.transform.position = val; + break; + } + case PSXTrackType.ObjectRotationY: + { + var go = GameObject.Find(track.ObjectName); + if (go != null) go.transform.rotation = Quaternion.Euler(0, val.y, 0); + break; + } + case PSXTrackType.ObjectActive: + { + var go = GameObject.Find(track.ObjectName); + if (go != null) go.SetActive(val.x > 0.5f); + break; + } + } + } + } + + // Drive scene view camera + if (sv != null && (camPos.HasValue || camRot.HasValue)) + { + Vector3 pos = camPos ?? sv.camera.transform.position; + Quaternion rot = camRot ?? sv.camera.transform.rotation; + + // SceneView needs pivot and rotation set — pivot = position + forward * size + sv.rotation = rot; + sv.pivot = pos + rot * Vector3.forward * sv.cameraDistance; + sv.Repaint(); + } + + // Fire audio events (only during playback, not scrubbing) + if (_playing && clip.AudioEvents != null) + { + for (int i = 0; i < clip.AudioEvents.Count; i++) + { + if (_firedAudioEventIndices.Contains(i)) continue; + var evt = clip.AudioEvents[i]; + if (frame >= evt.Frame) + { + _firedAudioEventIndices.Add(i); + PlayAudioPreview(evt); + } + } + } + } + + // ===================================================================== + // Track Evaluation (linear interpolation, matching C++ runtime) + // ===================================================================== + + private static Vector3 EvaluateTrack(PSXCutsceneTrack track, float frame, Vector3 initialValue) + { + if (track.Keyframes == null || track.Keyframes.Count == 0) + return Vector3.zero; + + // ObjectActive always uses step interpolation regardless of InterpMode + if (track.TrackType == PSXTrackType.ObjectActive) + { + // Use initial state if before first keyframe + if (track.Keyframes.Count > 0 && track.Keyframes[0].Frame > 0 && frame < track.Keyframes[0].Frame) + return initialValue; + return EvaluateStep(track.Keyframes, frame); + } + + // Find surrounding keyframes + PSXKeyframe before = null, after = null; + for (int i = 0; i < track.Keyframes.Count; i++) + { + if (track.Keyframes[i].Frame <= frame) + before = track.Keyframes[i]; + if (track.Keyframes[i].Frame >= frame && after == null) + after = track.Keyframes[i]; + } + + if (before == null && after == null) return Vector3.zero; + + // Pre-first-keyframe: blend from initial value to first keyframe + if (before == null && after != null && after.Frame > 0 && frame < after.Frame) + { + float rawT = frame / after.Frame; + float t = ApplyInterpCurve(rawT, after.Interp); + + bool isRotation = track.TrackType == PSXTrackType.CameraRotation + || track.TrackType == PSXTrackType.ObjectRotationY; + if (isRotation) + { + return new Vector3( + Mathf.LerpAngle(initialValue.x, after.Value.x, t), + Mathf.LerpAngle(initialValue.y, after.Value.y, t), + Mathf.LerpAngle(initialValue.z, after.Value.z, t)); + } + return Vector3.Lerp(initialValue, after.Value, t); + } + + if (before == null) return after.Value; + if (after == null) return before.Value; + if (before == after) return before.Value; + + float span = after.Frame - before.Frame; + float rawT2 = (frame - before.Frame) / span; + float t2 = ApplyInterpCurve(rawT2, after.Interp); + + // Use shortest-path angle interpolation for rotation tracks + bool isRot = track.TrackType == PSXTrackType.CameraRotation + || track.TrackType == PSXTrackType.ObjectRotationY; + if (isRot) + { + return new Vector3( + Mathf.LerpAngle(before.Value.x, after.Value.x, t2), + Mathf.LerpAngle(before.Value.y, after.Value.y, t2), + Mathf.LerpAngle(before.Value.z, after.Value.z, t2)); + } + + return Vector3.Lerp(before.Value, after.Value, t2); + } + + /// + /// Apply easing curve to a linear t value (0..1). Matches the C++ applyCurve(). + /// + private static float ApplyInterpCurve(float t, PSXInterpMode mode) + { + switch (mode) + { + default: + case PSXInterpMode.Linear: + return t; + case PSXInterpMode.Step: + return 0f; + case PSXInterpMode.EaseIn: + return t * t; + case PSXInterpMode.EaseOut: + return t * (2f - t); + case PSXInterpMode.EaseInOut: + return t * t * (3f - 2f * t); + } + } + + private static Vector3 EvaluateStep(List keyframes, float frame) + { + Vector3 result = Vector3.zero; + for (int i = 0; i < keyframes.Count; i++) + { + if (keyframes[i].Frame <= frame) + result = keyframes[i].Value; + } + return result; + } + + // ===================================================================== + // Audio Preview + // ===================================================================== + + private void PlayAudioPreview(PSXAudioEvent evt) + { + if (string.IsNullOrEmpty(evt.ClipName)) return; + if (!_audioClipCache.TryGetValue(evt.ClipName, out AudioClip clip)) return; + + // Use Unity's editor audio playback utility via reflection + // (PlayClipAtPoint doesn't work in edit mode) + var unityEditorAssembly = typeof(AudioImporter).Assembly; + var audioUtilClass = unityEditorAssembly.GetType("UnityEditor.AudioUtil"); + if (audioUtilClass == null) return; + + // Stop any previous preview + var stopMethod = audioUtilClass.GetMethod("StopAllPreviewClips", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + stopMethod?.Invoke(null, null); + + // Play the clip + var playMethod = audioUtilClass.GetMethod("PlayPreviewClip", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public, + null, new System.Type[] { typeof(AudioClip), typeof(int), typeof(bool) }, null); + playMethod?.Invoke(null, new object[] { clip, 0, false }); + } + } +} +#endif diff --git a/Editor/PSXCutsceneEditor.cs.meta b/Editor/PSXCutsceneEditor.cs.meta new file mode 100644 index 0000000..501fc19 --- /dev/null +++ b/Editor/PSXCutsceneEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad1b0e43d59aa0446b4e1d6497e8ee94 \ No newline at end of file diff --git a/Runtime/PSXAudioEvent.cs b/Runtime/PSXAudioEvent.cs new file mode 100644 index 0000000..020397a --- /dev/null +++ b/Runtime/PSXAudioEvent.cs @@ -0,0 +1,27 @@ +using System; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A frame-based audio trigger within a cutscene. + /// When the cutscene reaches this frame, the named audio clip is played. + /// + [Serializable] + public class PSXAudioEvent + { + [Tooltip("Frame at which to trigger this audio clip.")] + public int Frame; + + [Tooltip("Name of the audio clip (must match a PSXAudioSource ClipName in the scene).")] + public string ClipName = ""; + + [Tooltip("Playback volume (0 = silent, 128 = max).")] + [Range(0, 128)] + public int Volume = 100; + + [Tooltip("Stereo pan (0 = hard left, 64 = center, 127 = hard right).")] + [Range(0, 127)] + public int Pan = 64; + } +} diff --git a/Runtime/PSXAudioEvent.cs.meta b/Runtime/PSXAudioEvent.cs.meta new file mode 100644 index 0000000..1e1a2b4 --- /dev/null +++ b/Runtime/PSXAudioEvent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 264e92578fac5014aa24c1e38e116b3b \ No newline at end of file diff --git a/Runtime/PSXCutsceneClip.cs b/Runtime/PSXCutsceneClip.cs new file mode 100644 index 0000000..f627b02 --- /dev/null +++ b/Runtime/PSXCutsceneClip.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A cutscene asset containing keyframed tracks and audio events. + /// Create via right-click → Create → PSX → Cutscene Clip. + /// Reference these assets anywhere in the project; the exporter collects + /// all PSXCutsceneClip assets via Resources.FindObjectsOfTypeAll. + /// + [CreateAssetMenu(fileName = "NewCutscene", menuName = "PSX/Cutscene Clip", order = 100)] + public class PSXCutsceneClip : ScriptableObject + { + [Tooltip("Name used to reference this cutscene from Lua (max 24 chars). Must be unique per scene.")] + public string CutsceneName = "cutscene"; + + [Tooltip("Total duration in frames at 30fps. E.g. 90 = 3 seconds.")] + public int DurationFrames = 90; + + [Tooltip("Tracks driving properties over time.")] + public List Tracks = new List(); + + [Tooltip("Audio events triggered at specific frames.")] + public List AudioEvents = new List(); + } +} diff --git a/Runtime/PSXCutsceneClip.cs.meta b/Runtime/PSXCutsceneClip.cs.meta new file mode 100644 index 0000000..23f24ab --- /dev/null +++ b/Runtime/PSXCutsceneClip.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 99c0b28de0bbbf7449afc28106b605dc \ No newline at end of file diff --git a/Runtime/PSXCutsceneExporter.cs b/Runtime/PSXCutsceneExporter.cs new file mode 100644 index 0000000..846f156 --- /dev/null +++ b/Runtime/PSXCutsceneExporter.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// Serializes PSXCutsceneClip data into the splashpack v12 binary format. + /// Called from PSXSceneWriter.Write() after all other data sections. + /// + public static class PSXCutsceneExporter + { + // Match C++ limits + private const int MAX_CUTSCENES = 16; + private const int MAX_TRACKS = 8; + private const int MAX_KEYFRAMES = 64; + private const int MAX_AUDIO_EVENTS = 64; + private const int MAX_NAME_LEN = 24; + + /// + /// Angle conversion: degrees to psyqo::Angle raw value. + /// psyqo::Angle = FixedPoint<10>, stored in pi-units. + /// 1.0_pi = 1024 raw = 180 degrees. So: raw = degrees * 1024 / 180. + /// + private static short DegreesToAngleRaw(float degrees) + { + float raw = degrees * 1024.0f / 180.0f; + return (short)Mathf.Clamp(Mathf.RoundToInt(raw), -32768, 32767); + } + + /// + /// Write all cutscene data and return the byte position of the cutscene table + /// so the header can be backfilled. + /// + /// 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. + /// GTE scaling factor. + /// Returns the file position where the cutscene table starts. + /// Optional log callback. + public static void ExportCutscenes( + BinaryWriter writer, + PSXCutsceneClip[] cutscenes, + PSXObjectExporter[] exporters, + PSXAudioSource[] audioSources, + float gteScaling, + out long cutsceneTableStart, + Action log = null) + { + cutsceneTableStart = 0; + + if (cutscenes == null || cutscenes.Length == 0) + return; + + if (cutscenes.Length > MAX_CUTSCENES) + { + log?.Invoke($"Too many cutscenes ({cutscenes.Length} > {MAX_CUTSCENES}). Only the first {MAX_CUTSCENES} will be exported.", LogType.Warning); + var trimmed = new PSXCutsceneClip[MAX_CUTSCENES]; + Array.Copy(cutscenes, trimmed, MAX_CUTSCENES); + cutscenes = trimmed; + } + + // Build audio source name → index lookup + Dictionary audioNameToIndex = new Dictionary(); + if (audioSources != null) + { + for (int i = 0; i < audioSources.Length; i++) + { + if (!string.IsNullOrEmpty(audioSources[i].ClipName) && !audioNameToIndex.ContainsKey(audioSources[i].ClipName)) + audioNameToIndex[audioSources[i].ClipName] = i; + } + } + + AlignToFourBytes(writer); + + // ── Cutscene Table ── + cutsceneTableStart = writer.BaseStream.Position; + + // SPLASHPACKCutsceneEntry: 12 bytes each + // Write placeholders first, then backfill + long[] entryPositions = new long[cutscenes.Length]; + for (int i = 0; i < cutscenes.Length; i++) + { + entryPositions[i] = writer.BaseStream.Position; + writer.Write((uint)0); // dataOffset placeholder + writer.Write((byte)0); // nameLen placeholder + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + writer.Write((uint)0); // nameOffset placeholder + } + + // ── Per-cutscene data ── + for (int ci = 0; ci < cutscenes.Length; ci++) + { + PSXCutsceneClip clip = cutscenes[ci]; + AlignToFourBytes(writer); + + // Record data offset + long dataPos = writer.BaseStream.Position; + + // Validate and clamp + int trackCount = Mathf.Min(clip.Tracks?.Count ?? 0, MAX_TRACKS); + int audioEventCount = 0; + + // Count valid audio events + List validEvents = new List(); + if (clip.AudioEvents != null) + { + foreach (var evt in clip.AudioEvents) + { + if (audioNameToIndex.ContainsKey(evt.ClipName)) + { + validEvents.Add(evt); + if (validEvents.Count >= MAX_AUDIO_EVENTS) break; + } + else + { + log?.Invoke($"Cutscene '{clip.CutsceneName}': audio event clip '{evt.ClipName}' not found in scene audio sources. Skipping.", LogType.Warning); + } + } + } + audioEventCount = validEvents.Count; + + // Sort audio events by frame (required for linear scan on PS1) + validEvents.Sort((a, b) => a.Frame.CompareTo(b.Frame)); + + // SPLASHPACKCutscene: 12 bytes + long tracksOffsetPlaceholder; + long audioEventsOffsetPlaceholder; + + writer.Write((ushort)clip.DurationFrames); + writer.Write((byte)trackCount); + writer.Write((byte)audioEventCount); + tracksOffsetPlaceholder = writer.BaseStream.Position; + writer.Write((uint)0); // tracksOffset placeholder + audioEventsOffsetPlaceholder = writer.BaseStream.Position; + writer.Write((uint)0); // audioEventsOffset placeholder + + // ── Tracks ── + AlignToFourBytes(writer); + long tracksStart = writer.BaseStream.Position; + + // SPLASHPACKCutsceneTrack: 12 bytes each + long[] trackObjectNameOffsets = new long[trackCount]; + long[] trackKeyframesOffsets = new long[trackCount]; + + for (int ti = 0; ti < trackCount; ti++) + { + PSXCutsceneTrack track = clip.Tracks[ti]; + bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; + string objName = isCameraTrack ? "" : (track.ObjectName ?? ""); + if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN); + + int kfCount = Mathf.Min(track.Keyframes?.Count ?? 0, MAX_KEYFRAMES); + + writer.Write((byte)track.TrackType); + writer.Write((byte)kfCount); + writer.Write((byte)objName.Length); + writer.Write((byte)0); // pad + trackObjectNameOffsets[ti] = writer.BaseStream.Position; + writer.Write((uint)0); // objectNameOffset placeholder + trackKeyframesOffsets[ti] = writer.BaseStream.Position; + writer.Write((uint)0); // keyframesOffset placeholder + } + + // ── Keyframe data (per track) ── + for (int ti = 0; ti < trackCount; ti++) + { + PSXCutsceneTrack track = clip.Tracks[ti]; + int kfCount = Mathf.Min(track.Keyframes?.Count ?? 0, MAX_KEYFRAMES); + + AlignToFourBytes(writer); + long kfStart = writer.BaseStream.Position; + + // Sort keyframes by frame + var sortedKf = new List(track.Keyframes ?? new List()); + sortedKf.Sort((a, b) => a.Frame.CompareTo(b.Frame)); + + for (int ki = 0; ki < kfCount; ki++) + { + PSXKeyframe kf = sortedKf[ki]; + // Pack interp mode into upper 3 bits, frame into lower 13 bits + ushort frameAndInterp = (ushort)((((int)kf.Interp & 0x7) << 13) | (kf.Frame & 0x1FFF)); + writer.Write(frameAndInterp); + + switch (track.TrackType) + { + case PSXTrackType.CameraPosition: + case PSXTrackType.ObjectPosition: + { + // Position: convert to fp12, negate Y for PSX coords + float gte = gteScaling; + short px = PSXTrig.ConvertCoordinateToPSX(kf.Value.x, gte); + short py = PSXTrig.ConvertCoordinateToPSX(-kf.Value.y, gte); + short pz = PSXTrig.ConvertCoordinateToPSX(kf.Value.z, gte); + writer.Write(px); + writer.Write(py); + writer.Write(pz); + break; + } + case PSXTrackType.CameraRotation: + { + // Rotation: degrees → psyqo::Angle raw (pi-units) + short rx = DegreesToAngleRaw(kf.Value.x); + short ry = DegreesToAngleRaw(kf.Value.y); + short rz = DegreesToAngleRaw(kf.Value.z); + writer.Write(rx); + writer.Write(ry); + writer.Write(rz); + break; + } + case PSXTrackType.ObjectRotationY: + { + // Only Y rotation matters + writer.Write((short)0); + writer.Write(DegreesToAngleRaw(kf.Value.y)); + writer.Write((short)0); + break; + } + case PSXTrackType.ObjectActive: + { + writer.Write((short)(kf.Value.x > 0.5f ? 1 : 0)); + writer.Write((short)0); + writer.Write((short)0); + break; + } + } + } + + // Backfill keyframes offset + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)trackKeyframesOffsets[ti], SeekOrigin.Begin); + writer.Write((uint)kfStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + } + + // ── Object name strings (per track) ── + for (int ti = 0; ti < trackCount; ti++) + { + PSXCutsceneTrack track = clip.Tracks[ti]; + bool isCameraTrack = track.TrackType == PSXTrackType.CameraPosition || track.TrackType == PSXTrackType.CameraRotation; + string objName = isCameraTrack ? "" : (track.ObjectName ?? ""); + if (objName.Length > MAX_NAME_LEN) objName = objName.Substring(0, MAX_NAME_LEN); + + if (objName.Length > 0) + { + long namePos = writer.BaseStream.Position; + byte[] nameBytes = Encoding.UTF8.GetBytes(objName); + writer.Write(nameBytes); + writer.Write((byte)0); // null terminator + + long curPos = writer.BaseStream.Position; + writer.Seek((int)trackObjectNameOffsets[ti], SeekOrigin.Begin); + writer.Write((uint)namePos); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + // else: objectNameOffset stays 0 + } + + // ── Audio events ── + AlignToFourBytes(writer); + long audioEventsStart = writer.BaseStream.Position; + + foreach (var evt in validEvents) + { + int clipIdx = audioNameToIndex[evt.ClipName]; + writer.Write((ushort)evt.Frame); + writer.Write((byte)clipIdx); + writer.Write((byte)Mathf.Clamp(evt.Volume, 0, 128)); + writer.Write((byte)Mathf.Clamp(evt.Pan, 0, 127)); + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + } + + // ── 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; + byte[] csNameBytes = Encoding.UTF8.GetBytes(csName); + writer.Write(csNameBytes); + writer.Write((byte)0); // null terminator + + // ── Backfill SPLASHPACKCutscene offsets ── + { + long curPos = writer.BaseStream.Position; + + // tracksOffset + writer.Seek((int)tracksOffsetPlaceholder, SeekOrigin.Begin); + writer.Write((uint)tracksStart); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + { + long curPos = writer.BaseStream.Position; + + // audioEventsOffset + writer.Seek((int)audioEventsOffsetPlaceholder, SeekOrigin.Begin); + writer.Write((uint)(audioEventCount > 0 ? audioEventsStart : 0)); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + + // ── Backfill cutscene table entry ── + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)entryPositions[ci], SeekOrigin.Begin); + writer.Write((uint)dataPos); // dataOffset + writer.Write((byte)csNameBytes.Length); // nameLen + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + writer.Write((byte)0); // pad + writer.Write((uint)nameStartPos); // nameOffset + writer.Seek((int)curPos, SeekOrigin.Begin); + } + } + + log?.Invoke($"{cutscenes.Length} cutscene(s) exported.", LogType.Log); + } + + private static void AlignToFourBytes(BinaryWriter writer) + { + long pos = writer.BaseStream.Position; + int padding = (int)(4 - (pos % 4)) % 4; + if (padding > 0) + writer.Write(new byte[padding]); + } + } +} diff --git a/Runtime/PSXCutsceneExporter.cs.meta b/Runtime/PSXCutsceneExporter.cs.meta new file mode 100644 index 0000000..7dcf4ee --- /dev/null +++ b/Runtime/PSXCutsceneExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d915e0da5e09ea348a020d077f0725da \ No newline at end of file diff --git a/Runtime/PSXCutsceneTrack.cs b/Runtime/PSXCutsceneTrack.cs new file mode 100644 index 0000000..478435b --- /dev/null +++ b/Runtime/PSXCutsceneTrack.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A single track within a cutscene, driving one property on one target. + /// + [Serializable] + public class PSXCutsceneTrack + { + [Tooltip("What property this track drives.")] + public PSXTrackType TrackType; + + [Tooltip("Target GameObject name (must match a PSXObjectExporter). Leave empty for camera tracks.")] + public string ObjectName = ""; + + [Tooltip("Keyframes for this track. Sort by frame number.")] + public List Keyframes = new List(); + } +} diff --git a/Runtime/PSXCutsceneTrack.cs.meta b/Runtime/PSXCutsceneTrack.cs.meta new file mode 100644 index 0000000..c3e0df1 --- /dev/null +++ b/Runtime/PSXCutsceneTrack.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c289e12325ed44499d49bc7a570c8de \ No newline at end of file diff --git a/Runtime/PSXInterpMode.cs b/Runtime/PSXInterpMode.cs new file mode 100644 index 0000000..c8083cb --- /dev/null +++ b/Runtime/PSXInterpMode.cs @@ -0,0 +1,15 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// Per-keyframe interpolation mode. Must match the C++ InterpMode enum in cutscene.hh. + /// Packed into the upper 3 bits of the 16-bit frame field on export. + /// + public enum PSXInterpMode : byte + { + Linear = 0, + Step = 1, + EaseIn = 2, + EaseOut = 3, + EaseInOut = 4, + } +} diff --git a/Runtime/PSXInterpMode.cs.meta b/Runtime/PSXInterpMode.cs.meta new file mode 100644 index 0000000..95f7cf8 --- /dev/null +++ b/Runtime/PSXInterpMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6f925971a446a614a9f4f3e61f2395c0 \ No newline at end of file diff --git a/Runtime/PSXKeyframe.cs b/Runtime/PSXKeyframe.cs new file mode 100644 index 0000000..5044a4e --- /dev/null +++ b/Runtime/PSXKeyframe.cs @@ -0,0 +1,26 @@ +using System; +using UnityEngine; + +namespace SplashEdit.RuntimeCode +{ + /// + /// A single keyframe in a cutscene track. + /// Value interpretation depends on track type: + /// CameraPosition / ObjectPosition: Unity world-space position (x, y, z) + /// CameraRotation: Euler angles in degrees (x=pitch, y=yaw, z=roll) + /// ObjectRotationY: y component = rotation in degrees + /// ObjectActive: x component = 0.0 (inactive) or 1.0 (active) + /// + [Serializable] + public class PSXKeyframe + { + [Tooltip("Frame number (0 = start of cutscene). At 30fps, frame 30 = 1 second.")] + public int Frame; + + [Tooltip("Keyframe value. Interpretation depends on track type.")] + public Vector3 Value; + + [Tooltip("Interpolation mode from this keyframe to the next.")] + public PSXInterpMode Interp = PSXInterpMode.Linear; + } +} diff --git a/Runtime/PSXKeyframe.cs.meta b/Runtime/PSXKeyframe.cs.meta new file mode 100644 index 0000000..d639a70 --- /dev/null +++ b/Runtime/PSXKeyframe.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1b2679967786bcf4e81793694cd3dfeb \ No newline at end of file diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs index b8e0645..8b7098b 100644 --- a/Runtime/PSXSceneExporter.cs +++ b/Runtime/PSXSceneExporter.cs @@ -37,6 +37,10 @@ namespace SplashEdit.RuntimeCode [Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")] public int SceneType = 0; // 0=exterior, 1=interior + [Header("Cutscenes")] + [Tooltip("Cutscene clips to include in this scene's splashpack. Only these will be exported.")] + public PSXCutsceneClip[] Cutscenes = new PSXCutsceneClip[0]; + private PSXObjectExporter[] _exporters; private TextureAtlas[] _atlases; @@ -254,6 +258,8 @@ namespace SplashEdit.RuntimeCode fogEnabled = FogEnabled, fogColor = FogColor, fogDensity = FogDensity, + cutscenes = Cutscenes, + audioSources = _audioSources, }; PSXSceneWriter.Write(path, in scene, (msg, type) => diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs index 5766261..248a35d 100644 --- a/Runtime/PSXSceneWriter.cs +++ b/Runtime/PSXSceneWriter.cs @@ -29,6 +29,10 @@ namespace SplashEdit.RuntimeCode public LuaFile sceneLuaFile; public float gteScaling; + // Cutscene data (v12) + public PSXCutsceneClip[] cutscenes; + public PSXAudioSource[] audioSources; + // Player public Vector3 playerPos; public Quaternion playerRot; @@ -115,7 +119,7 @@ namespace SplashEdit.RuntimeCode // ────────────────────────────────────────────────────── writer.Write('S'); writer.Write('P'); - writer.Write((ushort)11); // version + writer.Write((ushort)12); // version writer.Write((ushort)luaFiles.Count); writer.Write((ushort)scene.exporters.Length); writer.Write((ushort)0); // navmeshCount (legacy) @@ -208,6 +212,13 @@ namespace SplashEdit.RuntimeCode writer.Write((ushort)roomTriRefCount); } + // Cutscene header (version 12, 8 bytes) + int cutsceneCount = scene.cutscenes?.Length ?? 0; + writer.Write((ushort)cutsceneCount); + writer.Write((ushort)0); // reserved_cs + long cutsceneTableOffsetPos = writer.BaseStream.Position; + writer.Write((uint)0); // cutsceneTableOffset placeholder + // ────────────────────────────────────────────────────── // Lua file metadata // ────────────────────────────────────────────────────── @@ -582,6 +593,30 @@ namespace SplashEdit.RuntimeCode log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log); } + // ────────────────────────────────────────────────────── + // Cutscene data (version 12) + // ────────────────────────────────────────────────────── + if (cutsceneCount > 0) + { + PSXCutsceneExporter.ExportCutscenes( + writer, + scene.cutscenes, + scene.exporters, + scene.audioSources, + scene.gteScaling, + out long cutsceneTableActual, + log); + + // Backfill cutscene table offset in header + if (cutsceneTableActual != 0) + { + long curPos = writer.BaseStream.Position; + writer.Seek((int)cutsceneTableOffsetPos, SeekOrigin.Begin); + writer.Write((uint)cutsceneTableActual); + writer.Seek((int)curPos, SeekOrigin.Begin); + } + } + // Backfill offsets BackfillOffsets(writer, luaOffset, "lua", log); BackfillOffsets(writer, meshOffset, "mesh", log); diff --git a/Runtime/PSXTrackType.cs b/Runtime/PSXTrackType.cs new file mode 100644 index 0000000..ea986a5 --- /dev/null +++ b/Runtime/PSXTrackType.cs @@ -0,0 +1,14 @@ +namespace SplashEdit.RuntimeCode +{ + /// + /// Cutscene track types. Must match the C++ TrackType enum in cutscene.hh. + /// + public enum PSXTrackType : byte + { + CameraPosition = 0, + CameraRotation = 1, + ObjectPosition = 2, + ObjectRotationY = 3, + ObjectActive = 4, + } +} diff --git a/Runtime/PSXTrackType.cs.meta b/Runtime/PSXTrackType.cs.meta new file mode 100644 index 0000000..2000739 --- /dev/null +++ b/Runtime/PSXTrackType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fec0beba902819b4e8af4a684b908361 \ No newline at end of file