From ceebb13719d56cbd503d8b6b3c72a0a9f88364f1 Mon Sep 17 00:00:00 2001 From: vottozi Date: Wed, 15 Apr 2026 20:42:41 +0600 Subject: [PATCH] improve dialogue advance and add flexible cutscene camera system --- resources/dialogue/sample_dialogues.json | 144 ++++++++++++++- src/Game.cpp | 12 +- src/dialogue/DialogueDatabase.cpp | 45 +++++ src/dialogue/DialogueDatabase.h | 5 +- src/dialogue/DialogueOverlay.cpp | 95 +++++++--- src/dialogue/DialogueOverlay.h | 4 + src/dialogue/DialogueRuntime.cpp | 220 ++++++++++++++++++++--- src/dialogue/DialogueRuntime.h | 8 +- src/dialogue/DialogueSystem.cpp | 10 +- src/dialogue/DialogueTypes.h | 33 +++- 10 files changed, 502 insertions(+), 74 deletions(-) diff --git a/resources/dialogue/sample_dialogues.json b/resources/dialogue/sample_dialogues.json index 527b7eb..2f6cf3e 100644 --- a/resources/dialogue/sample_dialogues.json +++ b/resources/dialogue/sample_dialogues.json @@ -16,7 +16,7 @@ "id": "line_2", "type": "Line", "speaker": "Hero", - "portrait": "", + "portrait": "resources/hero.png", "text": "I need answers.", "next": "end_1" }, @@ -26,7 +26,6 @@ } ] }, - { "id": "test_choice_dialogue", "start": "line_1", @@ -43,7 +42,7 @@ "id": "choice_1", "type": "Choice", "speaker": "Hero", - "portrait": "", + "portrait": "resources/hero.png", "text": "Choose your answer.", "choices": [ { @@ -74,7 +73,7 @@ "speaker": "Merchant", "portrait": "resources/ghost_avatar.png", "text": "Just a trader passing through.", - "next": "end_1" + "next": "choice_1" }, { "id": "end_1", @@ -82,7 +81,6 @@ } ] }, - { "id": "test_condition_dialogue", "start": "set_flag_1", @@ -126,7 +124,6 @@ } ] }, - { "id": "test_cutscene_dialogue", "start": "cutscene_start", @@ -142,17 +139,69 @@ "type": "End" } ] + }, + { + "id": "test_silent_cutscene_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_silent_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_pan_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_pan_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] } ], - "cutscenes": [ { "id": "test_cutscene_01", "background": "resources/first_cutscene.png", + "durationMs": 6800, + "cameraTrack": [ + { + "durationMs": 2400, + "from": { "focusX": 0.50, "focusY": 0.55, "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 2200, + "from": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 }, + "to": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 }, + "easing": "EaseInOutCubic" + }, + { + "durationMs": 2200, + "from": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 }, + "to": { "focusX": 0.58, "focusY": 0.46, "zoom": 1.10, "rotationDeg": -0.6 }, + "easing": "EaseOutSine" + } + ], "lines": [ { "speaker": "Narrator", - "portrait": "", + "portrait": "resources/hero.png", "text": "The air in the room turned cold.", "durationMs": 2200 }, @@ -163,6 +212,83 @@ "durationMs": 2600 } ] + }, + { + "id": "test_cutscene_silent_01", + "background": "resources/first_cutscene.png", + "durationMs": 5200, + "cameraTrack": [ + { + "durationMs": 2600, + "from": { "focusX": 0.40, "focusY": 0.54, "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 2600, + "from": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 }, + "to": { "focusX": 0.72, "focusY": 0.48, "zoom": 1.34, "rotationDeg": -0.5 }, + "easing": "EaseOutCubic" + } + ], + "lines": [] + }, + { + "id": "test_cutscene_pan_01", + "background": "resources/first_cutscene.png", + "durationMs": 12000, + "cameraTrack": [ + { + "durationMs": 1200, + "from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, + "easing": "Linear" + }, + { + "durationMs": 2500, + "from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 2600, + "from": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 1800, + "from": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 }, + "to": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 3900, + "from": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 }, + "to": { "focusX": 0.16, "focusY": 0.84, "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "The memory begins in silence.", + "durationMs": 2200 + }, + { + "speaker": "Narrator", + "portrait": "", + "text": "Something is drawing your eyes across the whole scene.", + "durationMs": 2800 + }, + { + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "Do not look away.", + "durationMs": 2400 + } + ] } ] -} \ No newline at end of file +} diff --git a/src/Game.cpp b/src/Game.cpp index 3876585..bf682d1 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -516,11 +516,11 @@ namespace ZL targetInteractiveObject = nullptr; } - //for (auto& npc : npcs) npc->update(delta); - if (player) { - dialogueSystem.update(static_cast(delta), player->position); - } + } + + if (player) { + dialogueSystem.update(static_cast(delta), player->position); } } } @@ -732,11 +732,11 @@ namespace ZL if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { switch (event.key.keysym.sym) { case SDLK_f: - dialogueSystem.startDialogue("test_line_dialogue"); + dialogueSystem.startDialogue("test_choice_dialogue"); break; case SDLK_e: - dialogueSystem.startDialogue("test_cutscene_dialogue"); + dialogueSystem.startDialogue("test_cutscene_pan_dialogue"); break; case SDLK_RETURN: diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index 77177a3..a120f66 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -29,6 +29,19 @@ ComparisonOp DialogueDatabase::parseComparisonOp(const std::string& value) { 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; +} + Condition DialogueDatabase::parseCondition(const json& j) { Condition c; c.flag = j.value("flag", ""); @@ -126,12 +139,44 @@ CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) { return line; } +CutsceneCameraPose DialogueDatabase::parseCutsceneCameraPose(const json& j) { + CutsceneCameraPose pose; + pose.focusX = j.value("focusX", 0.5f); + pose.focusY = j.value("focusY", 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; +} + StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { StaticCutsceneDefinition cutscene; cutscene.id = j.value("id", ""); cutscene.background = j.value("background", ""); cutscene.music = j.value("music", ""); cutscene.skippable = j.value("skippable", true); + cutscene.durationMs = j.value("durationMs", 0); + + if (j.contains("cameraTrack") && j["cameraTrack"].is_array()) { + for (const auto& item : j["cameraTrack"]) { + cutscene.cameraTrack.push_back(parseCutsceneCameraSegment(item)); + } + } if (j.contains("lines") && j["lines"].is_array()) { for (const auto& item : j["lines"]) { diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h index e2e3c09..68aafb6 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -23,6 +23,7 @@ private: 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 Condition parseCondition(const json& j); static Effect parseEffect(const json& j); @@ -30,9 +31,9 @@ private: 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 StaticCutsceneDefinition parseCutscene(const json& j); - - }; } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp index 5718e02..2771c55 100644 --- a/src/dialogue/DialogueOverlay.cpp +++ b/src/dialogue/DialogueOverlay.cpp @@ -4,6 +4,8 @@ #include "GameConstants.h" #include "Environment.h" #include +#include +#include namespace ZL::Dialogue { @@ -46,6 +48,9 @@ bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) { void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { if (model.mode == PresentationMode::Hidden) { lastChoiceRects.clear(); + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = {}; + cutsceneAdvanceEnabled = false; return; } @@ -59,10 +64,13 @@ void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) { const float W = Environment::projectionWidth; - const float H = Environment::projectionHeight; + // const float H = Environment::projectionHeight; const UiRect portraitRect{ 24.0f, 24.0f, 182.0f, 182.0f }; const UiRect textboxRect{ 220.0f, 24.0f, max(200.0f, W - 244.0f), 182.0f }; + lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h }; + lastCutsceneAdvanceRect = {}; + cutsceneAdvanceEnabled = false; if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { @@ -76,7 +84,7 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& glEnable(GL_BLEND); renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); - renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); @@ -113,7 +121,7 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); - renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, Environment::projectionHeight, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); @@ -162,11 +170,12 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) { const float W = Environment::projectionWidth; const float H = Environment::projectionHeight; - const UiRect fullscreenRect{ 0.0f, 0.0f, W, H }; const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = subtitleRect; + cutsceneAdvanceEnabled = model.showCutsceneSubtitle; - backgroundQuad.rebuild(fullscreenRect); - subtitleQuad.rebuild(subtitleRect); + std::shared_ptr bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath); glEnable(GL_BLEND); renderer.shaderManager.PushShader(defaultShaderName); @@ -175,42 +184,76 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& renderer.PushMatrix(); renderer.LoadIdentity(); - if (!model.backgroundPath.empty()) { - drawQuad(renderer, backgroundQuad, loadTextureCached(model.backgroundPath)); + if (bgTexture) { + const float texW = static_cast(bgTexture->getWidth()); + const float texH = static_cast(bgTexture->getHeight()); + const float baseScale = max(W / max(texW, 1.0f), H / max(texH, 1.0f)); + const float zoom = max(model.cutsceneCamera.zoom, 0.01f); + const float drawW = texW * baseScale * zoom; + const float drawH = texH * baseScale * zoom; + const float focusX = std::clamp(model.cutsceneCamera.focusX, 0.0f, 1.0f); + const float focusY = std::clamp(model.cutsceneCamera.focusY, 0.0f, 1.0f); + const float localFocusX = -drawW * 0.5f + drawW * focusX; + const float localFocusY = -drawH * 0.5f + drawH * focusY; + const float rotationRad = model.cutsceneCamera.rotationDeg * 3.14159265358979323846f / 180.0f; + + const UiRect backgroundRect{ -drawW * 0.5f, -drawH * 0.5f, drawW, drawH }; + backgroundQuad.rebuild(backgroundRect); + + renderer.TranslateMatrix({ W * 0.5f, H * 0.5f, 0.0f }); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(rotationRad, Eigen::Vector3f::UnitZ()))); + renderer.TranslateMatrix({ -localFocusX, -localFocusY, 0.0f }); + drawQuad(renderer, backgroundQuad, bgTexture); + renderer.LoadIdentity(); + } + + if (model.showCutsceneSubtitle) { + subtitleQuad.rebuild(subtitleRect); + drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); } - drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); - 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 }); + 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 }); + } + cutsceneRenderer->drawText( + wrapText(model.visibleText, 62), + subtitleRect.x + 24.0f, + subtitleRect.y + 30.0f, + 1.0f, + false, + { 1.0f, 1.0f, 1.0f, 1.0f } + ); } - cutsceneRenderer->drawText( - wrapText(model.visibleText, 62), - subtitleRect.x + 24.0f, - subtitleRect.y + 30.0f, - 1.0f, - false, - { 1.0f, 1.0f, 1.0f, 1.0f } - ); glDisable(GL_BLEND); } bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const { outChoiceIndex = -1; - if (model.mode != PresentationMode::Choice) { - return false; + + if (model.mode == PresentationMode::Choice) { + for (size_t i = 0; i < lastChoiceRects.size(); ++i) { + if (lastChoiceRects[i].contains(x, y)) { + outChoiceIndex = static_cast(i); + return true; + } + } + return lastDialogueAdvanceRect.contains(x, y); + } + + if (model.mode == PresentationMode::Dialogue) { + return lastDialogueAdvanceRect.contains(x, y); } - for (size_t i = 0; i < lastChoiceRects.size(); ++i) { - if (lastChoiceRects[i].contains(x, y)) { - outChoiceIndex = static_cast(i); - return true; - } + if (model.mode == PresentationMode::Cutscene) { + return cutsceneAdvanceEnabled && lastCutsceneAdvanceRect.contains(x, y); } + return false; } diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h index 34cc273..f7637a0 100644 --- a/src/dialogue/DialogueOverlay.h +++ b/src/dialogue/DialogueOverlay.h @@ -18,6 +18,7 @@ public: void draw(Renderer& renderer, const PresentationModel& model); // Coordinates are expected in the game's UI projection space + // Returns true only when the click should advance/select. bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const; private: @@ -40,6 +41,9 @@ private: std::shared_ptr cutsceneSubtitleTexture; mutable std::vector lastChoiceRects; + mutable UiRect lastDialogueAdvanceRect{}; + mutable UiRect lastCutsceneAdvanceRect{}; + mutable bool cutsceneAdvanceEnabled = false; std::unique_ptr nameRenderer; std::unique_ptr bodyRenderer; diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index e7307a2..ddc5b34 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -1,6 +1,7 @@ #include "dialogue/DialogueRuntime.h" #include +#include #include namespace ZL::Dialogue { @@ -30,6 +31,8 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) { revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; + cutsceneElapsedMs = 0; + cutsceneTotalDurationMs = 0; presentation = {}; presentation.dialogueId = dialogue->id; @@ -46,6 +49,8 @@ void DialogueRuntime::stop() { revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; + cutsceneElapsedMs = 0; + cutsceneTotalDurationMs = 0; mode = Mode::Inactive; presentation = {}; } @@ -63,20 +68,59 @@ void DialogueRuntime::update(int deltaMs) { } if (mode == Mode::PlayingCutscene && activeCutscene) { - if (currentCutsceneLine < 0 || currentCutsceneLine >= static_cast(activeCutscene->lines.size())) { - advanceCutsceneLine(); + 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; } - const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; - if (line.waitForConfirm) { + refreshCutscenePresentation(); + + if (!activeCutscene || mode != Mode::PlayingCutscene) { return; } - cutsceneTimerMs += deltaMs; - const int durationMs = (line.durationMs > 0) ? line.durationMs : computeFallbackCutsceneDurationMs(line.text); - if (cutsceneTimerMs >= durationMs) { - advanceCutsceneLine(); + 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; } } } @@ -125,7 +169,13 @@ void DialogueRuntime::confirmAdvance() { } if (mode == Mode::PlayingCutscene) { - advanceCutsceneLine(); + if (!activeCutscene || activeCutscene->lines.empty()) { + return; + } + + if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(activeCutscene->lines.size())) { + advanceCutsceneLine(); + } } } @@ -261,6 +311,8 @@ void DialogueRuntime::presentLine(const Node& node) { presentation.choices.clear(); presentation.selectedChoice = 0; presentation.revealCompleted = node.text.empty(); + presentation.showCutsceneSubtitle = false; + presentation.cutsceneCamera = {}; if (presentation.revealCompleted) { presentation.visibleText = node.text; @@ -303,6 +355,8 @@ void DialogueRuntime::presentChoices(const Node& node) { presentation.backgroundPath.clear(); presentation.selectedChoice = 0; presentation.revealCompleted = true; + presentation.showCutsceneSubtitle = false; + presentation.cutsceneCamera = {}; } void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { @@ -326,9 +380,37 @@ void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::st activeCutscene = cutscene; pendingNodeAfterCutscene = nextNodeAfterCutscene; mode = Mode::PlayingCutscene; + cutsceneElapsedMs = 0; + cutsceneTimerMs = 0; + currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0; + cutsceneTotalDurationMs = std::max(activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene)); + if (cutsceneTotalDurationMs <= 0 && activeCutscene->lines.empty()) { + cutsceneTotalDurationMs = 3000; + } + refreshCutscenePresentation(); + + 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; + activeCutscene = nullptr; currentCutsceneLine = -1; cutsceneTimerMs = 0; - advanceCutsceneLine(); + cutsceneElapsedMs = 0; + cutsceneTotalDurationMs = 0; + if (!pendingNodeAfterCutscene.empty()) { + const std::string nextNode = pendingNodeAfterCutscene; + pendingNodeAfterCutscene.clear(); + enterNode(nextNode); + } + else { + stop(); + } } void DialogueRuntime::advanceCutsceneLine() { @@ -337,18 +419,19 @@ void DialogueRuntime::advanceCutsceneLine() { 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())) { - activeCutscene = nullptr; - if (!pendingNodeAfterCutscene.empty()) { - const std::string nextNode = pendingNodeAfterCutscene; - pendingNodeAfterCutscene.clear(); - enterNode(nextNode); - } - else { - stop(); + refreshCutscenePresentation(); + if (cutsceneTotalDurationMs <= 0 || cutsceneElapsedMs >= cutsceneTotalDurationMs) { + finishCutscene(); } return; } @@ -356,22 +439,101 @@ void DialogueRuntime::advanceCutsceneLine() { refreshCutscenePresentation(); } +CutsceneCameraPose DialogueRuntime::evaluateCutsceneCameraPose() const { + CutsceneCameraPose defaultPose{}; + if (!activeCutscene || activeCutscene->cameraTrack.empty()) { + return defaultPose; + } + + int elapsed = cutsceneElapsedMs; + for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) { + const int durationMs = std::max(segment.durationMs, 1); + if (elapsed <= durationMs) { + const float rawT = static_cast(elapsed) / static_cast(durationMs); + const float t = applyEasing(segment.easing, std::clamp(rawT, 0.0f, 1.0f)); + CutsceneCameraPose pose; + pose.focusX = segment.from.focusX + (segment.to.focusX - segment.from.focusX) * t; + pose.focusY = segment.from.focusY + (segment.to.focusY - segment.from.focusY) * t; + pose.zoom = segment.from.zoom + (segment.to.zoom - segment.from.zoom) * t; + pose.rotationDeg = segment.from.rotationDeg + (segment.to.rotationDeg - segment.from.rotationDeg) * t; + return pose; + } + elapsed -= durationMs; + } + + return activeCutscene->cameraTrack.back().to; +} + void DialogueRuntime::refreshCutscenePresentation() { - if (!activeCutscene || currentCutsceneLine < 0 || - currentCutsceneLine >= static_cast(activeCutscene->lines.size())) { + if (!activeCutscene) { + return; + } + + presentation.mode = PresentationMode::Cutscene; + presentation.backgroundPath = activeCutscene->background; + presentation.cutsceneCamera = evaluateCutsceneCameraPose(); + + std::cout << "[CUTSCENE] pose focus=(" + << presentation.cutsceneCamera.focusX << ", " + << presentation.cutsceneCamera.focusY << ") zoom=" + << presentation.cutsceneCamera.zoom + << " rot=" << presentation.cutsceneCamera.rotationDeg + << " line=" << currentCutsceneLine + << std::endl; + + presentation.choices.clear(); + presentation.selectedChoice = 0; + 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]; - 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.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) { @@ -382,6 +544,14 @@ int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) 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(); diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index f810834..4584e78 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -63,6 +63,8 @@ private: int currentCutsceneLine = -1; int cutsceneTimerMs = 0; + int cutsceneElapsedMs = 0; + int cutsceneTotalDurationMs = 0; bool evaluateConditions(const std::vector& conditions) const; void applyEffects(const std::vector& effects); @@ -71,11 +73,15 @@ private: void presentLine(const Node& node); void presentChoices(const Node& node); void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); + void finishCutscene(); void advanceCutsceneLine(); void refreshCutscenePresentation(); + CutsceneCameraPose evaluateCutsceneCameraPose() const; + static float applyEasing(EasingType easing, float t); static int computeFallbackCutsceneDurationMs(const std::string& text); + static int computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene); }; -} // namespace ZL::Dialogue \ No newline at end of file +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp index 319f416..7491259 100644 --- a/src/dialogue/DialogueSystem.cpp +++ b/src/dialogue/DialogueSystem.cpp @@ -73,12 +73,14 @@ bool DialogueSystem::handlePointerReleased(float x, float y) { int choiceIndex = -1; const PresentationModel& model = runtime.getPresentation(); - if (overlay.handlePointerReleased(x, y, model, choiceIndex)) { - while (model.selectedChoice != choiceIndex) { + if (!overlay.handlePointerReleased(x, y, model, choiceIndex)) { + return false; + } + + if (choiceIndex >= 0) { + while (runtime.getPresentation().selectedChoice != choiceIndex) { runtime.moveSelection(1); } - runtime.confirmAdvance(); - return true; } runtime.confirmAdvance(); diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index 76cd925..833ab44 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -31,6 +31,19 @@ enum class ComparisonOp { LessOrEqual }; +enum class EasingType { + Linear, + EaseInSine, + EaseOutSine, + EaseInOutSine, + EaseInQuad, + EaseOutQuad, + EaseInOutQuad, + EaseInCubic, + EaseOutCubic, + EaseInOutCubic +}; + struct Condition { std::string flag; ComparisonOp op = ComparisonOp::Exists; @@ -91,11 +104,27 @@ struct CutsceneLine { bool waitForConfirm = false; }; +struct CutsceneCameraPose { + float focusX = 0.5f; + float focusY = 0.5f; + float zoom = 1.0f; + float rotationDeg = 0.0f; +}; + +struct CutsceneCameraSegment { + int durationMs = 0; + CutsceneCameraPose from; + CutsceneCameraPose to; + EasingType easing = EasingType::EaseInOutSine; +}; + struct StaticCutsceneDefinition { std::string id; std::string background; std::string music; bool skippable = true; + int durationMs = 0; + std::vector cameraTrack; std::vector lines; }; @@ -123,6 +152,8 @@ struct PresentationModel { std::vector choices; int selectedChoice = 0; bool revealCompleted = true; + bool showCutsceneSubtitle = false; + CutsceneCameraPose cutsceneCamera; }; struct SaveState { @@ -137,4 +168,4 @@ struct SaveState { bool active = false; }; -} // namespace ZL::Dialogue \ No newline at end of file +} // namespace ZL::Dialogue