diff --git a/Makefile b/Makefile index f3e355f..a7654fc 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,8 @@ src/audiomanager.cpp \ src/controls.cpp \ src/profiler.cpp \ src/collision.cpp \ -src/bvh.cpp +src/bvh.cpp \ +src/cutscene.cpp CPPFLAGS += -DPCDRV_SUPPORT=1 diff --git a/src/camera.cpp b/src/camera.cpp index c7e3834..2c19b80 100644 --- a/src/camera.cpp +++ b/src/camera.cpp @@ -23,6 +23,10 @@ void psxsplash::Camera::SetPosition(psyqo::FixedPoint<12> x, psyqo::FixedPoint<1 } void psxsplash::Camera::SetRotation(psyqo::Angle x, psyqo::Angle y, psyqo::Angle z) { + m_angleX = (int16_t)x.value; + m_angleY = (int16_t)y.value; + m_angleZ = (int16_t)z.value; + auto rotX = psyqo::SoftMath::generateRotationMatrix33(x, psyqo::SoftMath::Axis::X, m_trig); auto rotY = psyqo::SoftMath::generateRotationMatrix33(y, psyqo::SoftMath::Axis::Y, m_trig); auto rotZ = psyqo::SoftMath::generateRotationMatrix33(z, psyqo::SoftMath::Axis::Z, m_trig); diff --git a/src/camera.hh b/src/camera.hh index 45dffd0..996589f 100644 --- a/src/camera.hh +++ b/src/camera.hh @@ -27,9 +27,15 @@ class Camera { /// Near/Far planes based on typical PS1 draw distances void ExtractFrustum(Frustum& frustum) const; + /// Cached Euler angles (psyqo::Angle raw values) from the last SetRotation() call. + int16_t GetAngleX() const { return m_angleX; } + int16_t GetAngleY() const { return m_angleY; } + int16_t GetAngleZ() const { return m_angleZ; } + private: psyqo::Matrix33 m_rotationMatrix; psyqo::Trig<> m_trig; psyqo::Vec3 m_position; + int16_t m_angleX = 0, m_angleY = 0, m_angleZ = 0; }; } // namespace psxsplash \ No newline at end of file diff --git a/src/cutscene.cpp b/src/cutscene.cpp new file mode 100644 index 0000000..bb3d9ae --- /dev/null +++ b/src/cutscene.cpp @@ -0,0 +1,302 @@ +#include "cutscene.hh" + +#include +#include +#include + +namespace psxsplash { + +// Bare-metal string compare (avoids linking libc) +static bool cs_streq(const char* a, const char* b) { + while (*a && *b) { + if (*a++ != *b++) return false; + } + return *a == *b; +} + +void CutscenePlayer::init(Cutscene* cutscenes, int count, Camera* camera, AudioManager* audio) { + m_cutscenes = cutscenes; + m_count = count; + m_active = nullptr; + m_frame = 0; + m_nextAudio = 0; + m_camera = camera; + m_audio = audio; +} + +bool CutscenePlayer::play(const char* name) { + if (!name || !m_cutscenes) return false; + + for (int i = 0; i < m_count; i++) { + if (m_cutscenes[i].name && cs_streq(m_cutscenes[i].name, name)) { + m_active = &m_cutscenes[i]; + m_frame = 0; + m_nextAudio = 0; + + // Capture initial state for pre-first-keyframe blending + for (uint8_t ti = 0; ti < m_active->trackCount; ti++) { + CutsceneTrack& track = m_active->tracks[ti]; + track.initialValues[0] = track.initialValues[1] = track.initialValues[2] = 0; + switch (track.trackType) { + case TrackType::CameraPosition: + if (m_camera) { + auto& pos = m_camera->GetPosition(); + track.initialValues[0] = (int16_t)pos.x.value; + track.initialValues[1] = (int16_t)pos.y.value; + track.initialValues[2] = (int16_t)pos.z.value; + } + break; + case TrackType::CameraRotation: + if (m_camera) { + track.initialValues[0] = m_camera->GetAngleX(); + track.initialValues[1] = m_camera->GetAngleY(); + track.initialValues[2] = m_camera->GetAngleZ(); + } + break; + case TrackType::ObjectPosition: + if (track.target) { + track.initialValues[0] = (int16_t)track.target->position.x.value; + track.initialValues[1] = (int16_t)track.target->position.y.value; + track.initialValues[2] = (int16_t)track.target->position.z.value; + } + break; + case TrackType::ObjectRotationY: + // Can't easily recover angle from matrix — default to 0 + break; + case TrackType::ObjectActive: + if (track.target) { + track.initialValues[0] = track.target->isActive() ? 1 : 0; + } + break; + } + } + + return true; + } + } + return false; +} + +void CutscenePlayer::stop() { + m_active = nullptr; +} + +void CutscenePlayer::tick() { + if (!m_active) return; + + // Apply all tracks at the current frame + for (uint8_t i = 0; i < m_active->trackCount; i++) { + applyTrack(m_active->tracks[i]); + } + + // Fire audio events whose frame has been reached + while (m_nextAudio < m_active->audioEventCount) { + CutsceneAudioEvent& evt = m_active->audioEvents[m_nextAudio]; + if (evt.frame <= m_frame) { + if (m_audio) { + m_audio->play(evt.clipIndex, evt.volume, evt.pan); + } + m_nextAudio++; + } else { + break; + } + } + + // Advance frame + m_frame++; + if (m_frame >= m_active->totalFrames) { + m_active = nullptr; // Cutscene finished + } +} + +// Apply easing curve to fixed-point t ∈ [0, 4096]. +static int32_t applyCurve(int32_t t, InterpMode mode) { + switch (mode) { + default: + case InterpMode::Linear: + return t; + case InterpMode::Step: + return 0; // snaps to 'a' value until next keyframe + case InterpMode::EaseIn: + // t² / 4096 + return (int32_t)((int64_t)t * t >> 12); + case InterpMode::EaseOut: + // 1 − (1−t)² = t*(2 − t) / 4096 + return (int32_t)(((int64_t)t * (8192 - t)) >> 12); + case InterpMode::EaseInOut: { + // Smoothstep: 3t² − 2t³ (all in 12-bit fixed point) + int64_t t2 = (int64_t)t * t; // 24-bit + int64_t t3 = t2 * t; // 36-bit + return (int32_t)((3 * t2 - 2 * (t3 >> 12)) >> 12); + } + } +} + +// Common helper: find surrounding keyframes and compute fixed-point t (0..4096). +// Returns false if result was clamped (no interpolation needed, out[] already set). +// When true, t already has the easing curve applied based on the *destination* keyframe's InterpMode. +static bool findKfPair(CutsceneKeyframe* kf, uint8_t count, uint16_t frame, + uint8_t& a, uint8_t& b, int32_t& t, int16_t out[3]) { + if (count == 0) { + out[0] = out[1] = out[2] = 0; + return false; + } + if (frame <= kf[0].getFrame() || count == 1) { + out[0] = kf[0].values[0]; + out[1] = kf[0].values[1]; + out[2] = kf[0].values[2]; + return false; + } + if (frame >= kf[count - 1].getFrame()) { + out[0] = kf[count - 1].values[0]; + out[1] = kf[count - 1].values[1]; + out[2] = kf[count - 1].values[2]; + return false; + } + b = 1; + while (b < count && kf[b].getFrame() <= frame) b++; + a = b - 1; + uint16_t span = kf[b].getFrame() - kf[a].getFrame(); + if (span == 0) { + out[0] = kf[a].values[0]; + out[1] = kf[a].values[1]; + out[2] = kf[a].values[2]; + return false; + } + uint32_t num = (uint32_t)(frame - kf[a].getFrame()) << 12; + int32_t rawT = (int32_t)(num / span); + t = applyCurve(rawT, kf[b].getInterp()); + return true; +} + +void CutscenePlayer::lerpKeyframes(CutsceneKeyframe* kf, uint8_t count, const int16_t initial[3], int16_t out[3]) { + uint8_t a, b; + int32_t t; + if (!findKfPair(kf, count, m_frame, a, b, t, out)) { + // If clamped to first keyframe and frame < first kf frame, blend from initial + if (count > 0 && kf[0].getFrame() > 0 && m_frame < kf[0].getFrame()) { + uint16_t span = kf[0].getFrame(); + uint32_t num = (uint32_t)m_frame << 12; + int32_t rawT = (int32_t)(num / span); + int32_t ct = applyCurve(rawT, kf[0].getInterp()); + for (int i = 0; i < 3; i++) { + int32_t delta = (int32_t)kf[0].values[i] - (int32_t)initial[i]; + out[i] = (int16_t)((int32_t)initial[i] + ((delta * ct) >> 12)); + } + } + return; + } + + for (int i = 0; i < 3; i++) { + int32_t delta = (int32_t)kf[b].values[i] - (int32_t)kf[a].values[i]; + out[i] = (int16_t)((int32_t)kf[a].values[i] + ((delta * t) >> 12)); + } +} + +// Shortest-path angle interpolation. +// Angles are in psyqo units where 2048 = full circle (2π). +static constexpr int32_t ANGLE_FULL_CIRCLE = 2048; +static constexpr int32_t ANGLE_HALF_CIRCLE = 1024; + +void CutscenePlayer::lerpAngles(CutsceneKeyframe* kf, uint8_t count, const int16_t initial[3], int16_t out[3]) { + uint8_t a, b; + int32_t t; + if (!findKfPair(kf, count, m_frame, a, b, t, out)) { + // If clamped to first keyframe and frame < first kf frame, blend from initial + if (count > 0 && kf[0].getFrame() > 0 && m_frame < kf[0].getFrame()) { + uint16_t span = kf[0].getFrame(); + uint32_t num = (uint32_t)m_frame << 12; + int32_t rawT = (int32_t)(num / span); + int32_t ct = applyCurve(rawT, kf[0].getInterp()); + for (int i = 0; i < 3; i++) { + int32_t from = (int32_t)initial[i]; + int32_t to = (int32_t)kf[0].values[i]; + int32_t delta = to - from; + delta = ((delta + ANGLE_HALF_CIRCLE) % ANGLE_FULL_CIRCLE + ANGLE_FULL_CIRCLE) % ANGLE_FULL_CIRCLE - ANGLE_HALF_CIRCLE; + out[i] = (int16_t)(from + ((delta * ct) >> 12)); + } + } + return; + } + + for (int i = 0; i < 3; i++) { + int32_t from = (int32_t)kf[a].values[i]; + int32_t to = (int32_t)kf[b].values[i]; + // Shortest-path: wrap delta into [-1024, +1024) + int32_t delta = to - from; + // Modulo into [-2048, +2048) then clamp to half-circle + delta = ((delta + ANGLE_HALF_CIRCLE) % ANGLE_FULL_CIRCLE + ANGLE_FULL_CIRCLE) % ANGLE_FULL_CIRCLE - ANGLE_HALF_CIRCLE; + out[i] = (int16_t)(from + ((delta * t) >> 12)); + } +} + +void CutscenePlayer::applyTrack(CutsceneTrack& track) { + if (track.keyframeCount == 0 || !track.keyframes) return; + + int16_t out[3]; + + switch (track.trackType) { + case TrackType::CameraPosition: { + if (!m_camera) return; + lerpKeyframes(track.keyframes, track.keyframeCount, track.initialValues, out); + psyqo::FixedPoint<12> x, y, z; + x.value = (int32_t)out[0]; + y.value = (int32_t)out[1]; + z.value = (int32_t)out[2]; + m_camera->SetPosition(x, y, z); + break; + } + + case TrackType::CameraRotation: { + if (!m_camera) return; + lerpAngles(track.keyframes, track.keyframeCount, track.initialValues, out); + psyqo::Angle rx, ry, rz; + rx.value = (int32_t)out[0]; + ry.value = (int32_t)out[1]; + rz.value = (int32_t)out[2]; + m_camera->SetRotation(rx, ry, rz); + break; + } + + case TrackType::ObjectPosition: { + if (!track.target) return; + lerpKeyframes(track.keyframes, track.keyframeCount, track.initialValues, out); + track.target->position.x.value = (int32_t)out[0]; + track.target->position.y.value = (int32_t)out[1]; + track.target->position.z.value = (int32_t)out[2]; + break; + } + + case TrackType::ObjectRotationY: { + if (!track.target) return; + lerpAngles(track.keyframes, track.keyframeCount, track.initialValues, out); + psyqo::Angle yAngle; + yAngle.value = (int32_t)out[1]; + track.target->rotation = psyqo::SoftMath::generateRotationMatrix33( + yAngle, psyqo::SoftMath::Axis::Y, m_trig); + break; + } + + case TrackType::ObjectActive: { + if (!track.target) return; + // Step interpolation: find the last keyframe at or before m_frame + CutsceneKeyframe* kf = track.keyframes; + uint8_t count = track.keyframeCount; + // Use initial state if we're before the first keyframe + int16_t activeVal = (count > 0 && m_frame < kf[0].getFrame()) + ? track.initialValues[0] + : kf[0].values[0]; + for (uint8_t i = 0; i < count; i++) { + if (kf[i].getFrame() <= m_frame) { + activeVal = kf[i].values[0]; + } else { + break; + } + } + track.target->setActive(activeVal != 0); + break; + } + } +} + +} // namespace psxsplash diff --git a/src/cutscene.hh b/src/cutscene.hh new file mode 100644 index 0000000..55f905a --- /dev/null +++ b/src/cutscene.hh @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include + +#include "camera.hh" +#include "gameobject.hh" +#include "audiomanager.hh" + +namespace psxsplash { + +static constexpr int MAX_CUTSCENES = 16; +static constexpr int MAX_TRACKS = 8; +static constexpr int MAX_KEYFRAMES = 64; +static constexpr int MAX_AUDIO_EVENTS = 64; + +enum class TrackType : uint8_t { + CameraPosition = 0, + CameraRotation = 1, + ObjectPosition = 2, + ObjectRotationY = 3, + ObjectActive = 4, +}; + +/// Per-keyframe interpolation mode. +/// Packed into upper 3 bits of the frame field in CutsceneKeyframe. +enum class InterpMode : uint8_t { + Linear = 0, // Default linear interpolation + Step = 1, // Instant jump (no interpolation) + EaseIn = 2, // Slow start, fast end (quadratic) + EaseOut = 3, // Fast start, slow end (quadratic) + EaseInOut = 4, // Smooth start and end (smoothstep) +}; + +struct CutsceneKeyframe { + // Upper 3 bits = InterpMode (0-7), lower 13 bits = frame number (0-8191). + // At 30fps, max frame 8191 ≈ 4.5 minutes. + uint16_t frameAndInterp; + int16_t values[3]; + + uint16_t getFrame() const { return frameAndInterp & 0x1FFF; } + InterpMode getInterp() const { return static_cast(frameAndInterp >> 13); } +}; +static_assert(sizeof(CutsceneKeyframe) == 8, "CutsceneKeyframe must be 8 bytes"); + +struct CutsceneAudioEvent { + uint16_t frame; + uint8_t clipIndex; + uint8_t volume; + uint8_t pan; + uint8_t pad[3]; +}; +static_assert(sizeof(CutsceneAudioEvent) == 8, "CutsceneAudioEvent must be 8 bytes"); + +struct CutsceneTrack { + TrackType trackType; + uint8_t keyframeCount; + uint8_t pad[2]; + CutsceneKeyframe* keyframes; // Points into splashpack data (resolved at load time) + GameObject* target; // nullptr = camera track + + /// Initial values captured at play() time for pre-first-keyframe blending. + /// For position tracks: fp12 x,y,z. For rotation tracks: raw angle values. + /// For ObjectActive: values[0] = 1 (active) or 0 (inactive). + int16_t initialValues[3]; + int16_t _initPad; +}; + +struct Cutscene { + const char* name; // Points into splashpack data + uint16_t totalFrames; + uint8_t trackCount; + uint8_t audioEventCount; + CutsceneTrack tracks[MAX_TRACKS]; + CutsceneAudioEvent* audioEvents; // Points into splashpack data +}; + +/// Zero-allocation cutscene player. Call init() once after splashpack is loaded, +/// then tick() once per frame from the scene loop. +class CutscenePlayer { +public: + /// Initialize with loaded cutscene data. Safe to pass nullptr/0 if no cutscenes. + void init(Cutscene* cutscenes, int count, Camera* camera, AudioManager* audio); + + /// Play cutscene by name. Returns false if not found. + bool play(const char* name); + + /// Stop the current cutscene immediately. + void stop(); + + /// True if a cutscene is currently active. + bool isPlaying() const { return m_active != nullptr; } + + /// Advance one frame. Call once per frame. Does nothing when idle. + void tick(); + +private: + Cutscene* m_cutscenes = nullptr; + int m_count = 0; + Cutscene* m_active = nullptr; + uint16_t m_frame = 0; + uint8_t m_nextAudio = 0; + Camera* m_camera = nullptr; + AudioManager* m_audio = nullptr; + + psyqo::Trig<> m_trig; + + void applyTrack(CutsceneTrack& track); + void lerpKeyframes(CutsceneKeyframe* kf, uint8_t count, const int16_t initial[3], int16_t out[3]); + void lerpAngles(CutsceneKeyframe* kf, uint8_t count, const int16_t initial[3], int16_t out[3]); +}; + +} // namespace psxsplash diff --git a/src/luaapi.cpp b/src/luaapi.cpp index 03633e9..65e4f2e 100644 --- a/src/luaapi.cpp +++ b/src/luaapi.cpp @@ -3,6 +3,7 @@ #include "gameobject.hh" #include "controls.hh" #include "camera.hh" +#include "cutscene.hh" #include #include @@ -13,6 +14,7 @@ namespace psxsplash { // Static member SceneManager* LuaAPI::s_sceneManager = nullptr; +CutscenePlayer* LuaAPI::s_cutscenePlayer = nullptr; // Scale factor: FixedPoint<12> stores 1.0 as raw 4096. // Lua scripts work in world-space units (1 = one unit), so we convert. @@ -34,8 +36,9 @@ static psyqo::Trig<> s_trig; // REGISTRATION // ============================================================================ -void LuaAPI::RegisterAll(psyqo::Lua& L, SceneManager* scene) { +void LuaAPI::RegisterAll(psyqo::Lua& L, SceneManager* scene, CutscenePlayer* cutscenePlayer) { s_sceneManager = scene; + s_cutscenePlayer = cutscenePlayer; // ======================================================================== // ENTITY API @@ -262,6 +265,22 @@ void LuaAPI::RegisterAll(psyqo::Lua& L, SceneManager* scene) { L.setField(-2, "Set"); L.setGlobal("Persist"); + + // ======================================================================== + // CUTSCENE API + // ======================================================================== + L.newTable(); // Cutscene table + + L.push(Cutscene_Play); + L.setField(-2, "Play"); + + L.push(Cutscene_Stop); + L.setField(-2, "Stop"); + + L.push(Cutscene_IsPlaying); + L.setField(-2, "IsPlaying"); + + L.setGlobal("Cutscene"); } // ============================================================================ @@ -1382,4 +1401,40 @@ void LuaAPI::PersistClear() { } } +// ============================================================================ +// CUTSCENE API IMPLEMENTATION +// ============================================================================ + +int LuaAPI::Cutscene_Play(lua_State* L) { + psyqo::Lua lua(L); + + if (!s_cutscenePlayer || !lua.isString(1)) { + return 0; + } + + const char* name = lua.toString(1); + s_cutscenePlayer->play(name); + return 0; +} + +int LuaAPI::Cutscene_Stop(lua_State* L) { + psyqo::Lua lua(L); + + if (s_cutscenePlayer) { + s_cutscenePlayer->stop(); + } + return 0; +} + +int LuaAPI::Cutscene_IsPlaying(lua_State* L) { + psyqo::Lua lua(L); + + if (s_cutscenePlayer) { + lua.push(s_cutscenePlayer->isPlaying()); + } else { + lua.push(false); + } + return 1; +} + } // namespace psxsplash diff --git a/src/luaapi.hh b/src/luaapi.hh index 56a7b28..62e12a4 100644 --- a/src/luaapi.hh +++ b/src/luaapi.hh @@ -7,6 +7,7 @@ namespace psxsplash { class SceneManager; // Forward declaration +class CutscenePlayer; // Forward declaration /** * Lua API - Provides game scripting functionality @@ -23,7 +24,7 @@ class SceneManager; // Forward declaration class LuaAPI { public: // Initialize all API modules - static void RegisterAll(psyqo::Lua& L, SceneManager* scene); + static void RegisterAll(psyqo::Lua& L, SceneManager* scene, CutscenePlayer* cutscenePlayer = nullptr); // Called once per frame to advance the Lua frame counter static void IncrementFrameCount(); @@ -35,6 +36,9 @@ private: // Store scene manager for API access static SceneManager* s_sceneManager; + // Cutscene player pointer (set during RegisterAll) + static CutscenePlayer* s_cutscenePlayer; + // ======================================================================== // ENTITY API // ======================================================================== @@ -250,6 +254,19 @@ private: // Reset all persistent data static void PersistClear(); + // ======================================================================== + // CUTSCENE API - Cutscene playback control + // ======================================================================== + + // Cutscene.Play(name) -> nil + static int Cutscene_Play(lua_State* L); + + // Cutscene.Stop() -> nil + static int Cutscene_Stop(lua_State* L); + + // Cutscene.IsPlaying() -> boolean + static int Cutscene_IsPlaying(lua_State* L); + // ======================================================================== // HELPERS // ======================================================================== diff --git a/src/renderer.cpp b/src/renderer.cpp index 4ae00b5..323127d 100644 --- a/src/renderer.cpp +++ b/src/renderer.cpp @@ -270,6 +270,7 @@ void psxsplash::Renderer::Render(eastl::vector& objects) { psyqo::Vec3 cameraPosition = computeCameraViewPos(); int32_t fogFarSZ = m_fog.fogFarSZ; for (auto& obj : objects) { + if (!obj->isActive()) continue; setupObjectTransform(obj, cameraPosition); for (int i = 0; i < obj->polyCount; i++) processTriangle(obj->polygons[i], fogFarSZ, ot, balloc); @@ -298,6 +299,7 @@ void psxsplash::Renderer::RenderWithBVH(eastl::vector& objects, con const TriangleRef& ref = m_visibleRefs[i]; if (ref.objectIndex >= objects.size()) continue; GameObject* obj = objects[ref.objectIndex]; + if (!obj->isActive()) continue; if (ref.triangleIndex >= obj->polyCount) continue; if (ref.objectIndex != lastObjectIndex) { lastObjectIndex = ref.objectIndex; @@ -528,6 +530,7 @@ void psxsplash::Renderer::RenderWithRooms(eastl::vector& objects, const TriangleRef& ref = roomTriRefs[rm.firstTriRef + ti]; if (ref.objectIndex >= objects.size()) continue; GameObject* obj = objects[ref.objectIndex]; + if (!obj->isActive()) continue; if (ref.triangleIndex >= obj->polyCount) continue; if (ref.objectIndex != lastObj) { lastObj = ref.objectIndex; setupObjectTransform(obj, cameraPosition); } processTriangle(obj->polygons[ref.triangleIndex], fogFarSZ, ot, balloc); diff --git a/src/scenemanager.cpp b/src/scenemanager.cpp index 623678f..70855f8 100644 --- a/src/scenemanager.cpp +++ b/src/scenemanager.cpp @@ -23,7 +23,7 @@ void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { m_audio.init(); // Register the Lua API - LuaAPI::RegisterAll(L.getState(), this); + LuaAPI::RegisterAll(L.getState(), this, &m_cutscenePlayer); #ifdef PSXSPLASH_PROFILER debug::Profiler::getInstance().initialize(); @@ -73,6 +73,20 @@ void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { m_audio.loadClip((int)i, clip.adpcmData, clip.sizeBytes, clip.sampleRate, clip.loop); } + // Copy cutscene data into scene manager storage (sceneSetup is stack-local) + m_cutsceneCount = sceneSetup.cutsceneCount; + for (int i = 0; i < m_cutsceneCount; i++) { + m_cutscenes[i] = sceneSetup.loadedCutscenes[i]; + } + + // Initialize cutscene player (v12+) + m_cutscenePlayer.init( + m_cutsceneCount > 0 ? m_cutscenes : nullptr, + m_cutsceneCount, + &m_currentCamera, + &m_audio + ); + m_playerPosition = sceneSetup.playerStartPosition; playerRotationX = 0.0_pi; @@ -150,6 +164,9 @@ void psxsplash::SceneManager::InitializeScene(uint8_t* splashpackData) { void psxsplash::SceneManager::GameTick(psyqo::GPU &gpu) { LuaAPI::IncrementFrameCount(); + // Tick cutscene player (advance frame and apply tracks before rendering) + m_cutscenePlayer.tick(); + // Delta-time measurement: count elapsed frames based on gpu timer // PS1 NTSC frame = ~33333 microseconds (30fps vsync) { @@ -168,12 +185,24 @@ void psxsplash::SceneManager::GameTick(psyqo::GPU &gpu) { // Dispatch render path based on scene type. // Interior scenes (type 1) use room/portal occlusion; exterior scenes use BVH culling. if (m_sceneType == 1 && m_roomCount > 0 && m_rooms != nullptr) { - // Get camera room from nav region system (authoritative) instead of AABB guessing. - // NavRegion::roomIndex is set during export from the room each region belongs to. + // Determine which room the camera is in for portal culling. + // During cutscene playback the camera may be elsewhere from the player, + // so use the actual camera position to find the room. int camRoom = -1; - if (m_navRegions.isLoaded() && m_playerNavRegion != NAV_NO_REGION) { - uint8_t ri = m_navRegions.getRoomIndex(m_playerNavRegion); - if (ri != 0xFF) camRoom = (int)ri; + if (m_navRegions.isLoaded()) { + if (m_cutscenePlayer.isPlaying()) { + // Camera-driven: look up nav region from camera world position + auto& camPos = m_currentCamera.GetPosition(); + uint16_t camRegion = m_navRegions.findRegion(camPos.x.value, camPos.z.value); + if (camRegion != NAV_NO_REGION) { + uint8_t ri = m_navRegions.getRoomIndex(camRegion); + if (ri != 0xFF) camRoom = (int)ri; + } + } else if (m_playerNavRegion != NAV_NO_REGION) { + // Normal gameplay: use cached player nav region + uint8_t ri = m_navRegions.getRoomIndex(m_playerNavRegion); + if (ri != 0xFF) camRoom = (int)ri; + } } renderer.RenderWithRooms(m_gameObjects, m_rooms, m_roomCount, m_portals, m_portalCount, m_roomTriRefs, camRoom); @@ -574,6 +603,8 @@ void psxsplash::SceneManager::clearScene() { // 3. Reset hardware / subsystems m_audio.reset(); // Free SPU RAM and stop all voices m_collisionSystem.init(); // Re-init collision system + m_cutsceneCount = 0; + m_cutscenePlayer.init(nullptr, 0, nullptr, nullptr); // Reset cutscene player // BVH, WorldCollision, and NavRegions will be overwritten by next load // Reset room/portal pointers (they point into splashpack data which is being freed) diff --git a/src/scenemanager.hh b/src/scenemanager.hh index db03854..027634f 100644 --- a/src/scenemanager.hh +++ b/src/scenemanager.hh @@ -19,6 +19,7 @@ #include "interactable.hh" #include "luaapi.hh" #include "sceneloader.hh" +#include "cutscene.hh" namespace psxsplash { class SceneManager { @@ -113,6 +114,11 @@ class SceneManager { // Audio system AudioManager m_audio; + // Cutscene playback + Cutscene m_cutscenes[MAX_CUTSCENES]; + int m_cutsceneCount = 0; + CutscenePlayer m_cutscenePlayer; + psxsplash::Controls m_controls; psxsplash::Camera m_currentCamera; diff --git a/src/splashpack.cpp b/src/splashpack.cpp index c9e5de9..c0427e0 100644 --- a/src/splashpack.cpp +++ b/src/splashpack.cpp @@ -9,6 +9,7 @@ #include "bvh.hh" #include "collision.hh" #include "gameobject.hh" +#include "cutscene.hh" #include "lua.h" #include "mesh.hh" #include "worldcollision.hh" @@ -17,6 +18,14 @@ namespace psxsplash { +// Bare-metal string compare (no libc) +static bool sp_streq(const char* a, const char* b) { + while (*a && *b) { + if (*a++ != *b++) return false; + } + return *a == *b; +} + struct SPLASHPACKFileHeader { char magic[2]; // "SP" uint16_t version; // Format version (8 = movement params) @@ -70,8 +79,12 @@ struct SPLASHPACKFileHeader { uint16_t roomCount; // 0 = no room system (use BVH path) uint16_t portalCount; uint16_t roomTriRefCount; + // Version 12 additions (cutscenes): + uint16_t cutsceneCount; + uint16_t reserved_cs; + uint32_t cutsceneTableOffset; }; -static_assert(sizeof(SPLASHPACKFileHeader) == 96, "SPLASHPACKFileHeader must be 96 bytes"); +static_assert(sizeof(SPLASHPACKFileHeader) == 104, "SPLASHPACKFileHeader must be 104 bytes"); struct SPLASHPACKTextureAtlas { uint32_t polygonsOffset; @@ -91,7 +104,7 @@ void SplashPackLoader::LoadSplashpack(uint8_t *data, SplashpackSceneSetup &setup psyqo::Kernel::assert(data != nullptr, "Splashpack loading data pointer is null"); psxsplash::SPLASHPACKFileHeader *header = reinterpret_cast(data); psyqo::Kernel::assert(__builtin_memcmp(header->magic, "SP", 2) == 0, "Splashpack has incorrect magic"); - psyqo::Kernel::assert(header->version >= 11, "Splashpack version too old: re-export from SplashEdit"); + psyqo::Kernel::assert(header->version >= 12, "Splashpack version too old (need v12+): re-export from SplashEdit"); setup.playerStartPosition = header->playerStartPos; setup.playerStartRotation = header->playerStartRot; @@ -288,6 +301,88 @@ void SplashPackLoader::LoadSplashpack(uint8_t *data, SplashpackSceneSetup &setup // Read scene type (version 6+ stored it but it was never read until now) setup.sceneType = header->sceneType; + // Read cutscene data (version 12+) + if (header->version >= 12 && header->cutsceneCount > 0 && header->cutsceneTableOffset != 0) { + setup.cutsceneCount = 0; + uint8_t* tablePtr = data + header->cutsceneTableOffset; + int csCount = header->cutsceneCount; + if (csCount > MAX_CUTSCENES) csCount = MAX_CUTSCENES; + + for (int ci = 0; ci < csCount; ci++) { + // SPLASHPACKCutsceneEntry: 12 bytes + uint32_t dataOffset = *reinterpret_cast(tablePtr); tablePtr += 4; + uint8_t nameLen = *tablePtr++; + tablePtr += 3; // pad + uint32_t nameOffset = *reinterpret_cast(tablePtr); tablePtr += 4; + + Cutscene& cs = setup.loadedCutscenes[ci]; + cs.name = (nameLen > 0 && nameOffset != 0) + ? reinterpret_cast(data + nameOffset) + : nullptr; + + // SPLASHPACKCutscene: 12 bytes at dataOffset + uint8_t* csPtr = data + dataOffset; + cs.totalFrames = *reinterpret_cast(csPtr); csPtr += 2; + cs.trackCount = *csPtr++; + cs.audioEventCount = *csPtr++; + uint32_t tracksOff = *reinterpret_cast(csPtr); csPtr += 4; + uint32_t audioOff = *reinterpret_cast(csPtr); csPtr += 4; + + if (cs.trackCount > MAX_TRACKS) cs.trackCount = MAX_TRACKS; + if (cs.audioEventCount > MAX_AUDIO_EVENTS) cs.audioEventCount = MAX_AUDIO_EVENTS; + + // Audio events pointer + cs.audioEvents = (cs.audioEventCount > 0 && audioOff != 0) + ? reinterpret_cast(data + audioOff) + : nullptr; + + // Parse tracks + uint8_t* trackPtr = data + tracksOff; + for (uint8_t ti = 0; ti < cs.trackCount; ti++) { + CutsceneTrack& track = cs.tracks[ti]; + + // SPLASHPACKCutsceneTrack: 12 bytes + track.trackType = static_cast(*trackPtr++); + track.keyframeCount = *trackPtr++; + uint8_t objNameLen = *trackPtr++; + trackPtr++; // pad + uint32_t objNameOff = *reinterpret_cast(trackPtr); trackPtr += 4; + uint32_t kfOff = *reinterpret_cast(trackPtr); trackPtr += 4; + + // Resolve keyframes pointer + track.keyframes = (track.keyframeCount > 0 && kfOff != 0) + ? reinterpret_cast(data + kfOff) + : nullptr; + + // Resolve target object by name + track.target = nullptr; + if (objNameLen > 0 && objNameOff != 0) { + const char* objName = reinterpret_cast(data + objNameOff); + for (size_t oi = 0; oi < setup.objectNames.size(); oi++) { + if (setup.objectNames[oi] && + sp_streq(setup.objectNames[oi], objName)) { + track.target = setup.objects[oi]; + break; + } + } + // If not found, target stays nullptr — track will be skipped at runtime + } + } + + // Zero out unused track slots + for (uint8_t ti = cs.trackCount; ti < MAX_TRACKS; ti++) { + cs.tracks[ti].keyframeCount = 0; + cs.tracks[ti].keyframes = nullptr; + cs.tracks[ti].target = nullptr; + cs.tracks[ti].initialValues[0] = 0; + cs.tracks[ti].initialValues[1] = 0; + cs.tracks[ti].initialValues[2] = 0; + } + + setup.cutsceneCount++; + } + } + } } // namespace psxsplash diff --git a/src/splashpack.hh b/src/splashpack.hh index 671cefb..dea711c 100644 --- a/src/splashpack.hh +++ b/src/splashpack.hh @@ -12,6 +12,7 @@ #include "navregion.hh" #include "audiomanager.hh" #include "interactable.hh" +#include "cutscene.hh" namespace psxsplash { @@ -85,6 +86,10 @@ struct SplashpackSceneSetup { psyqo::FixedPoint<12, uint16_t> jumpVelocity; // Per-second initial velocity (fp12) psyqo::FixedPoint<12, uint16_t> gravity; // Per-second² acceleration (fp12) psyqo::FixedPoint<12, uint16_t> playerRadius; // Collision radius (fp12) + + // Cutscenes (version 12+) + Cutscene loadedCutscenes[MAX_CUTSCENES]; + int cutsceneCount = 0; }; class SplashPackLoader {