Cutscene sytstem
This commit is contained in:
706
Editor/PSXCutsceneEditor.cs
Normal file
706
Editor/PSXCutsceneEditor.cs
Normal file
@@ -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<int> _firedAudioEventIndices = new HashSet<int>();
|
||||
|
||||
// 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<string, Vector3> _savedObjectPositions = new Dictionary<string, Vector3>();
|
||||
private Dictionary<string, Quaternion> _savedObjectRotations = new Dictionary<string, Quaternion>();
|
||||
private Dictionary<string, bool> _savedObjectActive = new Dictionary<string, bool>();
|
||||
|
||||
// Audio preview
|
||||
private Dictionary<string, AudioClip> _audioClipCache = new Dictionary<string, AudioClip>();
|
||||
|
||||
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<string>();
|
||||
var audioNames = new HashSet<string>();
|
||||
var exporters = Object.FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
|
||||
foreach (var e in exporters)
|
||||
exporterNames.Add(e.gameObject.name);
|
||||
var audioSources = Object.FindObjectsByType<PSXAudioSource>(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<PSXCutsceneTrack>();
|
||||
|
||||
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<PSXKeyframe>();
|
||||
|
||||
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<PSXAudioEvent>();
|
||||
|
||||
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<PSXAudioSource>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply easing curve to a linear t value (0..1). Matches the C++ applyCurve().
|
||||
/// </summary>
|
||||
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<PSXKeyframe> 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
|
||||
2
Editor/PSXCutsceneEditor.cs.meta
Normal file
2
Editor/PSXCutsceneEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad1b0e43d59aa0446b4e1d6497e8ee94
|
||||
27
Runtime/PSXAudioEvent.cs
Normal file
27
Runtime/PSXAudioEvent.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// A frame-based audio trigger within a cutscene.
|
||||
/// When the cutscene reaches this frame, the named audio clip is played.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXAudioEvent.cs.meta
Normal file
2
Runtime/PSXAudioEvent.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 264e92578fac5014aa24c1e38e116b3b
|
||||
27
Runtime/PSXCutsceneClip.cs
Normal file
27
Runtime/PSXCutsceneClip.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<PSXCutsceneTrack> Tracks = new List<PSXCutsceneTrack>();
|
||||
|
||||
[Tooltip("Audio events triggered at specific frames.")]
|
||||
public List<PSXAudioEvent> AudioEvents = new List<PSXAudioEvent>();
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXCutsceneClip.cs.meta
Normal file
2
Runtime/PSXCutsceneClip.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99c0b28de0bbbf7449afc28106b605dc
|
||||
333
Runtime/PSXCutsceneExporter.cs
Normal file
333
Runtime/PSXCutsceneExporter.cs
Normal file
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes PSXCutsceneClip data into the splashpack v12 binary format.
|
||||
/// Called from PSXSceneWriter.Write() after all other data sections.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static short DegreesToAngleRaw(float degrees)
|
||||
{
|
||||
float raw = degrees * 1024.0f / 180.0f;
|
||||
return (short)Mathf.Clamp(Mathf.RoundToInt(raw), -32768, 32767);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write all cutscene data and return the byte position of the cutscene table
|
||||
/// so the header can be backfilled.
|
||||
/// </summary>
|
||||
/// <param name="writer">Binary writer positioned after all prior sections.</param>
|
||||
/// <param name="cutscenes">Cutscene clips to export (may be null/empty).</param>
|
||||
/// <param name="exporters">Scene object exporters for name validation.</param>
|
||||
/// <param name="audioSources">Audio sources for clip name → index resolution.</param>
|
||||
/// <param name="gteScaling">GTE scaling factor.</param>
|
||||
/// <param name="cutsceneTableStart">Returns the file position where the cutscene table starts.</param>
|
||||
/// <param name="log">Optional log callback.</param>
|
||||
public static void ExportCutscenes(
|
||||
BinaryWriter writer,
|
||||
PSXCutsceneClip[] cutscenes,
|
||||
PSXObjectExporter[] exporters,
|
||||
PSXAudioSource[] audioSources,
|
||||
float gteScaling,
|
||||
out long cutsceneTableStart,
|
||||
Action<string, LogType> 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<string, int> audioNameToIndex = new Dictionary<string, int>();
|
||||
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<PSXAudioEvent> validEvents = new List<PSXAudioEvent>();
|
||||
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<PSXKeyframe>(track.Keyframes ?? new List<PSXKeyframe>());
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXCutsceneExporter.cs.meta
Normal file
2
Runtime/PSXCutsceneExporter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d915e0da5e09ea348a020d077f0725da
|
||||
22
Runtime/PSXCutsceneTrack.cs
Normal file
22
Runtime/PSXCutsceneTrack.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// A single track within a cutscene, driving one property on one target.
|
||||
/// </summary>
|
||||
[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<PSXKeyframe> Keyframes = new List<PSXKeyframe>();
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXCutsceneTrack.cs.meta
Normal file
2
Runtime/PSXCutsceneTrack.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c289e12325ed44499d49bc7a570c8de
|
||||
15
Runtime/PSXInterpMode.cs
Normal file
15
Runtime/PSXInterpMode.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum PSXInterpMode : byte
|
||||
{
|
||||
Linear = 0,
|
||||
Step = 1,
|
||||
EaseIn = 2,
|
||||
EaseOut = 3,
|
||||
EaseInOut = 4,
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXInterpMode.cs.meta
Normal file
2
Runtime/PSXInterpMode.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f925971a446a614a9f4f3e61f2395c0
|
||||
26
Runtime/PSXKeyframe.cs
Normal file
26
Runtime/PSXKeyframe.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXKeyframe.cs.meta
Normal file
2
Runtime/PSXKeyframe.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b2679967786bcf4e81793694cd3dfeb
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
14
Runtime/PSXTrackType.cs
Normal file
14
Runtime/PSXTrackType.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Cutscene track types. Must match the C++ TrackType enum in cutscene.hh.
|
||||
/// </summary>
|
||||
public enum PSXTrackType : byte
|
||||
{
|
||||
CameraPosition = 0,
|
||||
CameraRotation = 1,
|
||||
ObjectPosition = 2,
|
||||
ObjectRotationY = 3,
|
||||
ObjectActive = 4,
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXTrackType.cs.meta
Normal file
2
Runtime/PSXTrackType.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fec0beba902819b4e8af4a684b908361
|
||||
Reference in New Issue
Block a user