diff --git a/resources/dialogue/sample_dialogues.json b/resources/dialogue/sample_dialogues.json index 3e5c7e0..b232fa4 100644 --- a/resources/dialogue/sample_dialogues.json +++ b/resources/dialogue/sample_dialogues.json @@ -42,7 +42,6 @@ } ] }, - { "id": "test_choice_dialogue", "start": "line_1", @@ -59,7 +58,7 @@ "id": "choice_1", "type": "Choice", "speaker": "Hero", - "portrait": "", + "portrait": "resources/hero.png", "text": "Choose your answer.", "choices": [ { @@ -90,7 +89,7 @@ "speaker": "Merchant", "portrait": "resources/ghost_avatar.png", "text": "Just a trader passing through.", - "next": "end_1" + "next": "choice_1" }, { "id": "end_1", @@ -98,7 +97,6 @@ } ] }, - { "id": "test_condition_dialogue", "start": "set_flag_1", @@ -142,7 +140,6 @@ } ] }, - { "id": "test_cutscene_dialogue", "start": "cutscene_start", @@ -158,17 +155,85 @@ "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" + } + ] + }, + { + "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": [ { "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 }, @@ -180,6 +245,121 @@ "background": "resources/loading.png" } ] - } + }, + { + "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": { "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": [] + } ] -} \ No newline at end of file +} 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 8c76055..5be8cee 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -773,7 +773,6 @@ namespace ZL lastTickCount = newTickCount; - //if (player) player->update(delta); if (player) { player->update(delta); dialogueSystem.update(static_cast(delta), player->position); @@ -808,18 +807,7 @@ namespace ZL targetInteractiveObject = nullptr; } - - //for (auto& npc : npcs) npc->update(delta); - //if (player) { - // dialogueSystem.update(static_cast(delta), player->position); - //} } - - // Some AI stuff - - - - } } @@ -1054,11 +1042,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_cutscene_pan_dialogue_silent"); break; case SDLK_e: - dialogueSystem.startDialogue("test_cutscene_dialogue"); + dialogueSystem.startDialogue("test_cutscene_pan_dialogue"); break; case SDLK_p: diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index 07f17b2..93ada15 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -29,6 +29,28 @@ 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; +} + +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", ""); @@ -127,12 +149,45 @@ CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) { 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; +} + 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..01732c1 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -23,6 +23,8 @@ 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 CutsceneAnchor parseCutsceneAnchor(const std::string& value); static Condition parseCondition(const json& j); static Effect parseEffect(const json& j); @@ -30,9 +32,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..5937e49 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 { @@ -18,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; @@ -46,6 +87,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 +103,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 +123,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 +160,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(); @@ -159,14 +206,117 @@ 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 fullscreenRect{ 0.0f, 0.0f, W, H }; const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; - backgroundQuad.rebuild(fullscreenRect); - subtitleQuad.rebuild(subtitleRect); + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = subtitleRect; + cutsceneAdvanceEnabled = model.showCutsceneSubtitle; + + std::shared_ptr bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath); glEnable(GL_BLEND); renderer.shaderManager.PushShader(defaultShaderName); @@ -175,42 +325,119 @@ 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()); + + 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) + ); + + drawQuad(renderer, backgroundQuad, bgTexture); + } + + 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..e761937 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: @@ -27,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; @@ -40,6 +56,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; @@ -61,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 32fccf1..33e5d99 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) { @@ -327,9 +381,37 @@ void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::st pendingNodeAfterCutscene = nextNodeAfterCutscene; currentCutsceneBackground = cutscene->background; 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() { @@ -338,18 +420,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; } @@ -357,28 +440,120 @@ void DialogueRuntime::advanceCutsceneLine() { 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; +} + 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 = evaluateCutsceneCameraBlend(); + + 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]; +/*<<<<<<< HEAD if (!line.background.empty()) { currentCutsceneBackground = line.background; } presentation.mode = PresentationMode::Cutscene; +======= +>>>>>>> witcher001-cutscene*/ presentation.speaker = line.speaker; presentation.fullText = line.text; presentation.visibleText = line.text; presentation.portraitPath = line.portrait; +/*<<<<<<< HEAD //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; + } +//>>>>>>> witcher001-cutscene } int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { @@ -389,6 +564,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 031e9ae..cfbb30a 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; std::string currentCutsceneBackground; @@ -73,11 +75,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(); + CutsceneCameraBlendState evaluateCutsceneCameraBlend() 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 0b6bd8b..980fa9b 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -31,6 +31,28 @@ 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; @@ -92,11 +114,34 @@ struct CutsceneLine { bool waitForConfirm = false; }; +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 StaticCutsceneDefinition { std::string id; std::string background; std::string music; bool skippable = true; + int durationMs = 0; + std::vector cameraTrack; std::vector lines; }; @@ -113,6 +158,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; @@ -124,6 +176,9 @@ struct PresentationModel { std::vector choices; int selectedChoice = 0; bool revealCompleted = true; + bool showCutsceneSubtitle = false; + + CutsceneCameraBlendState cutsceneCamera; }; struct SaveState { @@ -138,4 +193,4 @@ struct SaveState { bool active = false; }; -} // namespace ZL::Dialogue \ No newline at end of file +} // namespace ZL::Dialogue