Refactoring cutscenes
This commit is contained in:
1 parent
7011bbb754
commit
2e5988f8f4
20 files changed
+1913
-1881
No files matched your search
@@ -0,0 +1,172 @@
|
||||
#include "cutscene/CutsceneDatabase.h"
|
||||
|
||||
#include "utils/Utils.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace ZL
|
||||
{
|
||||
extern const char* CONST_ZIP_FILE;
|
||||
}
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
EasingType CutsceneDatabase::parseEasingType(const std::string& value) {
|
||||
if (value == "EaseInSine") return EasingType::EaseInSine;
|
||||
if (value == "EaseOutSine") return EasingType::EaseOutSine;
|
||||
if (value == "EaseInOutSine") return EasingType::EaseInOutSine;
|
||||
if (value == "EaseInQuad") return EasingType::EaseInQuad;
|
||||
if (value == "EaseOutQuad") return EasingType::EaseOutQuad;
|
||||
if (value == "EaseInOutQuad") return EasingType::EaseInOutQuad;
|
||||
if (value == "EaseInCubic") return EasingType::EaseInCubic;
|
||||
if (value == "EaseOutCubic") return EasingType::EaseOutCubic;
|
||||
if (value == "EaseInOutCubic") return EasingType::EaseInOutCubic;
|
||||
return EasingType::Linear;
|
||||
}
|
||||
|
||||
CutsceneAnchor CutsceneDatabase::parseCutsceneAnchor(const std::string& value) {
|
||||
if (value == "TopLeft") return CutsceneAnchor::TopLeft;
|
||||
if (value == "TopRight") return CutsceneAnchor::TopRight;
|
||||
if (value == "BottomRight") return CutsceneAnchor::BottomRight;
|
||||
if (value == "BottomLeft") return CutsceneAnchor::BottomLeft;
|
||||
if (value == "Custom") return CutsceneAnchor::Custom;
|
||||
return CutsceneAnchor::Center;
|
||||
}
|
||||
|
||||
CutsceneLine CutsceneDatabase::parseCutsceneLine(const json& j) {
|
||||
CutsceneLine line;
|
||||
line.speaker = j.value("speaker", "");
|
||||
line.text = j.value("text", "");
|
||||
line.portrait = j.value("portrait", "");
|
||||
line.sfx = j.value("sfx", "");
|
||||
line.background = j.value("background", "");
|
||||
line.backgroundWidth = j.value("backgroundWidth", 0);
|
||||
line.backgroundHeight= j.value("backgroundHeight", 0);
|
||||
line.luaCallback = j.value("luaCallback", "");
|
||||
line.durationMs = j.value("durationMs", 0);
|
||||
line.waitForConfirm = j.value("waitForConfirm", false);
|
||||
line.questUnlock = j.value("questUnlock", "");
|
||||
line.questComplete = j.value("questComplete", "");
|
||||
line.questFail = j.value("questFail", "");
|
||||
line.objectiveComplete = j.value("objectiveComplete", "");
|
||||
line.objectiveVisible = j.value("objectiveVisible", "");
|
||||
return line;
|
||||
}
|
||||
|
||||
CutsceneCameraPose CutsceneDatabase::parseCutsceneCameraPose(const json& j) {
|
||||
CutsceneCameraPose pose;
|
||||
pose.anchor = parseCutsceneAnchor(j.value("anchor", "Center"));
|
||||
pose.centerX = j.value("centerX", 0.5f);
|
||||
pose.centerY = j.value("centerY", 0.5f);
|
||||
pose.zoom = j.value("zoom", 1.0f);
|
||||
pose.rotationDeg = j.value("rotationDeg", 0.0f);
|
||||
return pose;
|
||||
}
|
||||
|
||||
CutsceneCameraSegment CutsceneDatabase::parseCutsceneCameraSegment(const json& j) {
|
||||
CutsceneCameraSegment segment;
|
||||
segment.durationMs = j.value("durationMs", 0);
|
||||
segment.easing = parseEasingType(j.value("easing", "EaseInOutSine"));
|
||||
if (j.contains("from") && j["from"].is_object()) {
|
||||
segment.from = parseCutsceneCameraPose(j["from"]);
|
||||
}
|
||||
if (j.contains("to") && j["to"].is_object()) {
|
||||
segment.to = parseCutsceneCameraPose(j["to"]);
|
||||
}
|
||||
else {
|
||||
segment.to = segment.from;
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
CutsceneImageCue CutsceneDatabase::parseCutsceneImageCue(const json& j) {
|
||||
CutsceneImageCue cue;
|
||||
cue.path = j.value("path", "");
|
||||
cue.startMs = j.value("startMs", 0);
|
||||
cue.endMs = j.value("endMs", 0);
|
||||
cue.fadeInMs = j.value("fadeInMs", 0);
|
||||
cue.fadeOutMs = j.value("fadeOutMs", 0);
|
||||
return cue;
|
||||
}
|
||||
|
||||
StaticCutsceneDefinition CutsceneDatabase::parseCutscene(const json& j) {
|
||||
StaticCutsceneDefinition cutscene;
|
||||
cutscene.id = j.value("id", "");
|
||||
cutscene.background = j.value("background", "");
|
||||
cutscene.backgroundWidth = j.value("backgroundWidth", 1280);
|
||||
cutscene.backgroundHeight= j.value("backgroundHeight", 720);
|
||||
cutscene.music = j.value("music", "");
|
||||
cutscene.skippable = j.value("skippable", true);
|
||||
cutscene.durationMs = j.value("durationMs", 0);
|
||||
cutscene.fadeOutMs = j.value("fadeOutMs", 0);
|
||||
cutscene.fadeInMs = j.value("fadeInMs", 0);
|
||||
cutscene.endFadeOutMs = j.value("endFadeOutMs", 0);
|
||||
cutscene.endFadeInMs = j.value("endFadeInMs", 0);
|
||||
cutscene.onFadeInCallback= j.value("onFadeInCallback", "");
|
||||
|
||||
if (j.contains("cameraTrack") && j["cameraTrack"].is_array()) {
|
||||
for (const auto& item : j["cameraTrack"]) {
|
||||
cutscene.cameraTrack.push_back(parseCutsceneCameraSegment(item));
|
||||
}
|
||||
}
|
||||
if (j.contains("images") && j["images"].is_array()) {
|
||||
for (const auto& item : j["images"]) {
|
||||
cutscene.images.push_back(parseCutsceneImageCue(item));
|
||||
}
|
||||
}
|
||||
if (j.contains("lines") && j["lines"].is_array()) {
|
||||
for (const auto& item : j["lines"]) {
|
||||
cutscene.lines.push_back(parseCutsceneLine(item));
|
||||
}
|
||||
}
|
||||
return cutscene;
|
||||
}
|
||||
|
||||
bool CutsceneDatabase::loadFromFile(const std::string& path) {
|
||||
cutscenes.clear();
|
||||
|
||||
std::string raw;
|
||||
try {
|
||||
if (strlen(ZL::CONST_ZIP_FILE) == 0) {
|
||||
raw = readTextFile(path);
|
||||
}
|
||||
else {
|
||||
auto buf = readFileFromZIP(path, ZL::CONST_ZIP_FILE);
|
||||
if (buf.empty()) {
|
||||
std::cerr << "[cutscene] Failed to read " << path << " from zip\n";
|
||||
throw std::runtime_error("Failed to load cutscene file: " + path);
|
||||
}
|
||||
raw.assign(buf.begin(), buf.end());
|
||||
}
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "[cutscene] Failed to open " << path << ": " << e.what() << "\n";
|
||||
throw std::runtime_error("Failed to load cutscene file: " + path);
|
||||
}
|
||||
|
||||
json root;
|
||||
try {
|
||||
root = json::parse(raw);
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "[cutscene] JSON parse error in " << path << ": " << e.what() << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (root.contains("cutscenes") && root["cutscenes"].is_array()) {
|
||||
for (const auto& item : root["cutscenes"]) {
|
||||
StaticCutsceneDefinition cutscene = parseCutscene(item);
|
||||
if (!cutscene.id.empty()) {
|
||||
cutscenes[cutscene.id] = std::move(cutscene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const StaticCutsceneDefinition* CutsceneDatabase::findCutscene(const std::string& id) const {
|
||||
auto it = cutscenes.find(id);
|
||||
return (it != cutscenes.end()) ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "cutscene/CutsceneTypes.h"
|
||||
#include "external/nlohmann/json.hpp"
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
class CutsceneDatabase {
|
||||
public:
|
||||
using json = nlohmann::json;
|
||||
|
||||
bool loadFromFile(const std::string& path);
|
||||
|
||||
const StaticCutsceneDefinition* findCutscene(const std::string& id) const;
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, StaticCutsceneDefinition> cutscenes;
|
||||
|
||||
static EasingType parseEasingType(const std::string& value);
|
||||
static CutsceneAnchor parseCutsceneAnchor(const std::string& value);
|
||||
|
||||
static CutsceneLine parseCutsceneLine(const json& j);
|
||||
static CutsceneCameraPose parseCutsceneCameraPose(const json& j);
|
||||
static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j);
|
||||
static CutsceneImageCue parseCutsceneImageCue(const json& j);
|
||||
static StaticCutsceneDefinition parseCutscene(const json& j);
|
||||
};
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,410 @@
|
||||
#include "cutscene/CutsceneOverlay.h"
|
||||
#include "dialogue/DialogueTypes.h"
|
||||
|
||||
#include "Environment.h"
|
||||
#include "GameConstants.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
bool CutsceneOverlay::init(Renderer& renderer, const std::string& zipFile) {
|
||||
rendererRef = &renderer;
|
||||
zipFilename = zipFile;
|
||||
|
||||
choiceMainTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_main.png", zipFile);
|
||||
choiceOptionalTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_optional.png", zipFile);
|
||||
cutsceneSubtitleTexture = renderer.textureManager.LoadFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile);
|
||||
|
||||
nameRenderer = std::make_unique<TextRenderer>();
|
||||
cutsceneRenderer= std::make_unique<TextRenderer>();
|
||||
choiceRenderer = std::make_unique<TextRenderer>();
|
||||
|
||||
return
|
||||
nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) &&
|
||||
cutsceneRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) &&
|
||||
choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile);
|
||||
}
|
||||
|
||||
void CutsceneOverlay::update(const ZL::Dialogue::PresentationModel& model, int deltaMs) {
|
||||
if (model.mode != ZL::Dialogue::PresentationMode::Cutscene || !model.cutsceneSkippable) {
|
||||
cutsceneSkipHintVisible = false;
|
||||
cutsceneSkipArmed = false;
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipTriggered = false;
|
||||
cutsceneSkipHintRemainingMs = 0;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const int safeDeltaMs = max(deltaMs, 0);
|
||||
|
||||
if (cutsceneSkipHintVisible) {
|
||||
cutsceneSkipHintRemainingMs -= safeDeltaMs;
|
||||
if (cutsceneSkipHintRemainingMs <= 0) {
|
||||
cutsceneSkipHintVisible = false;
|
||||
cutsceneSkipArmed = false;
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipHintRemainingMs = 0;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (cutsceneSkipHolding && cutsceneSkipArmed) {
|
||||
cutsceneSkipHoldElapsedMs += safeDeltaMs;
|
||||
if (cutsceneSkipHoldElapsedMs >= CutsceneSkipHoldDurationMs) {
|
||||
cutsceneSkipTriggered = true;
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipHoldElapsedMs = CutsceneSkipHoldDurationMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CutsceneOverlay::draw(Renderer& renderer, const ZL::Dialogue::PresentationModel& model) {
|
||||
if (model.mode != ZL::Dialogue::PresentationMode::Cutscene) return;
|
||||
|
||||
const float W = Environment::projectionWidth;
|
||||
const float H = Environment::projectionHeight;
|
||||
const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f };
|
||||
|
||||
lastCutsceneAdvanceRect = subtitleRect;
|
||||
|
||||
glEnable(GL_BLEND);
|
||||
|
||||
renderer.shaderManager.PushShader("cutsceneFade");
|
||||
renderer.RenderUniform1i(textureUniformName, 0);
|
||||
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
|
||||
renderer.PushMatrix();
|
||||
renderer.LoadIdentity();
|
||||
|
||||
const UiRect screenRect{ 0.0f, 0.0f, W, H };
|
||||
|
||||
std::vector<ZL::Cutscene::PresentedCutsceneImage> imageLayers = model.cutsceneImages;
|
||||
if (imageLayers.empty() && !model.backgroundPath.empty()) {
|
||||
imageLayers.push_back({ model.backgroundPath, 1.0f });
|
||||
}
|
||||
|
||||
for (const ZL::Cutscene::PresentedCutsceneImage& layer : imageLayers) {
|
||||
const auto texture = loadTextureCached(layer.path);
|
||||
if (!texture) continue;
|
||||
|
||||
const float imgW = (model.backgroundWidth > 0) ? static_cast<float>(model.backgroundWidth) : static_cast<float>(texture->getWidth());
|
||||
const float imgH = (model.backgroundHeight > 0) ? static_cast<float>(model.backgroundHeight) : static_cast<float>(texture->getHeight());
|
||||
|
||||
ResolvedViewport layerViewport{};
|
||||
if (model.cutsceneCamera.active) {
|
||||
const ResolvedViewport fromVP = resolveViewportPose(model.cutsceneCamera.from, imgW, imgH, W, H);
|
||||
const ResolvedViewport toVP = resolveViewportPose(model.cutsceneCamera.to, imgW, imgH, W, H);
|
||||
layerViewport = blendViewport(fromVP, toVP, std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f));
|
||||
}
|
||||
else {
|
||||
layerViewport = resolveViewportPose(ZL::Cutscene::CutsceneCameraPose{}, imgW, imgH, W, H);
|
||||
}
|
||||
|
||||
const float halfW = layerViewport.widthPx * 0.5f;
|
||||
const float halfH = layerViewport.heightPx * 0.5f;
|
||||
const float rotRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f;
|
||||
const float c = std::cos(rotRad);
|
||||
const float s = std::sin(rotRad);
|
||||
|
||||
auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f {
|
||||
return {
|
||||
layerViewport.centerXPx + x * c - y * s,
|
||||
layerViewport.centerYPx + x * s + y * c
|
||||
};
|
||||
};
|
||||
|
||||
const Eigen::Vector2f srcBL = rotatePoint(-halfW, -halfH);
|
||||
const Eigen::Vector2f srcTL = rotatePoint(-halfW, +halfH);
|
||||
const Eigen::Vector2f srcTR = rotatePoint(+halfW, +halfH);
|
||||
const Eigen::Vector2f srcBR = rotatePoint(+halfW, -halfH);
|
||||
|
||||
auto toUV = [&](const Eigen::Vector2f& p) -> Eigen::Vector2f {
|
||||
return {
|
||||
std::clamp(p.x() / max(imgW, 1.0f), 0.0f, 1.0f),
|
||||
std::clamp(p.y() / max(imgH, 1.0f), 0.0f, 1.0f)
|
||||
};
|
||||
};
|
||||
|
||||
backgroundQuad.rebuildWithUV(screenRect, toUV(srcBL), toUV(srcTL), toUV(srcTR), toUV(srcBR));
|
||||
renderer.RenderUniform1f("uAlpha", std::clamp(layer.alpha * model.cutsceneGlobalFadeAlpha, 0.0f, 1.0f));
|
||||
glBindTexture(GL_TEXTURE_2D, texture->getTexID());
|
||||
renderer.DrawVertexRenderStruct(backgroundQuad.mesh);
|
||||
}
|
||||
|
||||
renderer.PopMatrix();
|
||||
renderer.PopProjectionMatrix();
|
||||
renderer.shaderManager.PopShader();
|
||||
|
||||
if (model.cutsceneBlackAlpha > 0.001f) {
|
||||
renderer.shaderManager.PushShader("cutsceneBlack");
|
||||
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
|
||||
renderer.PushMatrix();
|
||||
renderer.LoadIdentity();
|
||||
|
||||
backgroundQuad.rebuild(screenRect);
|
||||
renderer.RenderUniform1f("uAlpha", model.cutsceneBlackAlpha);
|
||||
renderer.DrawVertexRenderStruct(backgroundQuad.mesh);
|
||||
|
||||
renderer.PopMatrix();
|
||||
renderer.PopProjectionMatrix();
|
||||
renderer.shaderManager.PopShader();
|
||||
}
|
||||
|
||||
renderer.shaderManager.PushShader(defaultShaderName);
|
||||
renderer.RenderUniform1i(textureUniformName, 0);
|
||||
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
|
||||
renderer.PushMatrix();
|
||||
renderer.LoadIdentity();
|
||||
|
||||
if (model.showCutsceneSubtitle) {
|
||||
subtitleQuad.rebuild(subtitleRect);
|
||||
glBindTexture(GL_TEXTURE_2D, cutsceneSubtitleTexture->getTexID());
|
||||
renderer.DrawVertexRenderStruct(subtitleQuad.mesh);
|
||||
}
|
||||
|
||||
if (model.cutsceneSkippable && cutsceneSkipHintVisible) {
|
||||
const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f };
|
||||
skipHintBgQuad.rebuild(hintBg);
|
||||
glBindTexture(GL_TEXTURE_2D, choiceOptionalTexture->getTexID());
|
||||
renderer.DrawVertexRenderStruct(skipHintBgQuad.mesh);
|
||||
|
||||
const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f };
|
||||
skipProgressBgQuad.rebuild(progressBg);
|
||||
renderer.DrawVertexRenderStruct(skipProgressBgQuad.mesh);
|
||||
|
||||
if (cutsceneSkipHolding) {
|
||||
const float progress = std::clamp(
|
||||
static_cast<float>(cutsceneSkipHoldElapsedMs) / static_cast<float>(CutsceneSkipHoldDurationMs),
|
||||
0.0f, 1.0f
|
||||
);
|
||||
const UiRect progressFill{ progressBg.x, progressBg.y, progressBg.w * progress, progressBg.h };
|
||||
skipProgressFillQuad.rebuild(progressFill);
|
||||
glBindTexture(GL_TEXTURE_2D, choiceMainTexture->getTexID());
|
||||
renderer.DrawVertexRenderStruct(skipProgressFillQuad.mesh);
|
||||
}
|
||||
}
|
||||
|
||||
renderer.PopMatrix();
|
||||
renderer.PopProjectionMatrix();
|
||||
renderer.shaderManager.PopShader();
|
||||
|
||||
if (model.showCutsceneSubtitle) {
|
||||
if (!model.speaker.empty()) {
|
||||
nameRenderer->drawText(
|
||||
model.speaker,
|
||||
subtitleRect.x + 24.0f,
|
||||
subtitleRect.y + subtitleRect.h - 32.0f,
|
||||
1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }
|
||||
);
|
||||
}
|
||||
const std::string wrapped = wrapTextToWidth(model.visibleText, *cutsceneRenderer, subtitleRect.w - 48.0f, 1.0f);
|
||||
cutsceneRenderer->drawText(wrapped, subtitleRect.x + 24.0f, subtitleRect.y + 30.0f,
|
||||
1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f });
|
||||
}
|
||||
|
||||
if (model.cutsceneSkippable && cutsceneSkipHintVisible) {
|
||||
choiceRenderer->drawText(
|
||||
cutsceneSkipHolding ? "Hold to skip..." : "Hold LMB to skip",
|
||||
W - 232.0f, H - 50.0f,
|
||||
0.85f, false, { 1.0f, 1.0f, 1.0f, 0.95f }
|
||||
);
|
||||
}
|
||||
|
||||
glDisable(GL_BLEND);
|
||||
}
|
||||
|
||||
bool CutsceneOverlay::consumeSkipRequested() {
|
||||
const bool result = cutsceneSkipTriggered;
|
||||
cutsceneSkipTriggered = false;
|
||||
if (result) {
|
||||
cutsceneSkipHintVisible = false;
|
||||
cutsceneSkipArmed = false;
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipHintRemainingMs = 0;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void CutsceneOverlay::handlePointerDown(float x, float y, const ZL::Dialogue::PresentationModel& model) {
|
||||
(void)x; (void)y;
|
||||
if (model.mode != ZL::Dialogue::PresentationMode::Cutscene || !model.cutsceneSkippable) return;
|
||||
|
||||
if (!cutsceneSkipArmed) {
|
||||
cutsceneSkipHintVisible = true;
|
||||
cutsceneSkipArmed = true;
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipTriggered = false;
|
||||
cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
cutsceneSkipHintVisible = true;
|
||||
cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs;
|
||||
cutsceneSkipHolding = true;
|
||||
cutsceneSkipTriggered = false;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
}
|
||||
|
||||
void CutsceneOverlay::handlePointerMoved(float /*x*/, float /*y*/, const ZL::Dialogue::PresentationModel& /*model*/) {
|
||||
}
|
||||
|
||||
bool CutsceneOverlay::handlePointerReleased(float /*x*/, float /*y*/, const ZL::Dialogue::PresentationModel& model) {
|
||||
if (model.mode != ZL::Dialogue::PresentationMode::Cutscene) return false;
|
||||
if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) {
|
||||
cutsceneSkipHolding = false;
|
||||
cutsceneSkipHoldElapsedMs = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<Texture> CutsceneOverlay::loadTextureCached(const std::string& path) {
|
||||
if (path.empty()) return nullptr;
|
||||
return rendererRef->textureManager.LoadFromPng(path, zipFilename);
|
||||
}
|
||||
|
||||
float CutsceneOverlay::lerpFloat(float a, float b, float t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
CutsceneOverlay::ResolvedViewport CutsceneOverlay::resolveViewportPose(
|
||||
const CutsceneCameraPose& pose,
|
||||
float texW, float texH,
|
||||
float screenW, float screenH
|
||||
) {
|
||||
ResolvedViewport out{};
|
||||
|
||||
const float safeTexW = max(texW, 1.0f);
|
||||
const float safeTexH = max(texH, 1.0f);
|
||||
const float safeScreenW = max(screenW, 1.0f);
|
||||
const float safeScreenH = max(screenH, 1.0f);
|
||||
|
||||
const float screenAspect = safeScreenW / safeScreenH;
|
||||
const float imageAspect = safeTexW / safeTexH;
|
||||
|
||||
float baseViewportW = 0.0f;
|
||||
float baseViewportH = 0.0f;
|
||||
|
||||
if (imageAspect >= screenAspect) {
|
||||
baseViewportH = safeTexH;
|
||||
baseViewportW = safeTexH * screenAspect;
|
||||
}
|
||||
else {
|
||||
baseViewportW = safeTexW;
|
||||
baseViewportH = safeTexW / screenAspect;
|
||||
}
|
||||
|
||||
const float zoom = max(pose.zoom, 0.01f);
|
||||
const float viewportW = baseViewportW / zoom;
|
||||
const float viewportH = baseViewportH / zoom;
|
||||
|
||||
const float rotRad = pose.rotationDeg * 3.14159265358979323846f / 180.0f;
|
||||
const float c = std::cos(rotRad);
|
||||
const float s = std::sin(rotRad);
|
||||
|
||||
const float halfRotatedW = std::abs((viewportW * 0.5f) * c) + std::abs((viewportH * 0.5f) * s);
|
||||
const float halfRotatedH = std::abs((viewportW * 0.5f) * s) + std::abs((viewportH * 0.5f) * c);
|
||||
|
||||
float centerX = safeTexW * 0.5f;
|
||||
float centerY = safeTexH * 0.5f;
|
||||
|
||||
switch (pose.anchor) {
|
||||
case CutsceneAnchor::TopLeft:
|
||||
centerX = halfRotatedW;
|
||||
centerY = safeTexH - halfRotatedH;
|
||||
break;
|
||||
case CutsceneAnchor::TopRight:
|
||||
centerX = safeTexW - halfRotatedW;
|
||||
centerY = safeTexH - halfRotatedH;
|
||||
break;
|
||||
case CutsceneAnchor::BottomRight:
|
||||
centerX = safeTexW - halfRotatedW;
|
||||
centerY = halfRotatedH;
|
||||
break;
|
||||
case CutsceneAnchor::BottomLeft:
|
||||
centerX = halfRotatedW;
|
||||
centerY = halfRotatedH;
|
||||
break;
|
||||
case CutsceneAnchor::Custom:
|
||||
centerX = std::clamp(pose.centerX, 0.0f, 1.0f) * safeTexW;
|
||||
centerY = (1.0f - std::clamp(pose.centerY, 0.0f, 1.0f)) * safeTexH;
|
||||
centerX = std::clamp(centerX, halfRotatedW, safeTexW - halfRotatedW);
|
||||
centerY = std::clamp(centerY, halfRotatedH, safeTexH - halfRotatedH);
|
||||
break;
|
||||
case CutsceneAnchor::Center:
|
||||
default:
|
||||
centerX = safeTexW * 0.5f;
|
||||
centerY = safeTexH * 0.5f;
|
||||
break;
|
||||
}
|
||||
|
||||
out.centerXPx = centerX;
|
||||
out.centerYPx = centerY;
|
||||
out.widthPx = viewportW;
|
||||
out.heightPx = viewportH;
|
||||
out.rotationDeg = pose.rotationDeg;
|
||||
return out;
|
||||
}
|
||||
|
||||
CutsceneOverlay::ResolvedViewport CutsceneOverlay::blendViewport(
|
||||
const ResolvedViewport& from,
|
||||
const ResolvedViewport& to,
|
||||
float t
|
||||
) {
|
||||
ResolvedViewport out;
|
||||
out.centerXPx = lerpFloat(from.centerXPx, to.centerXPx, t);
|
||||
out.centerYPx = lerpFloat(from.centerYPx, to.centerYPx, t);
|
||||
out.widthPx = lerpFloat(from.widthPx, to.widthPx, t);
|
||||
out.heightPx = lerpFloat(from.heightPx, to.heightPx, t);
|
||||
out.rotationDeg = lerpFloat(from.rotationDeg, to.rotationDeg, t);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string CutsceneOverlay::wrapTextToWidth(
|
||||
const std::string& input,
|
||||
const TextRenderer& textRenderer,
|
||||
float maxWidthPx,
|
||||
float scale)
|
||||
{
|
||||
if (input.empty() || maxWidthPx <= 1.0f) return input;
|
||||
|
||||
std::string output;
|
||||
std::string currentLine;
|
||||
std::string currentWord;
|
||||
|
||||
auto flushLine = [&]() {
|
||||
if (!currentLine.empty()) {
|
||||
if (!output.empty()) output.push_back('\n');
|
||||
output += currentLine;
|
||||
currentLine.clear();
|
||||
}
|
||||
};
|
||||
|
||||
auto pushWord = [&](const std::string& word) {
|
||||
if (word.empty()) return;
|
||||
if (currentLine.empty()) { currentLine = word; return; }
|
||||
const std::string candidate = currentLine + " " + word;
|
||||
if (textRenderer.measureTextWidth(candidate, scale) <= maxWidthPx) {
|
||||
currentLine = candidate;
|
||||
}
|
||||
else {
|
||||
flushLine();
|
||||
currentLine = word;
|
||||
}
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < input.size(); ++i) {
|
||||
const char ch = input[i];
|
||||
if (ch == '\n') { pushWord(currentWord); currentWord.clear(); flushLine(); continue; }
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r') { pushWord(currentWord); currentWord.clear(); continue; }
|
||||
currentWord.push_back(ch);
|
||||
}
|
||||
pushWord(currentWord);
|
||||
flushLine();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include "cutscene/CutsceneTypes.h"
|
||||
#include "dialogue/DialogueTypes.h"
|
||||
#include "render/Renderer.h"
|
||||
#include "render/TextRenderer.h"
|
||||
#include "render/TextureManager.h"
|
||||
#include "render/UiQuad.h"
|
||||
#include "UiManager.h"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
class CutsceneOverlay {
|
||||
public:
|
||||
bool init(Renderer& renderer, const std::string& zipFile = "");
|
||||
void update(const ZL::Dialogue::PresentationModel& model, int deltaMs);
|
||||
void draw(Renderer& renderer, const ZL::Dialogue::PresentationModel& model);
|
||||
|
||||
void handlePointerDown(float x, float y, const ZL::Dialogue::PresentationModel& model);
|
||||
void handlePointerMoved(float x, float y, const ZL::Dialogue::PresentationModel& model);
|
||||
bool handlePointerReleased(float x, float y, const ZL::Dialogue::PresentationModel& model);
|
||||
bool consumeSkipRequested();
|
||||
|
||||
private:
|
||||
struct ResolvedViewport {
|
||||
float centerXPx = 0.0f;
|
||||
float centerYPx = 0.0f;
|
||||
float widthPx = 1.0f;
|
||||
float heightPx = 1.0f;
|
||||
float rotationDeg = 0.0f;
|
||||
};
|
||||
|
||||
Renderer* rendererRef = nullptr;
|
||||
std::string zipFilename;
|
||||
|
||||
std::shared_ptr<Texture> choiceMainTexture;
|
||||
std::shared_ptr<Texture> choiceOptionalTexture;
|
||||
std::shared_ptr<Texture> cutsceneSubtitleTexture;
|
||||
|
||||
mutable UiRect lastCutsceneAdvanceRect{};
|
||||
mutable UiRect lastCutsceneSkipRect{};
|
||||
|
||||
// Skip UX state
|
||||
bool cutsceneSkipHintVisible = false;
|
||||
bool cutsceneSkipArmed = false;
|
||||
bool cutsceneSkipHolding = false;
|
||||
bool cutsceneSkipTriggered = false;
|
||||
int cutsceneSkipHintRemainingMs = 0;
|
||||
int cutsceneSkipHoldElapsedMs = 0;
|
||||
static constexpr int CutsceneSkipHintDurationMs = 5000;
|
||||
static constexpr int CutsceneSkipHoldDurationMs = 3500;
|
||||
|
||||
std::unique_ptr<TextRenderer> nameRenderer;
|
||||
std::unique_ptr<TextRenderer> cutsceneRenderer;
|
||||
std::unique_ptr<TextRenderer> choiceRenderer;
|
||||
|
||||
UiQuad backgroundQuad;
|
||||
UiQuad subtitleQuad;
|
||||
UiQuad skipHintBgQuad;
|
||||
UiQuad skipProgressBgQuad;
|
||||
UiQuad skipProgressFillQuad;
|
||||
|
||||
std::shared_ptr<Texture> loadTextureCached(const std::string& path);
|
||||
|
||||
static float lerpFloat(float a, float b, float t);
|
||||
static ResolvedViewport resolveViewportPose(
|
||||
const CutsceneCameraPose& pose,
|
||||
float texW, float texH,
|
||||
float screenW, float screenH
|
||||
);
|
||||
static ResolvedViewport blendViewport(
|
||||
const ResolvedViewport& from,
|
||||
const ResolvedViewport& to,
|
||||
float t
|
||||
);
|
||||
static std::string wrapTextToWidth(const std::string& input, const TextRenderer& textRenderer,
|
||||
float maxWidthPx, float scale);
|
||||
};
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,509 @@
|
||||
#include "cutscene/CutsceneRuntime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
static std::pair<std::string, std::string> splitDot(const std::string& s) {
|
||||
const auto dot = s.find('.');
|
||||
if (dot == std::string::npos) return {s, ""};
|
||||
return {s.substr(0, dot), s.substr(dot + 1)};
|
||||
}
|
||||
|
||||
void CutsceneRuntime::setDatabase(const CutsceneDatabase* value) {
|
||||
database = value;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::setQuestJournal(Quest::QuestJournal* journal) {
|
||||
questJournal = journal;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::setOnFinished(std::function<void(const std::string&)> cb) {
|
||||
onFinished = std::move(cb);
|
||||
}
|
||||
|
||||
void CutsceneRuntime::setOnLineStarted(std::function<void(const std::string&)> cb) {
|
||||
onLineStarted = std::move(cb);
|
||||
}
|
||||
|
||||
void CutsceneRuntime::setOnFadeInComplete(std::function<void(const std::string&)> cb) {
|
||||
onFadeInComplete = std::move(cb);
|
||||
}
|
||||
|
||||
bool CutsceneRuntime::start(const std::string& cutsceneId) {
|
||||
if (!database) {
|
||||
std::cerr << "[cutscene] No database assigned to runtime\n";
|
||||
return false;
|
||||
}
|
||||
const StaticCutsceneDefinition* def = database->findCutscene(cutsceneId);
|
||||
if (!def) {
|
||||
std::cerr << "[cutscene] Cutscene not found: " << cutsceneId << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
activeCutscene = def;
|
||||
activeCutsceneId = cutsceneId;
|
||||
active = true;
|
||||
fadeInCallbackFired = false;
|
||||
currentCutsceneBackground = def->background;
|
||||
cutsceneElapsedMs = 0;
|
||||
cutsceneTimerMs = 0;
|
||||
currentCutsceneLine = def->lines.empty() ? -1 : 0;
|
||||
|
||||
int imageTrackDurationMs = 0;
|
||||
for (size_t i = 0; i < def->images.size(); ++i) {
|
||||
const CutsceneImageCue& cue = def->images[i];
|
||||
int cueEnd = cue.endMs;
|
||||
if (cueEnd <= cue.startMs) {
|
||||
if (i + 1 < def->images.size()) {
|
||||
cueEnd = std::max(def->images[i + 1].startMs, cue.startMs);
|
||||
}
|
||||
else {
|
||||
cueEnd = cue.startMs + std::max(cue.fadeInMs, 0) + std::max(cue.fadeOutMs, 0) + 1000;
|
||||
}
|
||||
}
|
||||
imageTrackDurationMs = std::max(imageTrackDurationMs, cueEnd);
|
||||
}
|
||||
cutsceneContentDurationMs = std::max({ def->durationMs, computeCameraTrackDurationMs(*def), imageTrackDurationMs });
|
||||
if (cutsceneContentDurationMs <= 0 && def->lines.empty()) {
|
||||
cutsceneContentDurationMs = 3000;
|
||||
}
|
||||
cutsceneTotalDurationMs = cutsceneContentDurationMs + def->endFadeOutMs + def->endFadeInMs;
|
||||
|
||||
presentation = {};
|
||||
refreshPresentation();
|
||||
|
||||
if (!def->lines.empty()) {
|
||||
const CutsceneLine& firstLine = def->lines[0];
|
||||
applyQuestActions(firstLine.questUnlock, firstLine.questComplete,
|
||||
firstLine.questFail, firstLine.objectiveComplete, firstLine.objectiveVisible);
|
||||
if (onLineStarted && !firstLine.luaCallback.empty())
|
||||
onLineStarted(firstLine.luaCallback);
|
||||
}
|
||||
|
||||
std::cout << "[CUTSCENE] start id=" << cutsceneId
|
||||
<< " lines=" << def->lines.size()
|
||||
<< " totalDuration=" << cutsceneTotalDurationMs
|
||||
<< std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::stop() {
|
||||
activeCutscene = nullptr;
|
||||
activeCutsceneId.clear();
|
||||
active = false;
|
||||
fadeInCallbackFired = false;
|
||||
currentCutsceneLine = -1;
|
||||
cutsceneTimerMs = 0;
|
||||
cutsceneElapsedMs = 0;
|
||||
cutsceneTotalDurationMs = 0;
|
||||
cutsceneContentDurationMs = 0;
|
||||
currentCutsceneBackground.clear();
|
||||
presentation = {};
|
||||
}
|
||||
|
||||
void CutsceneRuntime::update(int deltaMs) {
|
||||
if (!active || !activeCutscene) return;
|
||||
|
||||
cutsceneElapsedMs += deltaMs;
|
||||
|
||||
if (!activeCutscene->lines.empty() &&
|
||||
currentCutsceneLine >= 0 &&
|
||||
currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
|
||||
|
||||
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
|
||||
if (!line.waitForConfirm) {
|
||||
cutsceneTimerMs += deltaMs;
|
||||
const int durationMs = (line.durationMs > 0)
|
||||
? line.durationMs
|
||||
: computeFallbackDurationMs(line.text);
|
||||
|
||||
if (cutsceneTimerMs >= durationMs) {
|
||||
advanceLine();
|
||||
if (!active || !activeCutscene) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!active || !activeCutscene) return;
|
||||
|
||||
refreshPresentation();
|
||||
|
||||
if (!active || !activeCutscene) return;
|
||||
|
||||
if (!fadeInCallbackFired && onFadeInComplete && activeCutscene) {
|
||||
const int fadeInCompleteMs = activeCutscene->fadeOutMs + activeCutscene->fadeInMs;
|
||||
if (cutsceneElapsedMs >= fadeInCompleteMs && !activeCutscene->onFadeInCallback.empty()) {
|
||||
fadeInCallbackFired = true;
|
||||
onFadeInComplete(activeCutscene->onFadeInCallback);
|
||||
}
|
||||
}
|
||||
|
||||
if (!active || !activeCutscene) return;
|
||||
|
||||
const bool subtitlesFinished =
|
||||
activeCutscene->lines.empty() ||
|
||||
currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size());
|
||||
|
||||
const bool durationFinished =
|
||||
cutsceneTotalDurationMs > 0 &&
|
||||
cutsceneElapsedMs >= cutsceneTotalDurationMs;
|
||||
|
||||
if (activeCutscene->lines.empty()) {
|
||||
if (durationFinished) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtitlesFinished && (cutsceneTotalDurationMs <= 0 || durationFinished)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
bool CutsceneRuntime::canSkip() const {
|
||||
return active && activeCutscene && activeCutscene->skippable;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::skip() {
|
||||
if (!canSkip()) return;
|
||||
|
||||
if (!activeCutscene->images.empty()) {
|
||||
int nextImageStartMs = -1;
|
||||
for (const CutsceneImageCue& cue : activeCutscene->images) {
|
||||
if (cue.path.empty()) continue;
|
||||
if (cue.startMs > cutsceneElapsedMs) {
|
||||
if (nextImageStartMs < 0 || cue.startMs < nextImageStartMs) {
|
||||
nextImageStartMs = cue.startMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextImageStartMs >= 0) {
|
||||
cutsceneElapsedMs = nextImageStartMs;
|
||||
syncLineToElapsedTime();
|
||||
refreshPresentation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
void CutsceneRuntime::applyQuestActions(
|
||||
const std::string& questUnlock, const std::string& questComplete,
|
||||
const std::string& questFail, const std::string& objectiveComplete,
|
||||
const std::string& objectiveVisible)
|
||||
{
|
||||
if (!questJournal) return;
|
||||
if (!questUnlock.empty()) questJournal->unlockQuest(questUnlock);
|
||||
if (!questComplete.empty()) questJournal->completeQuest(questComplete);
|
||||
if (!questFail.empty()) questJournal->failQuest(questFail);
|
||||
if (!objectiveComplete.empty()) {
|
||||
auto [qId, oId] = splitDot(objectiveComplete);
|
||||
questJournal->setObjectiveCompleted(qId, oId);
|
||||
}
|
||||
if (!objectiveVisible.empty()) {
|
||||
auto [qId, oId] = splitDot(objectiveVisible);
|
||||
questJournal->setObjectiveVisible(qId, oId);
|
||||
}
|
||||
}
|
||||
|
||||
void CutsceneRuntime::finish() {
|
||||
std::cout << "[CUTSCENE] finish id=" << activeCutsceneId << std::endl;
|
||||
const std::string finishedId = activeCutsceneId;
|
||||
stop();
|
||||
if (onFinished && !finishedId.empty()) {
|
||||
onFinished(finishedId);
|
||||
}
|
||||
}
|
||||
|
||||
void CutsceneRuntime::syncLineToElapsedTime() {
|
||||
if (!activeCutscene || activeCutscene->lines.empty()) {
|
||||
currentCutsceneLine = -1;
|
||||
cutsceneTimerMs = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
int elapsed = std::max(cutsceneElapsedMs, 0);
|
||||
int accumulatedMs = 0;
|
||||
|
||||
for (size_t i = 0; i < activeCutscene->lines.size(); ++i) {
|
||||
const CutsceneLine& line = activeCutscene->lines[i];
|
||||
const int durationMs = (line.durationMs > 0)
|
||||
? line.durationMs
|
||||
: computeFallbackDurationMs(line.text);
|
||||
|
||||
if (elapsed < accumulatedMs + durationMs) {
|
||||
currentCutsceneLine = static_cast<int>(i);
|
||||
cutsceneTimerMs = std::max(0, elapsed - accumulatedMs);
|
||||
return;
|
||||
}
|
||||
accumulatedMs += durationMs;
|
||||
}
|
||||
|
||||
currentCutsceneLine = -1;
|
||||
cutsceneTimerMs = 0;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::advanceLine() {
|
||||
if (!activeCutscene) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (activeCutscene->lines.empty()) return;
|
||||
|
||||
std::cout << "[CUTSCENE] advance before current=" << currentCutsceneLine << std::endl;
|
||||
++currentCutsceneLine;
|
||||
std::cout << "[CUTSCENE] advance after current=" << currentCutsceneLine << std::endl;
|
||||
cutsceneTimerMs = 0;
|
||||
|
||||
if (currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size())) {
|
||||
refreshPresentation();
|
||||
if (cutsceneContentDurationMs <= 0 || cutsceneElapsedMs >= cutsceneContentDurationMs) {
|
||||
if (activeCutscene->endFadeOutMs <= 0 && activeCutscene->endFadeInMs <= 0) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const CutsceneLine& newLine = activeCutscene->lines[currentCutsceneLine];
|
||||
applyQuestActions(newLine.questUnlock, newLine.questComplete,
|
||||
newLine.questFail, newLine.objectiveComplete, newLine.objectiveVisible);
|
||||
if (onLineStarted && !newLine.luaCallback.empty())
|
||||
onLineStarted(newLine.luaCallback);
|
||||
|
||||
refreshPresentation();
|
||||
}
|
||||
|
||||
CutsceneCameraBlendState CutsceneRuntime::evaluateCameraBlend() const {
|
||||
CutsceneCameraBlendState result;
|
||||
result.active = false;
|
||||
result.from = {};
|
||||
result.to = {};
|
||||
result.t = 1.0f;
|
||||
|
||||
if (!activeCutscene || activeCutscene->cameraTrack.empty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int elapsed = cutsceneElapsedMs;
|
||||
for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) {
|
||||
const int durationMs = std::max(segment.durationMs, 1);
|
||||
if (elapsed <= durationMs) {
|
||||
result.active = true;
|
||||
result.from = segment.from;
|
||||
result.to = segment.to;
|
||||
result.t = applyEasing(
|
||||
segment.easing,
|
||||
std::clamp(static_cast<float>(elapsed) / static_cast<float>(durationMs), 0.0f, 1.0f)
|
||||
);
|
||||
return result;
|
||||
}
|
||||
elapsed -= durationMs;
|
||||
}
|
||||
|
||||
result.active = true;
|
||||
result.from = activeCutscene->cameraTrack.back().to;
|
||||
result.to = activeCutscene->cameraTrack.back().to;
|
||||
result.t = 1.0f;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<PresentedCutsceneImage> CutsceneRuntime::evaluateImages() const {
|
||||
std::vector<PresentedCutsceneImage> result;
|
||||
if (!activeCutscene) return result;
|
||||
|
||||
const std::string& fallbackPath = !currentCutsceneBackground.empty()
|
||||
? currentCutsceneBackground
|
||||
: activeCutscene->background;
|
||||
|
||||
if (activeCutscene->images.empty()) {
|
||||
if (!fallbackPath.empty()) {
|
||||
result.push_back({ fallbackPath, 1.0f });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const int effectiveTotalDuration = (cutsceneContentDurationMs > 0)
|
||||
? cutsceneContentDurationMs
|
||||
: std::max(activeCutscene->durationMs, 1);
|
||||
const int now = std::max(cutsceneElapsedMs, 0);
|
||||
|
||||
for (size_t i = 0; i < activeCutscene->images.size(); ++i) {
|
||||
const CutsceneImageCue& cue = activeCutscene->images[i];
|
||||
if (cue.path.empty()) continue;
|
||||
|
||||
const int startMs = std::max(cue.startMs, 0);
|
||||
int endMs = cue.endMs;
|
||||
if (endMs <= startMs) {
|
||||
if (i + 1 < activeCutscene->images.size()) {
|
||||
endMs = std::max(activeCutscene->images[i + 1].startMs, startMs + 1);
|
||||
}
|
||||
else {
|
||||
endMs = effectiveTotalDuration;
|
||||
}
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
endMs = startMs + 1;
|
||||
}
|
||||
|
||||
if (now < startMs || now > endMs) continue;
|
||||
|
||||
float alpha = 1.0f;
|
||||
if (cue.fadeInMs > 0 && now < startMs + cue.fadeInMs) {
|
||||
alpha = std::clamp(
|
||||
static_cast<float>(now - startMs) / static_cast<float>(cue.fadeInMs),
|
||||
0.0f, 1.0f
|
||||
);
|
||||
}
|
||||
|
||||
if (alpha > 0.0f) {
|
||||
result.push_back({ cue.path, alpha });
|
||||
}
|
||||
}
|
||||
|
||||
if (result.empty() && !fallbackPath.empty()) {
|
||||
result.push_back({ fallbackPath, 1.0f });
|
||||
}
|
||||
|
||||
if (!result.empty() && result.front().alpha < 0.999f &&
|
||||
!fallbackPath.empty() && result.front().path != fallbackPath)
|
||||
{
|
||||
result.insert(result.begin(), { fallbackPath, 1.0f });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void CutsceneRuntime::refreshPresentation() {
|
||||
if (!activeCutscene) return;
|
||||
|
||||
presentation.mode = ZL::Dialogue::PresentationMode::Cutscene;
|
||||
presentation.backgroundPath = activeCutscene->background;
|
||||
presentation.backgroundWidth = activeCutscene->backgroundWidth;
|
||||
presentation.backgroundHeight = activeCutscene->backgroundHeight;
|
||||
presentation.cutsceneCamera = evaluateCameraBlend();
|
||||
presentation.cutsceneImages = evaluateImages();
|
||||
presentation.cutsceneSkippable = activeCutscene->skippable;
|
||||
|
||||
const int fadeOutMs = activeCutscene->fadeOutMs;
|
||||
const int fadeInMs = activeCutscene->fadeInMs;
|
||||
const int endFadeOutMs = activeCutscene->endFadeOutMs;
|
||||
const int endFadeInMs = activeCutscene->endFadeInMs;
|
||||
const int endFadeOutStart = cutsceneContentDurationMs;
|
||||
const int endFadeInStart = cutsceneContentDurationMs + endFadeOutMs;
|
||||
|
||||
if (cutsceneElapsedMs < fadeOutMs) {
|
||||
presentation.cutsceneGlobalFadeAlpha = 0.0f;
|
||||
presentation.cutsceneBlackAlpha = std::clamp(
|
||||
static_cast<float>(cutsceneElapsedMs) / static_cast<float>(fadeOutMs),
|
||||
0.0f, 1.0f
|
||||
);
|
||||
}
|
||||
else if (cutsceneElapsedMs < endFadeOutStart) {
|
||||
presentation.cutsceneGlobalFadeAlpha = 1.0f;
|
||||
const int phase2elapsed = cutsceneElapsedMs - fadeOutMs;
|
||||
presentation.cutsceneBlackAlpha = (fadeInMs > 0)
|
||||
? std::clamp(1.0f - static_cast<float>(phase2elapsed) / static_cast<float>(fadeInMs), 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
}
|
||||
else if (cutsceneElapsedMs < endFadeInStart) {
|
||||
presentation.cutsceneGlobalFadeAlpha = 1.0f;
|
||||
const int elapsed = cutsceneElapsedMs - endFadeOutStart;
|
||||
presentation.cutsceneBlackAlpha = (endFadeOutMs > 0)
|
||||
? std::clamp(static_cast<float>(elapsed) / static_cast<float>(endFadeOutMs), 0.0f, 1.0f)
|
||||
: 1.0f;
|
||||
}
|
||||
else {
|
||||
presentation.cutsceneGlobalFadeAlpha = 0.0f;
|
||||
const int elapsed = cutsceneElapsedMs - endFadeInStart;
|
||||
presentation.cutsceneBlackAlpha = (endFadeInMs > 0)
|
||||
? std::clamp(1.0f - static_cast<float>(elapsed) / static_cast<float>(endFadeInMs), 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
}
|
||||
|
||||
presentation.choices.clear();
|
||||
presentation.selectedChoice = -1;
|
||||
presentation.revealCompleted = true;
|
||||
|
||||
const bool hasSubtitle = currentCutsceneLine >= 0 &&
|
||||
currentCutsceneLine < static_cast<int>(activeCutscene->lines.size());
|
||||
presentation.showCutsceneSubtitle = hasSubtitle;
|
||||
|
||||
if (!hasSubtitle) {
|
||||
presentation.speaker.clear();
|
||||
presentation.fullText.clear();
|
||||
presentation.visibleText.clear();
|
||||
presentation.portraitPath.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
|
||||
|
||||
if (!line.background.empty()) {
|
||||
currentCutsceneBackground = line.background;
|
||||
if (line.backgroundWidth > 0) presentation.backgroundWidth = line.backgroundWidth;
|
||||
if (line.backgroundHeight > 0) presentation.backgroundHeight = line.backgroundHeight;
|
||||
}
|
||||
|
||||
presentation.backgroundPath = currentCutsceneBackground;
|
||||
presentation.speaker = line.speaker;
|
||||
presentation.fullText = line.text;
|
||||
presentation.visibleText = line.text;
|
||||
presentation.portraitPath = line.portrait;
|
||||
presentation.selectedChoice = 0;
|
||||
|
||||
std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size()
|
||||
<< " current=" << currentCutsceneLine
|
||||
<< std::endl;
|
||||
}
|
||||
|
||||
float CutsceneRuntime::applyEasing(EasingType easing, float t) {
|
||||
t = std::clamp(t, 0.0f, 1.0f);
|
||||
constexpr float PI = 3.14159265358979323846f;
|
||||
|
||||
switch (easing) {
|
||||
case EasingType::EaseInSine:
|
||||
return 1.0f - std::cos((t * PI) * 0.5f);
|
||||
case EasingType::EaseOutSine:
|
||||
return std::sin((t * PI) * 0.5f);
|
||||
case EasingType::EaseInOutSine:
|
||||
return -(std::cos(PI * t) - 1.0f) * 0.5f;
|
||||
case EasingType::EaseInQuad:
|
||||
return t * t;
|
||||
case EasingType::EaseOutQuad:
|
||||
return 1.0f - (1.0f - t) * (1.0f - t);
|
||||
case EasingType::EaseInOutQuad:
|
||||
return (t < 0.5f) ? 2.0f * t * t : 1.0f - std::pow(-2.0f * t + 2.0f, 2.0f) * 0.5f;
|
||||
case EasingType::EaseInCubic:
|
||||
return t * t * t;
|
||||
case EasingType::EaseOutCubic:
|
||||
return 1.0f - std::pow(1.0f - t, 3.0f);
|
||||
case EasingType::EaseInOutCubic:
|
||||
return (t < 0.5f) ? 4.0f * t * t * t : 1.0f - std::pow(-2.0f * t + 2.0f, 3.0f) * 0.5f;
|
||||
case EasingType::Linear:
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
int CutsceneRuntime::computeFallbackDurationMs(const std::string& text) {
|
||||
const int cps = 17;
|
||||
const int minDuration = 1500;
|
||||
const int linger = 450;
|
||||
const int calculated = static_cast<int>((1000.0 * static_cast<double>(std::max<size_t>(text.size(), 1))) / cps);
|
||||
return std::max(minDuration, calculated + linger);
|
||||
}
|
||||
|
||||
int CutsceneRuntime::computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene) {
|
||||
int total = 0;
|
||||
for (const CutsceneCameraSegment& segment : cutscene.cameraTrack) {
|
||||
total += std::max(segment.durationMs, 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "cutscene/CutsceneDatabase.h"
|
||||
#include "dialogue/DialogueTypes.h"
|
||||
#include "quest/QuestJournal.h"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
class CutsceneRuntime {
|
||||
public:
|
||||
void setDatabase(const CutsceneDatabase* value);
|
||||
void setQuestJournal(Quest::QuestJournal* journal);
|
||||
|
||||
void setOnFinished(std::function<void(const std::string&)> cb);
|
||||
void setOnLineStarted(std::function<void(const std::string&)> cb);
|
||||
void setOnFadeInComplete(std::function<void(const std::string&)> cb);
|
||||
|
||||
bool start(const std::string& cutsceneId);
|
||||
void stop();
|
||||
void update(int deltaMs);
|
||||
|
||||
bool isActive() const { return active; }
|
||||
bool canSkip() const;
|
||||
void skip();
|
||||
|
||||
const ZL::Dialogue::PresentationModel& getPresentation() const { return presentation; }
|
||||
|
||||
private:
|
||||
const CutsceneDatabase* database = nullptr;
|
||||
Quest::QuestJournal* questJournal = nullptr;
|
||||
|
||||
const StaticCutsceneDefinition* activeCutscene = nullptr;
|
||||
std::string activeCutsceneId;
|
||||
bool active = false;
|
||||
bool fadeInCallbackFired = false;
|
||||
|
||||
int currentCutsceneLine = -1;
|
||||
int cutsceneTimerMs = 0;
|
||||
int cutsceneElapsedMs = 0;
|
||||
int cutsceneTotalDurationMs = 0;
|
||||
int cutsceneContentDurationMs = 0;
|
||||
std::string currentCutsceneBackground;
|
||||
|
||||
ZL::Dialogue::PresentationModel presentation;
|
||||
|
||||
std::function<void(const std::string&)> onFinished;
|
||||
std::function<void(const std::string&)> onLineStarted;
|
||||
std::function<void(const std::string&)> onFadeInComplete;
|
||||
|
||||
void applyQuestActions(const std::string& questUnlock, const std::string& questComplete,
|
||||
const std::string& questFail, const std::string& objectiveComplete,
|
||||
const std::string& objectiveVisible);
|
||||
|
||||
void finish();
|
||||
void syncLineToElapsedTime();
|
||||
void advanceLine();
|
||||
void refreshPresentation();
|
||||
|
||||
CutsceneCameraBlendState evaluateCameraBlend() const;
|
||||
std::vector<PresentedCutsceneImage> evaluateImages() const;
|
||||
|
||||
static float applyEasing(EasingType easing, float t);
|
||||
static int computeFallbackDurationMs(const std::string& text);
|
||||
static int computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene);
|
||||
};
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ZL::Cutscene {
|
||||
|
||||
enum class EasingType {
|
||||
Linear,
|
||||
EaseInSine,
|
||||
EaseOutSine,
|
||||
EaseInOutSine,
|
||||
EaseInQuad,
|
||||
EaseOutQuad,
|
||||
EaseInOutQuad,
|
||||
EaseInCubic,
|
||||
EaseOutCubic,
|
||||
EaseInOutCubic
|
||||
};
|
||||
|
||||
enum class CutsceneAnchor {
|
||||
Center,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
Custom
|
||||
};
|
||||
|
||||
struct CutsceneLine {
|
||||
std::string speaker;
|
||||
std::string text;
|
||||
std::string portrait;
|
||||
std::string sfx;
|
||||
std::string background;
|
||||
std::string luaCallback;
|
||||
int backgroundWidth = 0;
|
||||
int backgroundHeight = 0;
|
||||
int durationMs = 0;
|
||||
bool waitForConfirm = false;
|
||||
|
||||
std::string questUnlock;
|
||||
std::string questComplete;
|
||||
std::string questFail;
|
||||
std::string objectiveComplete; // "quest_id.objective_id"
|
||||
std::string objectiveVisible; // "quest_id.objective_id"
|
||||
};
|
||||
|
||||
struct CutsceneCameraPose {
|
||||
CutsceneAnchor anchor = CutsceneAnchor::Center;
|
||||
float centerX = 0.5f;
|
||||
float centerY = 0.5f;
|
||||
float zoom = 1.0f;
|
||||
float rotationDeg = 0.0f;
|
||||
};
|
||||
|
||||
struct CutsceneCameraSegment {
|
||||
int durationMs = 0;
|
||||
CutsceneCameraPose from;
|
||||
CutsceneCameraPose to;
|
||||
EasingType easing = EasingType::EaseInOutSine;
|
||||
};
|
||||
|
||||
struct CutsceneImageCue {
|
||||
std::string path;
|
||||
int startMs = 0;
|
||||
int endMs = 0;
|
||||
int fadeInMs = 0;
|
||||
int fadeOutMs = 0;
|
||||
};
|
||||
|
||||
struct StaticCutsceneDefinition {
|
||||
std::string id;
|
||||
std::string background;
|
||||
int backgroundWidth = 1280;
|
||||
int backgroundHeight = 720;
|
||||
std::string music;
|
||||
std::string onFadeInCallback;
|
||||
bool skippable = true;
|
||||
int durationMs = 0;
|
||||
int fadeOutMs = 0;
|
||||
int fadeInMs = 0;
|
||||
int endFadeOutMs = 0;
|
||||
int endFadeInMs = 0;
|
||||
std::vector<CutsceneCameraSegment> cameraTrack;
|
||||
std::vector<CutsceneImageCue> images;
|
||||
std::vector<CutsceneLine> lines;
|
||||
};
|
||||
|
||||
struct PresentedCutsceneImage {
|
||||
std::string path;
|
||||
float alpha = 1.0f;
|
||||
};
|
||||
|
||||
struct CutsceneCameraBlendState {
|
||||
bool active = false;
|
||||
CutsceneCameraPose from;
|
||||
CutsceneCameraPose to;
|
||||
float t = 1.0f;
|
||||
};
|
||||
|
||||
} // namespace ZL::Cutscene
|
||||
Reference in new issue
Block a user