From 2e5988f8f44dbf3ec7f50ea5c03e2efb66dada04 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Sat, 6 Jun 2026 22:04:42 +0300 Subject: [PATCH] Refactoring cutscenes --- proj-web/CMakeLists.txt | 8 + proj-windows/CMakeLists.txt | 8 + src/Location.cpp | 1 + src/cutscene/CutsceneDatabase.cpp | 172 ++++++ src/cutscene/CutsceneDatabase.h | 31 + src/cutscene/CutsceneOverlay.cpp | 410 +++++++++++++ src/cutscene/CutsceneOverlay.h | 83 +++ src/cutscene/CutsceneRuntime.cpp | 509 ++++++++++++++++ src/cutscene/CutsceneRuntime.h | 69 +++ src/cutscene/CutsceneTypes.h | 102 ++++ src/dialogue/DialogueDatabase.cpp | 201 ++----- src/dialogue/DialogueDatabase.h | 9 - src/dialogue/DialogueOverlay.cpp | 948 +++++++----------------------- src/dialogue/DialogueOverlay.h | 115 +--- src/dialogue/DialogueRuntime.cpp | 677 ++------------------- src/dialogue/DialogueRuntime.h | 55 +- src/dialogue/DialogueSystem.cpp | 175 +++--- src/dialogue/DialogueSystem.h | 38 +- src/dialogue/DialogueTypes.h | 118 +--- src/render/UiQuad.h | 65 ++ 20 files changed, 1913 insertions(+), 1881 deletions(-) create mode 100644 src/cutscene/CutsceneDatabase.cpp create mode 100644 src/cutscene/CutsceneDatabase.h create mode 100644 src/cutscene/CutsceneOverlay.cpp create mode 100644 src/cutscene/CutsceneOverlay.h create mode 100644 src/cutscene/CutsceneRuntime.cpp create mode 100644 src/cutscene/CutsceneRuntime.h create mode 100644 src/cutscene/CutsceneTypes.h create mode 100644 src/render/UiQuad.h diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index 10dffd9..fa20b5f 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -129,6 +129,14 @@ set(SOURCES ../src/quest/QuestTypes.h ../src/quest/QuestJournal.h ../src/quest/QuestJournal.cpp + ../src/cutscene/CutsceneTypes.h + ../src/cutscene/CutsceneDatabase.h + ../src/cutscene/CutsceneDatabase.cpp + ../src/cutscene/CutsceneRuntime.h + ../src/cutscene/CutsceneRuntime.cpp + ../src/cutscene/CutsceneOverlay.h + ../src/cutscene/CutsceneOverlay.cpp + ../src/render/UiQuad.h ) add_executable(bishkek-witcher ${SOURCES}) diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 4182765..4205aec 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -84,6 +84,14 @@ add_executable(witcher001 ../src/quest/QuestTypes.h ../src/quest/QuestJournal.h ../src/quest/QuestJournal.cpp + ../src/cutscene/CutsceneTypes.h + ../src/cutscene/CutsceneDatabase.h + ../src/cutscene/CutsceneDatabase.cpp + ../src/cutscene/CutsceneRuntime.h + ../src/cutscene/CutsceneRuntime.cpp + ../src/cutscene/CutsceneOverlay.h + ../src/cutscene/CutsceneOverlay.cpp + ../src/render/UiQuad.h ) # Установка проекта по умолчанию для Visual Studio diff --git a/src/Location.cpp b/src/Location.cpp index 45d2389..c886f7c 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -130,6 +130,7 @@ namespace ZL dialogueSystem.init(renderer, CONST_ZIP_FILE); dialogueSystem.loadDatabase(params.dialoguesJsonPath); + dialogueSystem.loadCutsceneDatabase(params.dialoguesJsonPath); dialogueSystem.setQuestJournal(journal); npcNameText = std::make_unique(); diff --git a/src/cutscene/CutsceneDatabase.cpp b/src/cutscene/CutsceneDatabase.cpp new file mode 100644 index 0000000..c0085fc --- /dev/null +++ b/src/cutscene/CutsceneDatabase.cpp @@ -0,0 +1,172 @@ +#include "cutscene/CutsceneDatabase.h" + +#include "utils/Utils.h" +#include + +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 diff --git a/src/cutscene/CutsceneDatabase.h b/src/cutscene/CutsceneDatabase.h new file mode 100644 index 0000000..1e83582 --- /dev/null +++ b/src/cutscene/CutsceneDatabase.h @@ -0,0 +1,31 @@ +#pragma once + +#include "cutscene/CutsceneTypes.h" +#include "external/nlohmann/json.hpp" +#include +#include + +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 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 diff --git a/src/cutscene/CutsceneOverlay.cpp b/src/cutscene/CutsceneOverlay.cpp new file mode 100644 index 0000000..bdb0312 --- /dev/null +++ b/src/cutscene/CutsceneOverlay.cpp @@ -0,0 +1,410 @@ +#include "cutscene/CutsceneOverlay.h" +#include "dialogue/DialogueTypes.h" + +#include "Environment.h" +#include "GameConstants.h" +#include +#include + +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(); + cutsceneRenderer= std::make_unique(); + choiceRenderer = std::make_unique(); + + 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 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(model.backgroundWidth) : static_cast(texture->getWidth()); + const float imgH = (model.backgroundHeight > 0) ? static_cast(model.backgroundHeight) : static_cast(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(cutsceneSkipHoldElapsedMs) / static_cast(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 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 diff --git a/src/cutscene/CutsceneOverlay.h b/src/cutscene/CutsceneOverlay.h new file mode 100644 index 0000000..dbc154c --- /dev/null +++ b/src/cutscene/CutsceneOverlay.h @@ -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 +#include +#include + +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 choiceMainTexture; + std::shared_ptr choiceOptionalTexture; + std::shared_ptr 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 nameRenderer; + std::unique_ptr cutsceneRenderer; + std::unique_ptr choiceRenderer; + + UiQuad backgroundQuad; + UiQuad subtitleQuad; + UiQuad skipHintBgQuad; + UiQuad skipProgressBgQuad; + UiQuad skipProgressFillQuad; + + std::shared_ptr 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 diff --git a/src/cutscene/CutsceneRuntime.cpp b/src/cutscene/CutsceneRuntime.cpp new file mode 100644 index 0000000..0433a2b --- /dev/null +++ b/src/cutscene/CutsceneRuntime.cpp @@ -0,0 +1,509 @@ +#include "cutscene/CutsceneRuntime.h" + +#include +#include +#include + +namespace ZL::Cutscene { + +static std::pair 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 cb) { + onFinished = std::move(cb); +} + +void CutsceneRuntime::setOnLineStarted(std::function cb) { + onLineStarted = std::move(cb); +} + +void CutsceneRuntime::setOnFadeInComplete(std::function 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(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(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(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(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(elapsed) / static_cast(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 CutsceneRuntime::evaluateImages() const { + std::vector 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(now - startMs) / static_cast(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(cutsceneElapsedMs) / static_cast(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(phase2elapsed) / static_cast(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(elapsed) / static_cast(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(elapsed) / static_cast(endFadeInMs), 0.0f, 1.0f) + : 0.0f; + } + + presentation.choices.clear(); + presentation.selectedChoice = -1; + presentation.revealCompleted = true; + + const bool hasSubtitle = currentCutsceneLine >= 0 && + currentCutsceneLine < static_cast(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((1000.0 * static_cast(std::max(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 diff --git a/src/cutscene/CutsceneRuntime.h b/src/cutscene/CutsceneRuntime.h new file mode 100644 index 0000000..cf6fb06 --- /dev/null +++ b/src/cutscene/CutsceneRuntime.h @@ -0,0 +1,69 @@ +#pragma once + +#include "cutscene/CutsceneDatabase.h" +#include "dialogue/DialogueTypes.h" +#include "quest/QuestJournal.h" +#include +#include + +namespace ZL::Cutscene { + +class CutsceneRuntime { +public: + void setDatabase(const CutsceneDatabase* value); + void setQuestJournal(Quest::QuestJournal* journal); + + void setOnFinished(std::function cb); + void setOnLineStarted(std::function cb); + void setOnFadeInComplete(std::function 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 onFinished; + std::function onLineStarted; + std::function 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 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 diff --git a/src/cutscene/CutsceneTypes.h b/src/cutscene/CutsceneTypes.h new file mode 100644 index 0000000..af3b440 --- /dev/null +++ b/src/cutscene/CutsceneTypes.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +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 cameraTrack; + std::vector images; + std::vector 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 diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index 47e3a20..2cf96c3 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -11,73 +11,51 @@ namespace ZL namespace ZL::Dialogue { NodeType DialogueDatabase::parseNodeType(const std::string& value) { - if (value == "Choice") return NodeType::Choice; - if (value == "Condition") return NodeType::Condition; - if (value == "SetFlag") return NodeType::SetFlag; - if (value == "Jump") return NodeType::Jump; - if (value == "End") return NodeType::End; + if (value == "Choice") return NodeType::Choice; + if (value == "Condition") return NodeType::Condition; + if (value == "SetFlag") return NodeType::SetFlag; + if (value == "Jump") return NodeType::Jump; + if (value == "End") return NodeType::End; if (value == "CutsceneStart") return NodeType::CutsceneStart; return NodeType::Line; } ChoiceKind DialogueDatabase::parseChoiceKind(const std::string& value) { if (value == "Optional") return ChoiceKind::Optional; - if (value == "Exit") return ChoiceKind::Exit; + if (value == "Exit") return ChoiceKind::Exit; return ChoiceKind::Main; } ComparisonOp DialogueDatabase::parseComparisonOp(const std::string& value) { - if (value == "==" || value == "Equals") return ComparisonOp::Equals; - if (value == "!=" || value == "NotEquals") return ComparisonOp::NotEquals; + if (value == "==" || value == "Equals") return ComparisonOp::Equals; + if (value == "!=" || value == "NotEquals") return ComparisonOp::NotEquals; if (value == ">=" || value == "GreaterOrEqual") return ComparisonOp::GreaterOrEqual; - if (value == "<=" || value == "LessOrEqual") return ComparisonOp::LessOrEqual; + if (value == "<=" || value == "LessOrEqual") return ComparisonOp::LessOrEqual; return ComparisonOp::Exists; } -EasingType DialogueDatabase::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 DialogueDatabase::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; -} - Condition DialogueDatabase::parseCondition(const json& j) { Condition c; - c.flag = j.value("flag", ""); - c.op = parseComparisonOp(j.value("op", "Exists")); + c.flag = j.value("flag", ""); + c.op = parseComparisonOp(j.value("op", "Exists")); c.value = j.value("value", 1); return c; } Effect DialogueDatabase::parseEffect(const json& j) { Effect e; - e.flag = j.value("flag", ""); - e.value = j.value("value", 1); + e.flag = j.value("flag", ""); + e.value = j.value("value", 1); e.relative = j.value("relative", false); return e; } Choice DialogueDatabase::parseChoice(const json& j) { Choice c; - c.id = j.value("id", ""); - c.text = j.value("text", ""); - c.next = j.value("next", ""); - c.kind = parseChoiceKind(j.value("kind", "Main")); + c.id = j.value("id", ""); + c.text = j.value("text", ""); + c.next = j.value("next", ""); + c.kind = parseChoiceKind(j.value("kind", "Main")); c.consumeOnce = j.value("consumeOnce", false); if (j.contains("conditions") && j["conditions"].is_array()) { @@ -95,17 +73,17 @@ Choice DialogueDatabase::parseChoice(const json& j) { Node DialogueDatabase::parseNode(const json& j) { Node node; - node.id = j.value("id", ""); - node.type = parseNodeType(j.value("type", "Line")); - node.speaker = j.value("speaker", ""); - node.text = j.value("text", ""); - node.portrait = j.value("portrait", ""); - node.next = j.value("next", ""); - node.trueNext = j.value("trueNext", ""); - node.falseNext = j.value("falseNext", ""); + node.id = j.value("id", ""); + node.type = parseNodeType(j.value("type", "Line")); + node.speaker = j.value("speaker", ""); + node.text = j.value("text", ""); + node.portrait = j.value("portrait", ""); + node.next = j.value("next", ""); + node.trueNext = j.value("trueNext", ""); + node.falseNext = j.value("falseNext", ""); node.cutsceneId = j.value("cutsceneId", ""); - node.luaCallback = j.value("luaCallback", ""); - node.chatBubble = j.value("chatBubble", ""); + node.luaCallback= j.value("luaCallback", ""); + node.chatBubble = j.value("chatBubble", ""); node.questUnlock = j.value("questUnlock", ""); node.questComplete = j.value("questComplete", ""); node.questFail = j.value("questFail", ""); @@ -127,16 +105,15 @@ Node DialogueDatabase::parseNode(const json& j) { node.choices.push_back(parseChoice(item)); } } - return node; } DialogueDefinition DialogueDatabase::parseDialogue(const json& j) { DialogueDefinition result; - result.id = j.value("id", ""); - result.displayName = j.value("displayName", result.id); - result.startNode = j.value("start", ""); - result.uninterruptible = j.value("uninterruptible", false); + result.id = j.value("id", ""); + result.displayName = j.value("displayName", result.id); + result.startNode = j.value("start", ""); + result.uninterruptible= j.value("uninterruptible", false); if (j.contains("nodes") && j["nodes"].is_array()) { for (const auto& item : j["nodes"]) { @@ -146,105 +123,11 @@ DialogueDefinition DialogueDatabase::parseDialogue(const json& j) { } } } - return result; } -CutsceneLine DialogueDatabase::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 DialogueDatabase::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 DialogueDatabase::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 DialogueDatabase::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 DialogueDatabase::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 DialogueDatabase::loadFromFile(const std::string& path) { dialogues.clear(); - cutscenes.clear(); std::string raw; try { @@ -254,15 +137,15 @@ bool DialogueDatabase::loadFromFile(const std::string& path) { else { auto buf = readFileFromZIP(path, CONST_ZIP_FILE); if (buf.empty()) { - std::cerr << "UiManager: failed to read " << path << " from zip " << CONST_ZIP_FILE << std::endl; - throw std::runtime_error("Failed to load UI file: " + path); + std::cerr << "[dialogue] Failed to read " << path << " from zip\n"; + throw std::runtime_error("Failed to load dialogue file: " + path); } raw.assign(buf.begin(), buf.end()); } } catch (const std::exception& e) { - std::cerr << "UiManager: failed to open " << path << " : " << e.what() << std::endl; - throw std::runtime_error("Failed to load UI file: " + path); + std::cerr << "[dialogue] Failed to open " << path << ": " << e.what() << "\n"; + throw std::runtime_error("Failed to load dialogue file: " + path); } json root; @@ -283,15 +166,6 @@ bool DialogueDatabase::loadFromFile(const std::string& path) { } } - 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 !dialogues.empty(); } @@ -300,9 +174,4 @@ const DialogueDefinition* DialogueDatabase::findDialogue(const std::string& id) return (it != dialogues.end()) ? &it->second : nullptr; } -const StaticCutsceneDefinition* DialogueDatabase::findCutscene(const std::string& id) const { - auto it = cutscenes.find(id); - return (it != cutscenes.end()) ? &it->second : nullptr; -} - } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h index 50946d2..3a09239 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -14,28 +14,19 @@ public: bool loadFromFile(const std::string& path); const DialogueDefinition* findDialogue(const std::string& id) const; - const StaticCutsceneDefinition* findCutscene(const std::string& id) const; private: std::unordered_map dialogues; - std::unordered_map cutscenes; static NodeType parseNodeType(const std::string& value); static ChoiceKind parseChoiceKind(const std::string& value); static ComparisonOp parseComparisonOp(const std::string& value); - static EasingType parseEasingType(const std::string& value); - static CutsceneAnchor parseCutsceneAnchor(const std::string& value); static Condition parseCondition(const json& j); static Effect parseEffect(const json& j); static Choice parseChoice(const json& j); static Node parseNode(const json& j); static DialogueDefinition parseDialogue(const json& j); - 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::Dialogue diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp index 947aeb8..ca0f78d 100644 --- a/src/dialogue/DialogueOverlay.cpp +++ b/src/dialogue/DialogueOverlay.cpp @@ -5,787 +5,255 @@ #include "Environment.h" #include #include -#include namespace ZL { - extern float x; - extern float y; + extern float x; + extern float y; } namespace ZL::Dialogue { -void DialogueOverlay::TexturedQuad::rebuild(const UiRect& newRect) { - rect = newRect; - mesh.data = CreateRect2D( - { rect.x + rect.w * 0.5f, rect.y + rect.h * 0.5f }, - { rect.w * 0.5f, rect.h * 0.5f }, - 0.0f - ); - mesh.RefreshVBO(); - initialized = true; -} - -void DialogueOverlay::TexturedQuad::rebuildWithUV( - const UiRect& newRect, - const Eigen::Vector2f& uvBottomLeft, - const Eigen::Vector2f& uvTopLeft, - const Eigen::Vector2f& uvTopRight, - const Eigen::Vector2f& uvBottomRight -) { - rect = newRect; - - const float x0 = rect.x; - const float y0 = rect.y; - const float x1 = rect.x + rect.w; - const float y1 = rect.y + rect.h; - - VertexDataStruct data; - data.PositionData = { - { x0, y0, 0.0f }, // bottom-left - { x0, y1, 0.0f }, // top-left - { x1, y1, 0.0f }, // top-right - - { x1, y1, 0.0f }, // top-right - { x1, y0, 0.0f }, // bottom-right - { x0, y0, 0.0f } // bottom-left - }; - - data.TexCoordData = { - uvBottomLeft, - uvTopLeft, - uvTopRight, - - uvTopRight, - uvBottomRight, - uvBottomLeft - }; - - mesh.AssignFrom(data); - initialized = true; -} - bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) { - rendererRef = &renderer; - zipFilename = zipFile; + rendererRef = &renderer; + zipFilename = zipFile; - textboxTexture = renderer.textureManager.LoadFromPng("resources/dialogue/textbox_bg.png", zipFile); - //portraitFrameTexture = renderer.textureManager.LoadFromPng("resources/dialogue/portrait_frame.png", zipFile); - choiceMainTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_main.png", zipFile); - choiceOptionalTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_optional.png", zipFile); - choiceSelectedTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_selected.png", zipFile); - cutsceneSubtitleTexture = renderer.textureManager.LoadFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile); + textboxTexture = renderer.textureManager.LoadFromPng("resources/dialogue/textbox_bg.png", zipFile); + choiceMainTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_main.png", zipFile); + choiceOptionalTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_optional.png", zipFile); + choiceSelectedTexture = renderer.textureManager.LoadFromPng("resources/dialogue/choice_selected.png", zipFile); - nameRenderer = std::make_unique(); - bodyRenderer = std::make_unique(); - choiceRenderer = std::make_unique(); - cutsceneRenderer = std::make_unique(); + nameRenderer = std::make_unique(); + bodyRenderer = std::make_unique(); + choiceRenderer = std::make_unique(); - const bool ok = - nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) && - bodyRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) && - choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile) && - cutsceneRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile); - - return ok; + return + nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) && + bodyRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) && + choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile); } void DialogueOverlay::update(const PresentationModel& model, int deltaMs) { - if (model.mode == PresentationMode::Hidden) { - hoveredChoiceIndex = -1; - cutsceneSkipHintVisible = false; - cutsceneSkipArmed = false; - cutsceneSkipHolding = false; - cutsceneSkipTriggered = false; - cutsceneSkipHintRemainingMs = 0; - cutsceneSkipHoldElapsedMs = 0; - lastChoiceRects.clear(); - lastDialogueAdvanceRect = {}; - lastCutsceneAdvanceRect = {}; - return; - } - - if (model.mode != PresentationMode::Choice) { - hoveredChoiceIndex = -1; - } - - if (model.mode != 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)deltaMs; + if (model.mode == PresentationMode::Hidden) { + hoveredChoiceIndex = -1; + lastChoiceRects.clear(); + lastDialogueAdvanceRect = {}; + return; + } + if (model.mode != PresentationMode::Choice) { + hoveredChoiceIndex = -1; + } } void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { - if (model.mode == PresentationMode::Hidden) { - lastChoiceRects.clear(); - lastDialogueAdvanceRect = {}; - lastCutsceneAdvanceRect = {}; - cutsceneSkipHintVisible = false; - cutsceneSkipArmed = false; - cutsceneSkipHolding = false; - cutsceneSkipHintRemainingMs = 0; - cutsceneSkipHoldElapsedMs = 0; - return; - } - - if (model.mode == PresentationMode::Cutscene) { - drawCutscene(renderer, model); - } - else { - drawDialogue(renderer, model); - } -} - -void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) { - const float W = Environment::projectionWidth; - // const float H = Environment::projectionHeight; - - UiRect portraitRect{ 24.0f+90, 24.0f+16, 176.0f, 176.0f }; - //const UiRect textboxRect{ 220.0f, 24.0f, max(200.0f, W - 244.0f), 182.0f }; - UiRect textboxRect{ 30.f, -48.f, 1222.f, 340.0f }; - - - lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h }; - lastCutsceneAdvanceRect = {}; - cutsceneSkipHintVisible = false; - cutsceneSkipArmed = false; - cutsceneSkipHolding = false; - cutsceneSkipHintRemainingMs = 0; - cutsceneSkipHoldElapsedMs = 0; - - if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || - portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { - portraitQuad.rebuild(portraitRect); - } - if (!textboxQuad.initialized || textboxQuad.rect.w != textboxRect.w || textboxQuad.rect.h != textboxRect.h || - textboxQuad.rect.x != textboxRect.x || textboxQuad.rect.y != textboxRect.y) { - textboxQuad.rebuild(textboxRect); - } - - glEnable(GL_BLEND); - renderer.shaderManager.PushShader(defaultShaderName); - renderer.RenderUniform1i(textureUniformName, 0); - renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); - renderer.PushMatrix(); - renderer.LoadIdentity(); - renderer.RenderUniform1f("uAlpha", 1.0f); - - drawQuad(renderer, textboxQuad, textboxTexture); - //drawQuad(renderer, portraitQuad, model.portraitPath.empty() ? portraitFrameTexture : loadTextureCached(model.portraitPath)); - drawQuad(renderer, portraitQuad, loadTextureCached(model.portraitPath)); - - renderer.PopMatrix(); - renderer.PopProjectionMatrix(); - renderer.shaderManager.PopShader(); - - const float nameX = 312; - const float nameY = 232 - 38.0f; - const float bodyX = 312; - const float bodyY = 232 - 78.0f; - - if (!model.speaker.empty()) { - nameRenderer->drawText(model.speaker, nameX, nameY, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }); - } - - const float bodyTextScale = 1.0f; - const float bodyMaxWidthPx = W - nameX - 48.f-x-60.f; - - const std::string wrappedBody = wrapTextToWidth(model.visibleText, *bodyRenderer, bodyMaxWidthPx, bodyTextScale); - bodyRenderer->drawText(wrappedBody, bodyX, bodyY, bodyTextScale, false, { 1.0f, 1.0f, 1.0f, 1.0f }); - - lastChoiceRects.clear(); - if (model.mode == PresentationMode::Choice) { - const float choiceStartY = textboxRect.y + 56.0f; - const float choiceHeight = 30.0f; - const float choiceSpacing = 8.0f; - const float choiceWidth = textboxRect.w - 48.0f; - - if (choiceQuads.size() < model.choices.size()) { - choiceQuads.resize(model.choices.size()); - } - - renderer.shaderManager.PushShader(defaultShaderName); - renderer.RenderUniform1i(textureUniformName, 0); - renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); - renderer.PushMatrix(); - renderer.LoadIdentity(); - - for (size_t i = 0; i < model.choices.size(); ++i) { - const float y = choiceStartY + (choiceHeight + choiceSpacing) * static_cast(model.choices.size() - 1 - i); - UiRect rect{ textboxRect.x + 20.0f, y, choiceWidth, choiceHeight }; - lastChoiceRects.push_back(rect); - choiceQuads[i].rebuild(rect); - - const bool isHighlighted = static_cast(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; - std::shared_ptr choiceTexture = (model.choices[i].kind == ChoiceKind::Optional) ? choiceOptionalTexture : choiceMainTexture; - if (isHighlighted) { - choiceTexture = choiceSelectedTexture; - } - drawQuad(renderer, choiceQuads[i], choiceTexture); - } - - renderer.PopMatrix(); - renderer.PopProjectionMatrix(); - renderer.shaderManager.PopShader(); - - for (size_t i = 0; i < model.choices.size(); ++i) { - const UiRect& rect = lastChoiceRects[i]; - const bool isHighlighted = static_cast(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; - const std::array color = (model.choices[i].kind == ChoiceKind::Optional) - ? std::array{0.82f, 0.82f, 0.82f, 1.0f} - : std::array{ 1.0f, 0.93f, 0.65f, 1.0f }; - - const float choiceTextScale = 1.0f; - const float choiceMaxWidthPx = rect.w - 28.0f; - - const std::string wrappedChoiceText = wrapTextToWidth( - model.choices[i].text, - *choiceRenderer, - choiceMaxWidthPx, - choiceTextScale - ); - - choiceRenderer->drawText( - wrappedChoiceText, - rect.x + 14.0f, - rect.y + 9.0f, - choiceTextScale, - false, - isHighlighted ? std::array{1.0f, 1.0f, 1.0f, 1.0f} : color - ); - } - } - - glDisable(GL_BLEND); -} - -float DialogueOverlay::lerpFloat(float a, float b, float t) { - return a + (b - a) * t; -} - -DialogueOverlay::ResolvedViewport DialogueOverlay::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 rotationRad = pose.rotationDeg * 3.14159265358979323846f / 180.0f; - const float c = std::cos(rotationRad); - const float s = std::sin(rotationRad); - - // Bounding box повернутого viewport внутри source image. - 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: - // centerY: 0 = top, 1 = bottom - 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; -} - -DialogueOverlay::ResolvedViewport DialogueOverlay::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; -} - -void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) { - const float W = Environment::projectionWidth; - const float H = Environment::projectionHeight; - const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; - - lastDialogueAdvanceRect = {}; - 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 imageLayers = model.cutsceneImages; - if (imageLayers.empty() && !model.backgroundPath.empty()) { - imageLayers.push_back({ model.backgroundPath, 1.0f }); - } - - for (const PresentedCutsceneImage& layer : imageLayers) { - const auto texture = loadTextureCached(layer.path); - if (!texture) { - continue; - } - - // Logical content dimensions for camera and UV math. - // backgroundWidth/backgroundHeight define the coordinate space the camera track was authored in; - // the texture is assumed to fill this space entirely regardless of its actual pixel dimensions. - const float imgW = (model.backgroundWidth > 0) ? static_cast(model.backgroundWidth) : static_cast(texture->getWidth()); - const float imgH = (model.backgroundHeight > 0) ? static_cast(model.backgroundHeight) : static_cast(texture->getHeight()); - - ResolvedViewport layerViewport{}; - - if (model.cutsceneCamera.active) { - const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, imgW, imgH, W, H); - const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, imgW, imgH, W, H); - layerViewport = blendViewport( - fromViewport, - toViewport, - std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f) - ); - } - else { - layerViewport = resolveViewportPose(CutsceneCameraPose{}, imgW, imgH, W, H); - } - - const float halfW = layerViewport.widthPx * 0.5f; - const float halfH = layerViewport.heightPx * 0.5f; - const float rotationRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f; - - const float c = std::cos(rotationRad); - const float s = std::sin(rotationRad); - - auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f { - return { - layerViewport.centerXPx + x * c - y * s, - layerViewport.centerYPx + x * s + y * c - }; - }; - - // Source viewport corners in logical image space, then converted to UV via actual texture size. - 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)); - drawQuad(renderer, backgroundQuad, texture); - } - - renderer.PopMatrix(); - renderer.PopProjectionMatrix(); - renderer.shaderManager.PopShader(); - - // Black overlay: fades IN over the game world (phase 1), then fades OUT to reveal cutscene (phase 2). - // Image renders at cutsceneGlobalFadeAlpha (0 during phase 1 so game world shows through for the - // fade-to-black effect, 1 during phase 2 so it blocks the world while black peels away). - if (model.cutsceneBlackAlpha > 0.001f) { - const float blackAlpha = model.cutsceneBlackAlpha; - 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", blackAlpha); - renderer.DrawVertexRenderStruct(backgroundQuad.mesh); - - renderer.PopMatrix(); - renderer.PopProjectionMatrix(); - renderer.shaderManager.PopShader(); - } - - // UI quads over the image: subtitle panel and skip progress hint background. - 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); - drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); - } - - if (model.cutsceneSkippable && cutsceneSkipHintVisible) { - const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f }; - skipHintBgQuad.rebuild(hintBg); - drawQuad(renderer, skipHintBgQuad, choiceOptionalTexture); - - const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f }; - skipProgressBgQuad.rebuild(progressBg); - drawQuad(renderer, skipProgressBgQuad, choiceOptionalTexture); - - if (cutsceneSkipHolding) { - const float progress = std::clamp( - static_cast(cutsceneSkipHoldElapsedMs) / static_cast(CutsceneSkipHoldDurationMs), - 0.0f, - 1.0f - ); - const UiRect progressFill{ progressBg.x, progressBg.y, progressBg.w * progress, progressBg.h }; - skipProgressFillQuad.rebuild(progressFill); - drawQuad(renderer, skipProgressFillQuad, choiceMainTexture); - } - } - - 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 float subtitleTextScale = 1.0f; - const float subtitleMaxWidthPx = subtitleRect.w - 48.0f; - - const std::string wrappedSubtitle = wrapTextToWidth( - model.visibleText, - *cutsceneRenderer, - subtitleMaxWidthPx, - subtitleTextScale - ); - - cutsceneRenderer->drawText( - wrappedSubtitle, - subtitleRect.x + 24.0f, - subtitleRect.y + 30.0f, - subtitleTextScale, - 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 DialogueOverlay::consumeSkipRequested() { - const bool result = cutsceneSkipTriggered; - cutsceneSkipTriggered = false; - - if (result) { - cutsceneSkipHintVisible = false; - cutsceneSkipArmed = false; - cutsceneSkipHolding = false; - cutsceneSkipHintRemainingMs = 0; - cutsceneSkipHoldElapsedMs = 0; - } - - return result; + if (model.mode != PresentationMode::Dialogue && model.mode != PresentationMode::Choice) { + lastChoiceRects.clear(); + lastDialogueAdvanceRect = {}; + return; + } + + const float W = Environment::projectionWidth; + + UiRect portraitRect{ 24.0f + 90, 24.0f + 16, 176.0f, 176.0f }; + UiRect textboxRect{ 30.f, -48.f, 1222.f, 340.0f }; + + lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h }; + + if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || + portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { + portraitQuad.rebuild(portraitRect); + } + if (!textboxQuad.initialized || textboxQuad.rect.w != textboxRect.w || textboxQuad.rect.h != textboxRect.h || + textboxQuad.rect.x != textboxRect.x || textboxQuad.rect.y != textboxRect.y) { + textboxQuad.rebuild(textboxRect); + } + + glEnable(GL_BLEND); + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + renderer.RenderUniform1f("uAlpha", 1.0f); + + glBindTexture(GL_TEXTURE_2D, textboxTexture->getTexID()); + renderer.DrawVertexRenderStruct(textboxQuad.mesh); + + { + auto portrait = loadTextureCached(model.portraitPath); + if (portrait) { + glBindTexture(GL_TEXTURE_2D, portrait->getTexID()); + renderer.DrawVertexRenderStruct(portraitQuad.mesh); + } + } + + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + const float nameX = 312; + const float nameY = 232 - 38.0f; + const float bodyX = 312; + const float bodyY = 232 - 78.0f; + + if (!model.speaker.empty()) { + nameRenderer->drawText(model.speaker, nameX, nameY, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }); + } + + const float bodyMaxWidthPx = W - nameX - 48.f - ZL::x - 60.f; + const std::string wrappedBody = wrapTextToWidth(model.visibleText, *bodyRenderer, bodyMaxWidthPx, 1.0f); + bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); + + lastChoiceRects.clear(); + if (model.mode == PresentationMode::Choice) { + const float choiceStartY = textboxRect.y + 56.0f; + const float choiceHeight = 30.0f; + const float choiceSpacing = 8.0f; + const float choiceWidth = textboxRect.w - 48.0f; + + if (choiceQuads.size() < model.choices.size()) { + choiceQuads.resize(model.choices.size()); + } + + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + + for (size_t i = 0; i < model.choices.size(); ++i) { + const float cy = choiceStartY + (choiceHeight + choiceSpacing) * static_cast(model.choices.size() - 1 - i); + UiRect rect{ textboxRect.x + 20.0f, cy, choiceWidth, choiceHeight }; + lastChoiceRects.push_back(rect); + choiceQuads[i].rebuild(rect); + + const bool isHighlighted = static_cast(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; + std::shared_ptr choiceTexture = + (model.choices[i].kind == ChoiceKind::Optional) ? choiceOptionalTexture : choiceMainTexture; + if (isHighlighted) choiceTexture = choiceSelectedTexture; + glBindTexture(GL_TEXTURE_2D, choiceTexture->getTexID()); + renderer.DrawVertexRenderStruct(choiceQuads[i].mesh); + } + + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + for (size_t i = 0; i < model.choices.size(); ++i) { + const UiRect& rect = lastChoiceRects[i]; + const bool isHighlighted = static_cast(i) == hoveredChoiceIndex || static_cast(i) == model.selectedChoice; + const std::array color = (model.choices[i].kind == ChoiceKind::Optional) + ? std::array{0.82f, 0.82f, 0.82f, 1.0f} + : std::array{ 1.0f, 0.93f, 0.65f, 1.0f }; + + const std::string wrappedChoice = wrapTextToWidth(model.choices[i].text, *choiceRenderer, rect.w - 28.0f, 1.0f); + choiceRenderer->drawText( + wrappedChoice, rect.x + 14.0f, rect.y + 9.0f, 1.0f, false, + isHighlighted ? std::array{1.0f, 1.0f, 1.0f, 1.0f} : color + ); + } + } + + glDisable(GL_BLEND); } void DialogueOverlay::handlePointerDown(float x, float y, const PresentationModel& model) { - (void)x; - (void)y; - - if (model.mode == PresentationMode::Choice) { - handlePointerMoved(x, y, model); - return; - } - - if (model.mode == PresentationMode::Cutscene && model.cutsceneSkippable) { - // First click/tap only arms skip and shows the hint for a short time. - // It does not immediately start skipping, to avoid accidental skip. - if (!cutsceneSkipArmed) { - cutsceneSkipHintVisible = true; - cutsceneSkipArmed = true; - cutsceneSkipHolding = false; - cutsceneSkipTriggered = false; - cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; - cutsceneSkipHoldElapsedMs = 0; - return; - } - - // Once armed, holding anywhere on the screen starts skip progress. - cutsceneSkipHintVisible = true; - cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; - cutsceneSkipHolding = true; - cutsceneSkipTriggered = false; - cutsceneSkipHoldElapsedMs = 0; - return; - } + if (model.mode == PresentationMode::Choice) { + handlePointerMoved(x, y, model); + } } void DialogueOverlay::handlePointerMoved(float x, float y, const PresentationModel& model) { - if (model.mode == PresentationMode::Choice) { - hoveredChoiceIndex = -1; - for (size_t i = 0; i < lastChoiceRects.size(); ++i) { - if (rectContains(lastChoiceRects[i], x, y)) { - hoveredChoiceIndex = static_cast(i); - break; - } - } - return; - } - - hoveredChoiceIndex = -1; + if (model.mode == PresentationMode::Choice) { + hoveredChoiceIndex = -1; + for (size_t i = 0; i < lastChoiceRects.size(); ++i) { + if (rectContains(lastChoiceRects[i], x, y)) { + hoveredChoiceIndex = static_cast(i); + break; + } + } + return; + } + hoveredChoiceIndex = -1; } -bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue) { - outChoiceIndex = -1; - outAdvanceDialogue = false; +bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, + int& outChoiceIndex, bool& outAdvanceDialogue) +{ + outChoiceIndex = -1; + outAdvanceDialogue = false; - if (model.mode == PresentationMode::Choice) { - for (size_t i = 0; i < lastChoiceRects.size(); ++i) { - if (rectContains(lastChoiceRects[i], x, y)) { - outChoiceIndex = static_cast(i); - return true; - } - } - return false; - } - - if (model.mode == PresentationMode::Dialogue) { - outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y); - return outAdvanceDialogue; - } - - if (model.mode == PresentationMode::Cutscene) { - if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) { - cutsceneSkipHolding = false; - cutsceneSkipHoldElapsedMs = 0; - } - return true; + if (model.mode == PresentationMode::Choice) { + for (size_t i = 0; i < lastChoiceRects.size(); ++i) { + if (rectContains(lastChoiceRects[i], x, y)) { + outChoiceIndex = static_cast(i); + return true; + } + } + return false; } - return false; + if (model.mode == PresentationMode::Dialogue) { + outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y); + return outAdvanceDialogue; + } + + return false; } std::shared_ptr DialogueOverlay::loadTextureCached(const std::string& path) { - if (path.empty()) { - return nullptr; - } - - return rendererRef->textureManager.LoadFromPng(path, zipFilename); + if (path.empty()) return nullptr; + return rendererRef->textureManager.LoadFromPng(path, zipFilename); } -void DialogueOverlay::drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& texture) const { - if (!texture) { - return; - } - - glBindTexture(GL_TEXTURE_2D, texture->getTexID()); - renderer.DrawVertexRenderStruct(quad.mesh); -} - -std::string DialogueOverlay::wrapText(const std::string& input, size_t maxLineLength) { - if (input.size() <= maxLineLength) { - return input; - } - - std::string output; - size_t lineStart = 0; - while (lineStart < input.size()) { - size_t lineEnd = min(lineStart + maxLineLength, input.size()); - if (lineEnd < input.size()) { - const size_t split = input.rfind(' ', lineEnd); - if (split != std::string::npos && split > lineStart) { - lineEnd = split; - } - } - - output.append(input.substr(lineStart, lineEnd - lineStart)); - if (lineEnd < input.size()) { - output.push_back('\n'); - lineStart = lineEnd + (input[lineEnd] == ' ' ? 1 : 0); - } - else { - lineStart = lineEnd; - } - } - - return output; -} - -std::string DialogueOverlay::wrapTextToWidth(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale) +std::string DialogueOverlay::wrapTextToWidth( + const std::string& input, + const TextRenderer& textRenderer, + float maxWidthPx, + float scale) { - if (input.empty() || maxWidthPx <= 1.0f) { - return input; - } + if (input.empty() || maxWidthPx <= 1.0f) return input; - std::string output; - std::string currentLine; - std::string currentWord; + 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 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; - } + 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; + } + }; - 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(); - 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; + return output; } bool DialogueOverlay::rectContains(const UiRect& rect, float x, float y) { - return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; + return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; } -} // namespace ZL::Dialogue \ No newline at end of file +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h index 60e5e3f..01faa5a 100644 --- a/src/dialogue/DialogueOverlay.h +++ b/src/dialogue/DialogueOverlay.h @@ -4,6 +4,7 @@ #include "render/Renderer.h" #include "render/TextRenderer.h" #include "render/TextureManager.h" +#include "render/UiQuad.h" #include "UiManager.h" #include #include @@ -13,105 +14,41 @@ namespace ZL::Dialogue { class DialogueOverlay { public: - bool init(Renderer& renderer, const std::string& zipFile = ""); - void update(const PresentationModel& model, int deltaMs); - void draw(Renderer& renderer, const PresentationModel& model); + bool init(Renderer& renderer, const std::string& zipFile = ""); + void update(const PresentationModel& model, int deltaMs); + void draw(Renderer& renderer, const PresentationModel& model); - void handlePointerDown(float x, float y, const PresentationModel& model); - void handlePointerMoved(float x, float y, const PresentationModel& model); - bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue); - bool consumeSkipRequested(); + void handlePointerDown(float x, float y, const PresentationModel& model); + void handlePointerMoved(float x, float y, const PresentationModel& model); + bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue); private: - struct TexturedQuad { - UiRect rect; - VertexRenderStruct mesh; - bool initialized = false; + Renderer* rendererRef = nullptr; + std::string zipFilename; - void rebuild(const UiRect& newRect); - void rebuildWithUV( - const UiRect& newRect, - const Eigen::Vector2f& uvBottomLeft, - const Eigen::Vector2f& uvTopLeft, - const Eigen::Vector2f& uvTopRight, - const Eigen::Vector2f& uvBottomRight - ); - }; + std::shared_ptr textboxTexture; + std::shared_ptr choiceMainTexture; + std::shared_ptr choiceOptionalTexture; + std::shared_ptr choiceSelectedTexture; - struct ResolvedViewport { - float centerXPx = 0.0f; - float centerYPx = 0.0f; - float widthPx = 1.0f; - float heightPx = 1.0f; - float rotationDeg = 0.0f; - }; + mutable std::vector lastChoiceRects; + mutable UiRect lastDialogueAdvanceRect{}; - Renderer* rendererRef = nullptr; - std::string zipFilename; + int hoveredChoiceIndex = -1; - std::shared_ptr textboxTexture; - //std::shared_ptr portraitFrameTexture; - std::shared_ptr choiceMainTexture; - std::shared_ptr choiceOptionalTexture; - std::shared_ptr choiceSelectedTexture; - std::shared_ptr cutsceneSubtitleTexture; + std::unique_ptr nameRenderer; + std::unique_ptr bodyRenderer; + std::unique_ptr choiceRenderer; - mutable std::vector lastChoiceRects; - mutable UiRect lastDialogueAdvanceRect{}; - mutable UiRect lastCutsceneAdvanceRect{}; - mutable UiRect lastCutsceneSkipRect{}; + UiQuad portraitQuad; + UiQuad textboxQuad; + mutable std::vector choiceQuads; - int hoveredChoiceIndex = -1; + std::shared_ptr loadTextureCached(const std::string& path); - // Cutscene skip UX: - // First LMB/tap anywhere arms skip and shows a hint for 5 seconds. - // While armed, holding LMB/touch anywhere for 3.5 seconds requests skip. - 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 nameRenderer; - std::unique_ptr bodyRenderer; - std::unique_ptr choiceRenderer; - std::unique_ptr cutsceneRenderer; - - TexturedQuad portraitQuad; - TexturedQuad textboxQuad; - TexturedQuad subtitleQuad; - TexturedQuad backgroundQuad; - TexturedQuad skipHintBgQuad; - TexturedQuad skipProgressBgQuad; - TexturedQuad skipProgressFillQuad; - mutable std::vector choiceQuads; - - void drawDialogue(Renderer& renderer, const PresentationModel& model); - void drawCutscene(Renderer& renderer, const PresentationModel& model); - - std::shared_ptr loadTextureCached(const std::string& path); - void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& texture) const; - - static std::string wrapText(const std::string& input, size_t maxLineLength); - static std::string wrapTextToWidth(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale); - static bool rectContains(const UiRect& rect, float x, float y); - - 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); + static bool rectContains(const UiRect& rect, float x, float y); }; } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index 0d54af9..d51f180 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -1,7 +1,6 @@ #include "dialogue/DialogueRuntime.h" #include -#include #include namespace ZL::Dialogue { @@ -34,161 +33,58 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) { } activeDialogue = dialogue; - activeCutscene = nullptr; currentNodeId.clear(); - pendingNodeAfterCutscene.clear(); visibleChoices.clear(); selectedChoice = -1; revealCharacters = 0.0f; - currentCutsceneLine = -1; - cutsceneTimerMs = 0; - cutsceneElapsedMs = 0; - cutsceneTotalDurationMs = 0; - cutsceneContentDurationMs = 0; - currentCutsceneBackground.clear(); - fadeInCallbackFired = false; presentation = {}; presentation.dialogueId = dialogue->id; return enterNode(dialogue->startNode); } -bool DialogueRuntime::startStandaloneCutscene(const std::string& cutsceneId) { - if (!database) { - std::cerr << "[dialogue] No database assigned to runtime\n"; - return false; - } - const StaticCutsceneDefinition* def = database->findCutscene(cutsceneId); - if (!def) { - std::cerr << "[dialogue] Cutscene not found: " << cutsceneId << "\n"; - return false; - } +void DialogueRuntime::stop() { activeDialogue = nullptr; - activeCutsceneId = cutsceneId; - fadeInCallbackFired = false; + currentNodeId.clear(); + visibleChoices.clear(); + selectedChoice = -1; + revealCharacters = 0.0f; + mode = Mode::Inactive; presentation = {}; - startCutscene(cutsceneId, ""); - return true; } -void DialogueRuntime::setOnCutsceneFinished(std::function cb) { - onCutsceneFinished = std::move(cb); +void DialogueRuntime::resumeFromNode(const std::string& nodeId) { + if (mode != Mode::WaitingForCutscene) return; + if (nodeId.empty()) { + stop(); + return; + } + enterNode(nodeId); +} + +void DialogueRuntime::setOnCutsceneStartNeeded( + std::function cb) +{ + onCutsceneStartNeeded = std::move(cb); } void DialogueRuntime::setOnDialogueLineStarted(std::function cb) { onDialogueLineStarted = std::move(cb); } -void DialogueRuntime::setOnCutsceneLineStarted(std::function cb) { - onCutsceneLineStarted = std::move(cb); -} - -void DialogueRuntime::setOnCutsceneFadeInComplete(std::function cb) { - onCutsceneFadeInComplete = std::move(cb); -} - void DialogueRuntime::setOnChatBubbleReady(std::function cb) { onChatBubbleReady = std::move(cb); } -void DialogueRuntime::stop() { - activeDialogue = nullptr; - activeCutscene = nullptr; - currentNodeId.clear(); - pendingNodeAfterCutscene.clear(); - visibleChoices.clear(); - selectedChoice = -1; - revealCharacters = 0.0f; - currentCutsceneLine = -1; - cutsceneTimerMs = 0; - cutsceneElapsedMs = 0; - cutsceneTotalDurationMs = 0; - cutsceneContentDurationMs = 0; - currentCutsceneBackground.clear(); - fadeInCallbackFired = false; - mode = Mode::Inactive; - presentation = {}; -} - void DialogueRuntime::update(int deltaMs) { - if (mode == Mode::PresentingLine) { - if (!presentation.revealCompleted) { - revealCharacters += revealSpeedCharsPerSecond * (static_cast(deltaMs) / 1000.0f); - const size_t fullLen = presentation.fullText.size(); - const size_t visibleLen = static_cast(std::min(revealCharacters, static_cast(fullLen))); - presentation.visibleText = presentation.fullText.substr(0, visibleLen); - presentation.revealCompleted = (visibleLen >= fullLen); - } - return; - } + if (mode != Mode::PresentingLine) return; - if (mode == Mode::PlayingCutscene && activeCutscene) { - cutsceneElapsedMs += deltaMs; - - if (!activeCutscene->lines.empty() && - currentCutsceneLine >= 0 && - currentCutsceneLine < static_cast(activeCutscene->lines.size())) { - - const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; - if (!line.waitForConfirm) { - cutsceneTimerMs += deltaMs; - const int durationMs = - (line.durationMs > 0) - ? line.durationMs - : computeFallbackCutsceneDurationMs(line.text); - - if (cutsceneTimerMs >= durationMs) { - advanceCutsceneLine(); - - // ВАЖНО: после advance катсцена могла завершиться - if (!activeCutscene || mode != Mode::PlayingCutscene) { - return; - } - } - } - } - - if (!activeCutscene || mode != Mode::PlayingCutscene) { - return; - } - - refreshCutscenePresentation(); - - if (!activeCutscene || mode != Mode::PlayingCutscene) { - return; - } - - if (!fadeInCallbackFired && onCutsceneFadeInComplete && activeCutscene) { - const int fadeInCompleteMs = activeCutscene->fadeOutMs + activeCutscene->fadeInMs; - if (cutsceneElapsedMs >= fadeInCompleteMs && !activeCutscene->onFadeInCallback.empty()) { - fadeInCallbackFired = true; - onCutsceneFadeInComplete(activeCutscene->onFadeInCallback); - } - } - - if (!activeCutscene || mode != Mode::PlayingCutscene) { - return; - } - - const bool subtitlesFinished = - activeCutscene->lines.empty() || - currentCutsceneLine >= static_cast(activeCutscene->lines.size()); - - const bool durationFinished = - cutsceneTotalDurationMs > 0 && - cutsceneElapsedMs >= cutsceneTotalDurationMs; - - if (activeCutscene->lines.empty()) { - if (durationFinished) { - finishCutscene(); - } - return; - } - - if (subtitlesFinished && (cutsceneTotalDurationMs <= 0 || durationFinished)) { - finishCutscene(); - return; - } + if (!presentation.revealCompleted) { + revealCharacters += revealSpeedCharsPerSecond * (static_cast(deltaMs) / 1000.0f); + const size_t fullLen = presentation.fullText.size(); + const size_t visibleLen = static_cast(std::min(revealCharacters, static_cast(fullLen))); + presentation.visibleText = presentation.fullText.substr(0, visibleLen); + presentation.revealCompleted = (visibleLen >= fullLen); } } @@ -234,16 +130,10 @@ void DialogueRuntime::confirmAdvance() { enterNode(choice.next); return; } - - if (mode == Mode::PlayingCutscene) { - return; - } } void DialogueRuntime::moveSelection(int delta) { - if (mode != Mode::WaitingForChoice || visibleChoices.empty()) { - return; - } + if (mode != Mode::WaitingForChoice || visibleChoices.empty()) return; const int count = static_cast(visibleChoices.size()); if (selectedChoice < 0 || selectedChoice >= count) { @@ -251,61 +141,18 @@ void DialogueRuntime::moveSelection(int delta) { } else { selectedChoice = (selectedChoice + delta) % count; - if (selectedChoice < 0) { - selectedChoice += count; - } + if (selectedChoice < 0) selectedChoice += count; } presentation.selectedChoice = selectedChoice; } void DialogueRuntime::selectChoice(int index) { - if (mode != Mode::WaitingForChoice || visibleChoices.empty()) { - return; - } - if (index < 0 || index >= static_cast(visibleChoices.size())) { - return; - } + if (mode != Mode::WaitingForChoice || visibleChoices.empty()) return; + if (index < 0 || index >= static_cast(visibleChoices.size())) return; selectedChoice = index; presentation.selectedChoice = selectedChoice; } -bool DialogueRuntime::canSkipCurrentCutscene() const { - return mode == Mode::PlayingCutscene && activeCutscene && activeCutscene->skippable; -} - -void DialogueRuntime::skipCurrentCutscene() { - if (!canSkipCurrentCutscene()) { - return; - } - - // Multi-image cutscenes skip to the next image cue first. - // This matches the desired behavior: skip advances the visual chapter, - // not necessarily the whole cutscene immediately. - 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; - syncCutsceneLineToElapsedTime(); - refreshCutscenePresentation(); - return; - } - } - - finishCutscene(); -} - void DialogueRuntime::setFlag(const std::string& name, int value) { if (flagStore) (*flagStore)[name] = value; } @@ -369,9 +216,7 @@ bool DialogueRuntime::evaluateConditions(const std::vector& condition void DialogueRuntime::applyEffects(const std::vector& effects) { for (const Effect& effect : effects) { - if (effect.flag.empty()) { - continue; - } + if (effect.flag.empty()) continue; if (effect.relative) { setFlag(effect.flag, getFlag(effect.flag) + effect.value); } @@ -435,8 +280,10 @@ bool DialogueRuntime::enterNode(const std::string& nodeId) { return true; case NodeType::CutsceneStart: - activeCutsceneId = node.cutsceneId; - startCutscene(node.cutsceneId, node.next); + // Pause dialogue and hand off to the cutscene system via DialogueSystem. + mode = Mode::WaitingForCutscene; + if (onCutsceneStartNeeded) + onCutsceneStartNeeded(node.cutsceneId, node.next); return true; } @@ -483,12 +330,8 @@ void DialogueRuntime::presentChoices(const Node& node) { presentation.choices.clear(); for (const Choice& choice : node.choices) { - if (!choice.id.empty() && consumedChoices.count(choice.id) > 0) { - continue; - } - if (!evaluateConditions(choice.conditions)) { - continue; - } + if (!choice.id.empty() && consumedChoices.count(choice.id) > 0) continue; + if (!evaluateConditions(choice.conditions)) continue; visibleChoices.push_back(choice); presentation.choices.push_back({ choice.id, choice.text, choice.kind }); } @@ -521,450 +364,4 @@ void DialogueRuntime::presentChoices(const Node& node) { presentation.cutsceneBlackAlpha = 0.0f; } -void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { - if (!database) { - stop(); - return; - } - - const StaticCutsceneDefinition* cutscene = database->findCutscene(cutsceneId); - if (!cutscene) { - std::cerr << "[dialogue] Cutscene not found: " << cutsceneId << "\n"; - if (!nextNodeAfterCutscene.empty()) { - enterNode(nextNodeAfterCutscene); - } - else { - stop(); - } - return; - } - - activeCutscene = cutscene; - pendingNodeAfterCutscene = nextNodeAfterCutscene; - currentCutsceneBackground = cutscene->background; - mode = Mode::PlayingCutscene; - cutsceneElapsedMs = 0; - cutsceneTimerMs = 0; - fadeInCallbackFired = false; - currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0; - int imageTrackDurationMs = 0; - for (size_t i = 0; i < activeCutscene->images.size(); ++i) { - const CutsceneImageCue& cue = activeCutscene->images[i]; - int cueEnd = cue.endMs; - if (cueEnd <= cue.startMs) { - if (i + 1 < activeCutscene->images.size()) { - cueEnd = std::max(activeCutscene->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({ activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene), imageTrackDurationMs }); - if (cutsceneContentDurationMs <= 0 && activeCutscene->lines.empty()) { - cutsceneContentDurationMs = 3000; - } - cutsceneTotalDurationMs = cutsceneContentDurationMs + activeCutscene->endFadeOutMs + activeCutscene->endFadeInMs; - refreshCutscenePresentation(); - - if (!activeCutscene->lines.empty()) { - const CutsceneLine& firstLine = activeCutscene->lines[0]; - applyQuestActions(firstLine.questUnlock, firstLine.questComplete, - firstLine.questFail, firstLine.objectiveComplete, firstLine.objectiveVisible); - if (onCutsceneLineStarted && !firstLine.luaCallback.empty()) - onCutsceneLineStarted(firstLine.luaCallback); - } - - std::cout << "[CUTSCENE] start id=" << cutsceneId - << " lines=" << activeCutscene->lines.size() - << " totalDuration=" << cutsceneTotalDurationMs - << std::endl; -} - -void DialogueRuntime::finishCutscene() { - - std::cout << "[CUTSCENE] finish nextNode=" << pendingNodeAfterCutscene << std::endl; - const std::string finishedId = activeCutsceneId; - activeCutsceneId.clear(); - activeCutscene = nullptr; - currentCutsceneLine = -1; - cutsceneTimerMs = 0; - cutsceneElapsedMs = 0; - cutsceneTotalDurationMs = 0; - cutsceneContentDurationMs = 0; - currentCutsceneBackground.clear(); - if (onCutsceneFinished && !finishedId.empty()) - onCutsceneFinished(finishedId); - - if (!pendingNodeAfterCutscene.empty()) { - const std::string nextNode = pendingNodeAfterCutscene; - pendingNodeAfterCutscene.clear(); - enterNode(nextNode); - } - else { - stop(); - } -} - -void DialogueRuntime::syncCutsceneLineToElapsedTime() { - 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 - : computeFallbackCutsceneDurationMs(line.text); - - if (elapsed < accumulatedMs + durationMs) { - currentCutsceneLine = static_cast(i); - cutsceneTimerMs = std::max(0, elapsed - accumulatedMs); - return; - } - - accumulatedMs += durationMs; - } - - currentCutsceneLine = -1; - cutsceneTimerMs = 0; -} - -void DialogueRuntime::advanceCutsceneLine() { - 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(activeCutscene->lines.size())) { - refreshCutscenePresentation(); - if (cutsceneContentDurationMs <= 0 || cutsceneElapsedMs >= cutsceneContentDurationMs) { - // Only finish immediately if there's no end transition to play - if (activeCutscene->endFadeOutMs <= 0 && activeCutscene->endFadeInMs <= 0) { - finishCutscene(); - } - } - return; - } - - const CutsceneLine& newLine = activeCutscene->lines[currentCutsceneLine]; - applyQuestActions(newLine.questUnlock, newLine.questComplete, - newLine.questFail, newLine.objectiveComplete, newLine.objectiveVisible); - if (onCutsceneLineStarted && !newLine.luaCallback.empty()) - onCutsceneLineStarted(newLine.luaCallback); - - refreshCutscenePresentation(); -} - -CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() 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(elapsed) / static_cast(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 DialogueRuntime::evaluateCutsceneImages() const { - std::vector 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(now - startMs) / static_cast(cue.fadeInMs), - 0.0f, - 1.0f - ); - } - - if (alpha > 0.0f) { - result.push_back({ cue.path, alpha }); - } - } - - // Safety fallback: never leave the cutscene without an opaque image layer. - if (result.empty() && !fallbackPath.empty()) { - result.push_back({ fallbackPath, 1.0f }); - } - - // If the first active layer is still fading in, put an opaque fallback/base below it. - // This prevents the world from becoming visible behind the cutscene. - if (!result.empty() && result.front().alpha < 0.999f && !fallbackPath.empty() && result.front().path != fallbackPath) { - result.insert(result.begin(), { fallbackPath, 1.0f }); - } - - return result; -} - -void DialogueRuntime::refreshCutscenePresentation() { - if (!activeCutscene) { - return; - } - - presentation.mode = PresentationMode::Cutscene; - presentation.backgroundPath = activeCutscene->background; - presentation.backgroundWidth = activeCutscene->backgroundWidth; - presentation.backgroundHeight = activeCutscene->backgroundHeight; - presentation.cutsceneCamera = evaluateCutsceneCameraBlend(); - presentation.cutsceneImages = evaluateCutsceneImages(); - 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) { - // Start phase 1: game world fading to black - presentation.cutsceneGlobalFadeAlpha = 0.0f; - presentation.cutsceneBlackAlpha = std::clamp( - static_cast(cutsceneElapsedMs) / static_cast(fadeOutMs), - 0.0f, 1.0f - ); - } else if (cutsceneElapsedMs < endFadeOutStart) { - // Content playing (also covers start phase 2 fade-in) - presentation.cutsceneGlobalFadeAlpha = 1.0f; - const int phase2elapsed = cutsceneElapsedMs - fadeOutMs; - presentation.cutsceneBlackAlpha = (fadeInMs > 0) - ? std::clamp(1.0f - static_cast(phase2elapsed) / static_cast(fadeInMs), 0.0f, 1.0f) - : 0.0f; - } else if (cutsceneElapsedMs < endFadeInStart) { - // End phase 1: cutscene fading to black - presentation.cutsceneGlobalFadeAlpha = 1.0f; - const int elapsed = cutsceneElapsedMs - endFadeOutStart; - presentation.cutsceneBlackAlpha = (endFadeOutMs > 0) - ? std::clamp(static_cast(elapsed) / static_cast(endFadeOutMs), 0.0f, 1.0f) - : 1.0f; - } else { - // End phase 2: game world appearing from black (image hidden, world shows through) - presentation.cutsceneGlobalFadeAlpha = 0.0f; - const int elapsed = cutsceneElapsedMs - endFadeInStart; - presentation.cutsceneBlackAlpha = (endFadeInMs > 0) - ? std::clamp(1.0f - static_cast(elapsed) / static_cast(endFadeInMs), 0.0f, 1.0f) - : 0.0f; - } - - presentation.choices.clear(); - presentation.selectedChoice = -1; - presentation.revealCompleted = true; - - const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(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.mode = PresentationMode::Cutscene; - presentation.speaker = line.speaker; - presentation.fullText = line.text; - presentation.visibleText = line.text; - presentation.portraitPath = line.portrait; - - //presentation.backgroundPath = activeCutscene->background; - presentation.backgroundPath = currentCutsceneBackground; - presentation.choices.clear(); - presentation.selectedChoice = 0; - presentation.revealCompleted = true; - - - std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size() - << " current=" << currentCutsceneLine - << std::endl; -} - -float DialogueRuntime::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 DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { - const int cps = 17; - const int minDuration = 1500; - const int linger = 450; - const int calculated = static_cast((1000.0 * static_cast(std::max(text.size(), 1))) / cps); - return std::max(minDuration, calculated + linger); -} - -int DialogueRuntime::computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene) { - int total = 0; - for (const CutsceneCameraSegment& segment : cutscene.cameraTrack) { - total += std::max(segment.durationMs, 0); - } - return total; -} -/* -DialogueRuntime::json DialogueRuntime::buildSaveState() const { - json result; - result["active"] = isActive(); - result["dialogueId"] = activeDialogue ? activeDialogue->id : ""; - result["currentNodeId"] = currentNodeId; - result["pendingNodeAfterCutscene"] = pendingNodeAfterCutscene; - result["selectedChoice"] = selectedChoice; - result["currentCutsceneLine"] = currentCutsceneLine; - result["cutsceneTimerMs"] = cutsceneTimerMs; - result["consumedChoices"] = consumedChoices; - return result; -} - -bool DialogueRuntime::restoreSaveState(const json& state) { - if (!database) { - return false; - } - - flags.clear(); - consumedChoices.clear(); - -if (state.contains("consumedChoices")) { - consumedChoices = state["consumedChoices"].get>(); - } - - const bool active = state.value("active", false); - if (!active) { - stop(); - return true; - } - - const std::string dialogueId = state.value("dialogueId", ""); - if (!startDialogue(dialogueId)) { - return false; - } - - const std::string nodeId = state.value("currentNodeId", ""); - pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", ""); - selectedChoice = state.value("selectedChoice", -1); - currentCutsceneLine = state.value("currentCutsceneLine", -1); - cutsceneTimerMs = state.value("cutsceneTimerMs", 0); - - const bool ok = nodeId.empty() ? true : enterNode(nodeId); - if (mode == Mode::WaitingForChoice && !visibleChoices.empty()) { - if (selectedChoice >= 0) { - selectedChoice = std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1); - } - presentation.selectedChoice = selectedChoice; - } - return ok; -} -*/ } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index b24388a..00dcdb7 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -2,7 +2,6 @@ #include "dialogue/DialogueDatabase.h" #include "quest/QuestJournal.h" -#include "external/nlohmann/json.hpp" #include #include #include @@ -13,70 +12,54 @@ namespace ZL::Dialogue { class DialogueRuntime { public: - using json = nlohmann::json; - void setDatabase(const DialogueDatabase* value); bool startDialogue(const std::string& dialogueId); - bool startStandaloneCutscene(const std::string& cutsceneId); - void setOnCutsceneFinished(std::function cb); - void setOnDialogueLineStarted(std::function cb); - void setOnCutsceneLineStarted(std::function cb); - void setOnCutsceneFadeInComplete(std::function cb); - void setOnChatBubbleReady(std::function cb); void stop(); + // Called by DialogueSystem after the inline cutscene finishes. + void resumeFromNode(const std::string& nodeId); + + void setOnCutsceneStartNeeded(std::function cb); + void setOnDialogueLineStarted(std::function cb); + void setOnChatBubbleReady(std::function cb); + void update(int deltaMs); bool isActive() const { return mode != Mode::Inactive; } bool isInChoice() const { return mode == Mode::WaitingForChoice; } - bool isPlayingCutscene() const { return mode == Mode::PlayingCutscene; } void confirmAdvance(); void moveSelection(int delta); void selectChoice(int index); - bool canSkipCurrentCutscene() const; - void skipCurrentCutscene(); const PresentationModel& getPresentation() const { return presentation; } void setFlag(const std::string& name, int value); int getFlag(const std::string& name) const; - void setGlobalFlagStore(std::unordered_map* store); - void setQuestJournal(Quest::QuestJournal* journal); - //json buildSaveState() const; - //bool restoreSaveState(const json& state); - private: enum class Mode { Inactive, PresentingLine, WaitingForChoice, - PlayingCutscene + WaitingForCutscene // paused while an inline cutscene plays }; - std::function onCutsceneFinished; + std::function onCutsceneStartNeeded; std::function onDialogueLineStarted; - std::function onCutsceneLineStarted; - std::function onCutsceneFadeInComplete; std::function onChatBubbleReady; - std::string activeCutsceneId; - bool fadeInCallbackFired = false; const DialogueDatabase* database = nullptr; Quest::QuestJournal* questJournal = nullptr; const DialogueDefinition* activeDialogue = nullptr; - const StaticCutsceneDefinition* activeCutscene = nullptr; std::unordered_map* flagStore = nullptr; std::unordered_set consumedChoices; std::string currentNodeId; - std::string pendingNodeAfterCutscene; - std::vector visibleChoices; PresentationModel presentation; Mode mode = Mode::Inactive; @@ -84,14 +67,6 @@ private: int selectedChoice = -1; float revealCharacters = 0.0f; float revealSpeedCharsPerSecond = 52.0f; - - int currentCutsceneLine = -1; - int cutsceneTimerMs = 0; - int cutsceneElapsedMs = 0; - int cutsceneTotalDurationMs = 0; - int cutsceneContentDurationMs = 0; - - std::string currentCutsceneBackground; bool evaluateConditions(const std::vector& conditions) const; void applyEffects(const std::vector& effects); @@ -102,18 +77,6 @@ private: bool enterNode(const std::string& nodeId); void presentLine(const Node& node); void presentChoices(const Node& node); - void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); - void finishCutscene(); - void syncCutsceneLineToElapsedTime(); - - void advanceCutsceneLine(); - void refreshCutscenePresentation(); - CutsceneCameraBlendState evaluateCutsceneCameraBlend() const; - std::vector evaluateCutsceneImages() const; - - static float applyEasing(EasingType easing, float t); - static int computeFallbackCutsceneDurationMs(const std::string& text); - static int computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene); }; } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp index e9988d6..0be9a59 100644 --- a/src/dialogue/DialogueSystem.cpp +++ b/src/dialogue/DialogueSystem.cpp @@ -3,36 +3,57 @@ namespace ZL::Dialogue { bool DialogueSystem::init(Renderer& renderer, const std::string& zipFile) { - runtime.setDatabase(&database); - runtime.setOnCutsceneFinished([this](const std::string& id) { - if (onCutsceneFinishedCallback) onCutsceneFinishedCallback(id); - if (onCutsceneFinishedExtraCallback) onCutsceneFinishedExtraCallback(id); + dialogueRuntime.setDatabase(&database); + cutsceneRuntime.setDatabase(&cutsceneDatabase); + + // When dialogue hits a CutsceneStart node, hand off to cutsceneRuntime. + dialogueRuntime.setOnCutsceneStartNeeded([this](const std::string& cutsceneId, const std::string& nextNodeId) { + pendingNodeAfterCutscene = nextNodeId; + if (onCutsceneStartedCallback) onCutsceneStartedCallback(); + cutsceneRuntime.start(cutsceneId); }); - return overlay.init(renderer, zipFile); + + cutsceneRuntime.setOnFinished([this](const std::string& id) { + onCutsceneFinishedInternal(id); + }); + + return + dialogueOverlay.init(renderer, zipFile) && + cutsceneOverlay.init(renderer, zipFile); } bool DialogueSystem::loadDatabase(const std::string& path) { return database.loadFromFile(path); } +bool DialogueSystem::loadCutsceneDatabase(const std::string& path) { + return cutsceneDatabase.loadFromFile(path); +} + void DialogueSystem::update(int deltaMs) { - runtime.update(deltaMs); - overlay.update(runtime.getPresentation(), deltaMs); - if (overlay.consumeSkipRequested()) { - runtime.skipCurrentCutscene(); + dialogueRuntime.update(deltaMs); + cutsceneRuntime.update(deltaMs); + dialogueOverlay.update(dialogueRuntime.getPresentation(), deltaMs); + cutsceneOverlay.update(cutsceneRuntime.getPresentation(), deltaMs); + + if (cutsceneOverlay.consumeSkipRequested()) { + cutsceneRuntime.skip(); } } void DialogueSystem::draw(Renderer& renderer) { - overlay.draw(renderer, runtime.getPresentation()); + if (cutsceneRuntime.isActive()) { + cutsceneOverlay.draw(renderer, cutsceneRuntime.getPresentation()); + } + else { + dialogueOverlay.draw(renderer, dialogueRuntime.getPresentation()); + } } bool DialogueSystem::handleKeyDown(SDL_Keycode key) { - if (!runtime.isActive()) { - return false; - } + if (!isActive()) return false; - if (runtime.isPlayingCutscene()) { + if (cutsceneRuntime.isActive()) { switch (key) { case SDLK_RETURN: case SDLK_SPACE: @@ -45,93 +66,117 @@ bool DialogueSystem::handleKeyDown(SDL_Keycode key) { } switch (key) { - case SDLK_RETURN: - case SDLK_SPACE: - case SDLK_e: - runtime.confirmAdvance(); - return true; + case SDLK_RETURN: + case SDLK_SPACE: + case SDLK_e: + dialogueRuntime.confirmAdvance(); + return true; - case SDLK_UP: - case SDLK_w: - runtime.moveSelection(-1); - return true; + case SDLK_UP: + case SDLK_w: + dialogueRuntime.moveSelection(-1); + return true; - case SDLK_DOWN: - case SDLK_s: - runtime.moveSelection(1); - return true; + case SDLK_DOWN: + case SDLK_s: + dialogueRuntime.moveSelection(1); + return true; - case SDLK_ESCAPE: - stopDialogue(); - return true; + case SDLK_ESCAPE: + stopDialogue(); + return true; - default: - return false; + default: + return false; } } void DialogueSystem::handlePointerDown(float x, float y) { - if (!runtime.isActive()) { - return; + if (!isActive()) return; + + if (cutsceneRuntime.isActive()) { + cutsceneOverlay.handlePointerDown(x, y, cutsceneRuntime.getPresentation()); + } + else { + dialogueOverlay.handlePointerDown(x, y, dialogueRuntime.getPresentation()); } - overlay.handlePointerDown(x, y, runtime.getPresentation()); } void DialogueSystem::handlePointerMoved(float x, float y) { - if (!runtime.isActive()) { - return; + if (!isActive()) return; + + if (cutsceneRuntime.isActive()) { + cutsceneOverlay.handlePointerMoved(x, y, cutsceneRuntime.getPresentation()); + } + else { + dialogueOverlay.handlePointerMoved(x, y, dialogueRuntime.getPresentation()); } - overlay.handlePointerMoved(x, y, runtime.getPresentation()); } bool DialogueSystem::handlePointerReleased(float x, float y) { - if (!runtime.isActive()) { - return false; + if (!isActive()) return false; + + if (cutsceneRuntime.isActive()) { + if (cutsceneOverlay.consumeSkipRequested()) { + cutsceneRuntime.skip(); + return true; + } + return cutsceneOverlay.handlePointerReleased(x, y, cutsceneRuntime.getPresentation()); } int choiceIndex = -1; bool advanceDialogue = false; - const PresentationModel& model = runtime.getPresentation(); - if (!overlay.handlePointerReleased(x, y, model, choiceIndex, advanceDialogue)) { - if (overlay.consumeSkipRequested()) { - runtime.skipCurrentCutscene(); - return true; - } - return runtime.isPlayingCutscene(); + if (!dialogueOverlay.handlePointerReleased(x, y, dialogueRuntime.getPresentation(), choiceIndex, advanceDialogue)) { + return false; } if (choiceIndex >= 0) { - runtime.selectChoice(choiceIndex); - runtime.confirmAdvance(); + dialogueRuntime.selectChoice(choiceIndex); + dialogueRuntime.confirmAdvance(); return true; } if (advanceDialogue) { - runtime.confirmAdvance(); + dialogueRuntime.confirmAdvance(); if (onDialogueAdvancedCallback) onDialogueAdvancedCallback(); return true; } - if (overlay.consumeSkipRequested()) { - runtime.skipCurrentCutscene(); - return true; - } - return true; } bool DialogueSystem::startDialogue(const std::string& dialogueId) { - return runtime.startDialogue(dialogueId); + return dialogueRuntime.startDialogue(dialogueId); } bool DialogueSystem::startCutscene(const std::string& cutsceneId) { - bool result = runtime.startStandaloneCutscene(cutsceneId); + pendingNodeAfterCutscene.clear(); + const bool result = cutsceneRuntime.start(cutsceneId); if (result && onCutsceneStartedCallback) onCutsceneStartedCallback(); return result; } void DialogueSystem::skipCutscene() { - runtime.skipCurrentCutscene(); + cutsceneRuntime.skip(); +} + +void DialogueSystem::stopDialogue() { + dialogueRuntime.stop(); +} + +void DialogueSystem::onCutsceneFinishedInternal(const std::string& id) { + if (onCutsceneFinishedCallback) onCutsceneFinishedCallback(id); + if (onCutsceneFinishedExtraCallback) onCutsceneFinishedExtraCallback(id); + + // Resume dialogue if this cutscene was triggered by a CutsceneStart node. + if (!pendingNodeAfterCutscene.empty()) { + const std::string nextNode = pendingNodeAfterCutscene; + pendingNodeAfterCutscene.clear(); + dialogueRuntime.resumeFromNode(nextNode); + } + else { + dialogueRuntime.stop(); + } } void DialogueSystem::setOnCutsceneStarted(std::function cb) { @@ -147,27 +192,23 @@ void DialogueSystem::setOnCutsceneFinishedExtra(std::function cb) { - runtime.setOnDialogueLineStarted(std::move(cb)); + dialogueRuntime.setOnDialogueLineStarted(std::move(cb)); } void DialogueSystem::setOnCutsceneLineStarted(std::function cb) { - runtime.setOnCutsceneLineStarted(std::move(cb)); + cutsceneRuntime.setOnLineStarted(std::move(cb)); } void DialogueSystem::setOnCutsceneFadeInComplete(std::function cb) { - runtime.setOnCutsceneFadeInComplete(std::move(cb)); + cutsceneRuntime.setOnFadeInComplete(std::move(cb)); } void DialogueSystem::setOnChatBubbleReady(std::function cb) { - runtime.setOnChatBubbleReady(std::move(cb)); -} - -void DialogueSystem::stopDialogue() { - runtime.stop(); + dialogueRuntime.setOnChatBubbleReady(std::move(cb)); } void DialogueSystem::setOnDialogueAdvanced(std::function cb) { onDialogueAdvancedCallback = std::move(cb); } -} // namespace ZL::Dialogue \ No newline at end of file +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueSystem.h b/src/dialogue/DialogueSystem.h index 0147d4d..61fb36f 100644 --- a/src/dialogue/DialogueSystem.h +++ b/src/dialogue/DialogueSystem.h @@ -2,6 +2,9 @@ #include "dialogue/DialogueOverlay.h" #include "dialogue/DialogueRuntime.h" +#include "cutscene/CutsceneDatabase.h" +#include "cutscene/CutsceneOverlay.h" +#include "cutscene/CutsceneRuntime.h" #include "quest/QuestJournal.h" #include #include @@ -12,7 +15,9 @@ namespace ZL::Dialogue { class DialogueSystem { public: bool init(Renderer& renderer, const std::string& zipFile = ""); + bool loadDatabase(const std::string& path); + bool loadCutsceneDatabase(const std::string& path); void update(int deltaMs); void draw(Renderer& renderer); @@ -25,6 +30,7 @@ public: bool startDialogue(const std::string& dialogueId); bool startCutscene(const std::string& cutsceneId); void skipCutscene(); + void setOnCutsceneStarted(std::function cb); void setOnCutsceneFinished(std::function cb); void setOnCutsceneFinishedExtra(std::function cb); @@ -35,24 +41,38 @@ public: void setOnDialogueAdvanced(std::function cb); void stopDialogue(); - bool isActive() const { return runtime.isActive(); } - bool blocksGameplayInput() const { return runtime.isActive(); } + bool isActive() const { return dialogueRuntime.isActive() || cutsceneRuntime.isActive(); } + bool blocksGameplayInput() const { return isActive(); } - void setFlag(const std::string& name, int value) { runtime.setFlag(name, value); } - int getFlag(const std::string& name) const { return runtime.getFlag(name); } + void setFlag(const std::string& name, int value) { dialogueRuntime.setFlag(name, value); } + int getFlag(const std::string& name) const { return dialogueRuntime.getFlag(name); } - void setGlobalFlagStore(std::unordered_map* store) { runtime.setGlobalFlagStore(store); } - - void setQuestJournal(Quest::QuestJournal* journal) { runtime.setQuestJournal(journal); } + void setGlobalFlagStore(std::unordered_map* store) { + dialogueRuntime.setGlobalFlagStore(store); + } + void setQuestJournal(Quest::QuestJournal* journal) { + dialogueRuntime.setQuestJournal(journal); + cutsceneRuntime.setQuestJournal(journal); + } private: DialogueDatabase database; - DialogueRuntime runtime; - DialogueOverlay overlay; + DialogueRuntime dialogueRuntime; + DialogueOverlay dialogueOverlay; + + ZL::Cutscene::CutsceneDatabase cutsceneDatabase; + ZL::Cutscene::CutsceneRuntime cutsceneRuntime; + ZL::Cutscene::CutsceneOverlay cutsceneOverlay; + std::function onDialogueAdvancedCallback; std::function onCutsceneStartedCallback; std::function onCutsceneFinishedCallback; std::function onCutsceneFinishedExtraCallback; + + // Pending node to resume after an inline cutscene finishes. + std::string pendingNodeAfterCutscene; + + void onCutsceneFinishedInternal(const std::string& id); }; } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index d1adaf1..ce5da6f 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -1,5 +1,6 @@ #pragma once +#include "cutscene/CutsceneTypes.h" #include #include #include @@ -31,28 +32,6 @@ enum class ComparisonOp { LessOrEqual }; -enum class EasingType { - Linear, - EaseInSine, - EaseOutSine, - EaseInOutSine, - EaseInQuad, - EaseOutQuad, - EaseInOutQuad, - EaseInCubic, - EaseOutCubic, - EaseInOutCubic -}; - -enum class CutsceneAnchor { - Center, - TopLeft, - TopRight, - BottomRight, - BottomLeft, - Custom -}; - struct Condition { std::string flag; ComparisonOp op = ComparisonOp::Exists; @@ -116,84 +95,12 @@ struct DialogueDefinition { std::unordered_map nodes; }; -struct CutsceneLine { - std::string speaker; - std::string text; - std::string portrait; - std::string sfx; - std::string background; - std::string luaCallback; - int backgroundWidth = 0; // 0 = inherit from cutscene - int backgroundHeight = 0; // 0 = inherit from cutscene - int durationMs = 0; - bool waitForConfirm = false; - - // Quest actions fired when this line is shown (empty = no action) - 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; - - // Используется только для Custom. - // Нормализованные координаты 0..1, где: - // centerX: 0 = левый край, 1 = правый край - // centerY: 0 = верхний край, 1 = нижний край - 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 cameraTrack; - std::vector images; - std::vector lines; -}; - struct PresentedChoice { std::string id; std::string text; ChoiceKind kind = ChoiceKind::Main; }; -struct PresentedCutsceneImage { - std::string path; - float alpha = 1.0f; -}; - enum class PresentationMode { Hidden, Dialogue, @@ -201,13 +108,6 @@ enum class PresentationMode { Cutscene }; -struct CutsceneCameraBlendState { - bool active = false; - CutsceneCameraPose from; - CutsceneCameraPose to; - float t = 1.0f; -}; - struct PresentationModel { PresentationMode mode = PresentationMode::Hidden; std::string dialogueId; @@ -222,24 +122,12 @@ struct PresentationModel { bool showCutsceneSubtitle = false; bool cutsceneSkippable = false; - CutsceneCameraBlendState cutsceneCamera; - std::vector cutsceneImages; + ZL::Cutscene::CutsceneCameraBlendState cutsceneCamera; + std::vector cutsceneImages; float cutsceneGlobalFadeAlpha = 1.0f; float cutsceneBlackAlpha = 0.0f; int backgroundWidth = 1280; int backgroundHeight = 720; }; -struct SaveState { - std::string dialogueId; - std::string currentNodeId; - std::string pendingNodeAfterCutscene; - std::unordered_map flags; - std::unordered_set consumedChoices; - int selectedChoice = -1; - int currentCutsceneLine = -1; - int cutsceneTimerMs = 0; - bool active = false; -}; - } // namespace ZL::Dialogue diff --git a/src/render/UiQuad.h b/src/render/UiQuad.h new file mode 100644 index 0000000..f4ad2ca --- /dev/null +++ b/src/render/UiQuad.h @@ -0,0 +1,65 @@ +#pragma once + +#include "render/Renderer.h" +#include "UiManager.h" +#include + +namespace ZL +{ + // Axis-aligned textured quad with cached mesh. Rebuild only when rect changes. + struct UiQuad { + UiRect rect{}; + VertexRenderStruct mesh; + bool initialized = false; + + void rebuild(const UiRect& newRect) { + rect = newRect; + mesh.data = CreateRect2D( + { rect.x + rect.w * 0.5f, rect.y + rect.h * 0.5f }, + { rect.w * 0.5f, rect.h * 0.5f }, + 0.0f + ); + mesh.RefreshVBO(); + initialized = true; + } + + void rebuildWithUV( + const UiRect& newRect, + const Eigen::Vector2f& uvBottomLeft, + const Eigen::Vector2f& uvTopLeft, + const Eigen::Vector2f& uvTopRight, + const Eigen::Vector2f& uvBottomRight + ) { + rect = newRect; + + const float x0 = rect.x; + const float y0 = rect.y; + const float x1 = rect.x + rect.w; + const float y1 = rect.y + rect.h; + + VertexDataStruct data; + data.PositionData = { + { x0, y0, 0.0f }, + { x0, y1, 0.0f }, + { x1, y1, 0.0f }, + + { x1, y1, 0.0f }, + { x1, y0, 0.0f }, + { x0, y0, 0.0f } + }; + data.TexCoordData = { + uvBottomLeft, + uvTopLeft, + uvTopRight, + + uvTopRight, + uvBottomRight, + uvBottomLeft + }; + + mesh.AssignFrom(data); + initialized = true; + } + }; + +} \ No newline at end of file