cutscene system
This commit is contained in:
3
Makefile
3
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
302
src/cutscene.cpp
Normal file
302
src/cutscene.cpp
Normal file
@@ -0,0 +1,302 @@
|
||||
#include "cutscene.hh"
|
||||
|
||||
#include <psyqo/fixed-point.hh>
|
||||
#include <psyqo/soft-math.hh>
|
||||
#include <psyqo/trigonometry.hh>
|
||||
|
||||
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
|
||||
115
src/cutscene.hh
Normal file
115
src/cutscene.hh
Normal file
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <psyqo/fixed-point.hh>
|
||||
#include <psyqo/trigonometry.hh>
|
||||
#include <psyqo/soft-math.hh>
|
||||
|
||||
#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<InterpMode>(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
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "gameobject.hh"
|
||||
#include "controls.hh"
|
||||
#include "camera.hh"
|
||||
#include "cutscene.hh"
|
||||
|
||||
#include <psyqo/soft-math.hh>
|
||||
#include <psyqo/trigonometry.hh>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -270,6 +270,7 @@ void psxsplash::Renderer::Render(eastl::vector<GameObject*>& 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<GameObject*>& 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<GameObject*>& 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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<psxsplash::SPLASHPACKFileHeader *>(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<uint32_t*>(tablePtr); tablePtr += 4;
|
||||
uint8_t nameLen = *tablePtr++;
|
||||
tablePtr += 3; // pad
|
||||
uint32_t nameOffset = *reinterpret_cast<uint32_t*>(tablePtr); tablePtr += 4;
|
||||
|
||||
Cutscene& cs = setup.loadedCutscenes[ci];
|
||||
cs.name = (nameLen > 0 && nameOffset != 0)
|
||||
? reinterpret_cast<const char*>(data + nameOffset)
|
||||
: nullptr;
|
||||
|
||||
// SPLASHPACKCutscene: 12 bytes at dataOffset
|
||||
uint8_t* csPtr = data + dataOffset;
|
||||
cs.totalFrames = *reinterpret_cast<uint16_t*>(csPtr); csPtr += 2;
|
||||
cs.trackCount = *csPtr++;
|
||||
cs.audioEventCount = *csPtr++;
|
||||
uint32_t tracksOff = *reinterpret_cast<uint32_t*>(csPtr); csPtr += 4;
|
||||
uint32_t audioOff = *reinterpret_cast<uint32_t*>(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<CutsceneAudioEvent*>(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<TrackType>(*trackPtr++);
|
||||
track.keyframeCount = *trackPtr++;
|
||||
uint8_t objNameLen = *trackPtr++;
|
||||
trackPtr++; // pad
|
||||
uint32_t objNameOff = *reinterpret_cast<uint32_t*>(trackPtr); trackPtr += 4;
|
||||
uint32_t kfOff = *reinterpret_cast<uint32_t*>(trackPtr); trackPtr += 4;
|
||||
|
||||
// Resolve keyframes pointer
|
||||
track.keyframes = (track.keyframeCount > 0 && kfOff != 0)
|
||||
? reinterpret_cast<CutsceneKeyframe*>(data + kfOff)
|
||||
: nullptr;
|
||||
|
||||
// Resolve target object by name
|
||||
track.target = nullptr;
|
||||
if (objNameLen > 0 && objNameOff != 0) {
|
||||
const char* objName = reinterpret_cast<const char*>(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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user