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 {