From ceebb13719d56cbd503d8b6b3c72a0a9f88364f1 Mon Sep 17 00:00:00 2001 From: vottozi Date: Wed, 15 Apr 2026 20:42:41 +0600 Subject: [PATCH 1/2] 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 From 2494a44e4ae6b7e63ad2f9c95fa5ac8c39a00ba1 Mon Sep 17 00:00:00 2001 From: vottozi Date: Thu, 16 Apr 2026 01:29:01 +0600 Subject: [PATCH 2/2] refactor: switch cutscene camera to viewport/UV-based system --- resources/dialogue/sample_dialogues.json | 166 +++++++++++------ resources/hero.png | 3 + src/Game.cpp | 2 +- src/dialogue/DialogueDatabase.cpp | 14 +- src/dialogue/DialogueDatabase.h | 1 + src/dialogue/DialogueOverlay.cpp | 216 +++++++++++++++++++++-- src/dialogue/DialogueOverlay.h | 29 +++ src/dialogue/DialogueRuntime.cpp | 44 ++--- src/dialogue/DialogueRuntime.h | 2 +- src/dialogue/DialogueTypes.h | 30 +++- 10 files changed, 407 insertions(+), 100 deletions(-) create mode 100644 resources/hero.png diff --git a/resources/dialogue/sample_dialogues.json b/resources/dialogue/sample_dialogues.json index 2f6cf3e..09116ab 100644 --- a/resources/dialogue/sample_dialogues.json +++ b/resources/dialogue/sample_dialogues.json @@ -171,6 +171,22 @@ "type": "End" } ] + }, + { + "id": "test_cutscene_pan_dialogue_silent", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_pan_02", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] } ], "cutscenes": [ @@ -234,61 +250,99 @@ "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 - } - ] - } + "id": "test_cutscene_pan_01", + "background": "resources/first_cutscene.png", + "durationMs": 12000, + "cameraTrack": [ + { + "durationMs": 1200, + "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "easing": "Linear" + }, + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 2600, + "from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 1800, + "from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 3900, + "from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "resources/hero.png", + "text": "The memory begins in silence.", + "durationMs": 2200 + }, + { + "speaker": "Narrator", + "portrait": "resources/hero.png", + "text": "Something is drawing your eyes across the whole scene.", + "durationMs": 2800 + }, + { + "speaker": "Ghost", + "portrait": "resources/ghost_avatar.png", + "text": "Do not look away.", + "durationMs": 2400 + } + ] + }, + { + "id": "test_cutscene_pan_02", + "background": "resources/first_cutscene.png", + "durationMs": 12000, + "cameraTrack": [ + { + "durationMs": 1200, + "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "easing": "Linear" + }, + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.00, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 2600, + "from": { "anchor": "TopLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 1800, + "from": { "anchor": "TopRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 3900, + "from": { "anchor": "BottomRight", "zoom": 1.72, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "lines": [] + } ] } diff --git a/resources/hero.png b/resources/hero.png new file mode 100644 index 0000000..6ee790d --- /dev/null +++ b/resources/hero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:219db4827e0592950f17246741077783aa4edd45f549e20b36d3839e051f747e +size 100964 diff --git a/src/Game.cpp b/src/Game.cpp index bf682d1..19c260f 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -732,7 +732,7 @@ namespace ZL if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { switch (event.key.keysym.sym) { case SDLK_f: - dialogueSystem.startDialogue("test_choice_dialogue"); + dialogueSystem.startDialogue("test_cutscene_pan_dialogue_silent"); break; case SDLK_e: diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index a120f66..545c7cc 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -42,6 +42,15 @@ EasingType DialogueDatabase::parseEasingType(const std::string& value) { 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", ""); @@ -141,8 +150,9 @@ CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) { CutsceneCameraPose DialogueDatabase::parseCutsceneCameraPose(const json& j) { CutsceneCameraPose pose; - pose.focusX = j.value("focusX", 0.5f); - pose.focusY = j.value("focusY", 0.5f); + 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; diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h index 68aafb6..01732c1 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -24,6 +24,7 @@ private: 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); diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp index 2771c55..5937e49 100644 --- a/src/dialogue/DialogueOverlay.cpp +++ b/src/dialogue/DialogueOverlay.cpp @@ -20,6 +20,45 @@ void DialogueOverlay::TexturedQuad::rebuild(const UiRect& newRect) { 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; @@ -167,10 +206,112 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& 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; cutsceneAdvanceEnabled = model.showCutsceneSubtitle; @@ -187,24 +328,60 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& 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); + ResolvedViewport currentViewport{}; + + if (model.cutsceneCamera.active) { + const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, texW, texH, W, H); + const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, texW, texH, W, H); + + currentViewport = blendViewport( + fromViewport, + toViewport, + std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f) + ); + } + else { + currentViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H); + } + + const float halfW = currentViewport.widthPx * 0.5f; + const float halfH = currentViewport.heightPx * 0.5f; + const float rotationRad = currentViewport.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 { + currentViewport.centerXPx + x * c - y * s, + currentViewport.centerYPx + x * s + y * c + }; + }; + + // Source viewport corners in image pixel space (origin = bottom-left) + 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(texW, 1.0f), 0.0f, 1.0f), + std::clamp(p.y() / max(texH, 1.0f), 0.0f, 1.0f) + }; + }; + + const UiRect screenRect{ 0.0f, 0.0f, W, H }; + backgroundQuad.rebuildWithUV( + screenRect, + toUV(srcBL), + toUV(srcTL), + toUV(srcTR), + toUV(srcBR) + ); - 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) { @@ -218,7 +395,14 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& 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 }); + 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), diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h index f7637a0..e761937 100644 --- a/src/dialogue/DialogueOverlay.h +++ b/src/dialogue/DialogueOverlay.h @@ -28,6 +28,21 @@ private: bool initialized = false; 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 + ); + }; + + 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; @@ -65,6 +80,20 @@ private: 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 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 + ); }; } // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index ddc5b34..c68deed 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -439,29 +439,39 @@ void DialogueRuntime::advanceCutsceneLine() { refreshCutscenePresentation(); } -CutsceneCameraPose DialogueRuntime::evaluateCutsceneCameraPose() const { - CutsceneCameraPose defaultPose{}; +CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const { + CutsceneCameraBlendState result; + result.active = false; + result.from = {}; + result.to = {}; + result.t = 1.0f; + if (!activeCutscene || activeCutscene->cameraTrack.empty()) { - return defaultPose; + return result; } 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; + 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; } - return activeCutscene->cameraTrack.back().to; + result.active = true; + result.from = activeCutscene->cameraTrack.back().to; + result.to = activeCutscene->cameraTrack.back().to; + result.t = 1.0f; + + return result; } void DialogueRuntime::refreshCutscenePresentation() { @@ -471,15 +481,7 @@ void DialogueRuntime::refreshCutscenePresentation() { 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.cutsceneCamera = evaluateCutsceneCameraBlend(); presentation.choices.clear(); presentation.selectedChoice = 0; diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index 4584e78..78df448 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -77,7 +77,7 @@ private: void advanceCutsceneLine(); void refreshCutscenePresentation(); - CutsceneCameraPose evaluateCutsceneCameraPose() const; + CutsceneCameraBlendState evaluateCutsceneCameraBlend() const; static float applyEasing(EasingType easing, float t); static int computeFallbackCutsceneDurationMs(const std::string& text); diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index 833ab44..89df3c1 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -44,6 +44,15 @@ enum class EasingType { EaseInOutCubic }; +enum class CutsceneAnchor { + Center, + TopLeft, + TopRight, + BottomRight, + BottomLeft, + Custom +}; + struct Condition { std::string flag; ComparisonOp op = ComparisonOp::Exists; @@ -105,8 +114,15 @@ struct CutsceneLine { }; struct CutsceneCameraPose { - float focusX = 0.5f; - float focusY = 0.5f; + 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; }; @@ -141,6 +157,13 @@ 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; @@ -153,7 +176,8 @@ struct PresentationModel { int selectedChoice = 0; bool revealCompleted = true; bool showCutsceneSubtitle = false; - CutsceneCameraPose cutsceneCamera; + + CutsceneCameraBlendState cutsceneCamera; }; struct SaveState {