From 0a073243ad9c13a0837ca46363ba209230b58247 Mon Sep 17 00:00:00 2001 From: vottozi Date: Sat, 25 Apr 2026 01:32:16 +0600 Subject: [PATCH] safe LMB hold-to-skip cutscenes and multiple images with crossfade --- resources/dialogue/cutscene_image_tests.json | 290 ++++++++++++++++++ resources/second_cutscene.png | 3 + .../shaders/cutscene_fade_desktop.fragment | 11 + resources/shaders/cutscene_fade_web.fragment | 11 + resources/shaders/default_shadow.vertex | 2 + resources/shaders/skinning_shadow.vertex | 2 + src/Game.cpp | 43 ++- src/Location.cpp | 6 +- src/dialogue/DialogueDatabase.cpp | 16 + src/dialogue/DialogueDatabase.h | 1 + src/dialogue/DialogueOverlay.cpp | 205 +++++++++++-- src/dialogue/DialogueOverlay.h | 18 +- src/dialogue/DialogueRuntime.cpp | 198 ++++++++++-- src/dialogue/DialogueRuntime.h | 6 +- src/dialogue/DialogueSystem.cpp | 27 +- src/dialogue/DialogueTypes.h | 20 +- 16 files changed, 801 insertions(+), 58 deletions(-) create mode 100644 resources/dialogue/cutscene_image_tests.json create mode 100644 resources/second_cutscene.png create mode 100644 resources/shaders/cutscene_fade_desktop.fragment create mode 100644 resources/shaders/cutscene_fade_web.fragment diff --git a/resources/dialogue/cutscene_image_tests.json b/resources/dialogue/cutscene_image_tests.json new file mode 100644 index 0000000..cb8c04d --- /dev/null +++ b/resources/dialogue/cutscene_image_tests.json @@ -0,0 +1,290 @@ +{ + "dialogues": [ + { + "id": "test_cutscene_skip_hold_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_skip_hold_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_hardcut_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_hardcut_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_crossfade_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_crossfade_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_silent_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_silent_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + } + ], + "cutscenes": [ + { + "id": "test_cutscene_skip_hold_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 12000, + "cameraTrack": [ + { + "durationMs": 3000, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopRight", "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.65, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 3000, + "from": { "anchor": "BottomRight", "zoom": 1.65, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "This cutscene is long enough to test hold-to-skip.", + "durationMs": 2600 + }, + { + "speaker": "Narrator", + "portrait": "", + "text": "A normal click must not skip it.", + "durationMs": 2600 + }, + { + "speaker": "Ghost", + "portrait": "resources/ghost_avatar.png", + "text": "Only the skip button with hold should work.", + "durationMs": 2600 + } + ] + }, + { + "id": "test_cutscene_images_hardcut_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 9000, + "cameraTrack": [ + { + "durationMs": 4500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 4500, + "from": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 4500, + "fadeInMs": 0, + "fadeOutMs": 0 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 4500, + "endMs": 9000, + "fadeInMs": 0, + "fadeOutMs": 0 + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "First image should switch sharply to the second one.", + "durationMs": 2800 + }, + { + "speaker": "Narrator", + "portrait": "", + "text": "No fade should be visible here.", + "durationMs": 2800 + } + ] + }, + { + "id": "test_cutscene_images_crossfade_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 10000, + "cameraTrack": [ + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "Custom", "centerX": 0.35, "centerY": 0.30, "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutQuad" + }, + { + "durationMs": 2500, + "from": { "anchor": "Custom", "centerX": 0.35, "centerY": 0.30, "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "Custom", "centerX": 0.70, "centerY": 0.32, "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseOutCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "Custom", "centerX": 0.70, "centerY": 0.32, "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.70, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "BottomRight", "zoom": 1.70, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 6000, + "fadeInMs": 0, + "fadeOutMs": 0 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 4500, + "endMs": 10000, + "fadeInMs": 1500, + "fadeOutMs": 0 + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "The second image should fade over the first one.", + "durationMs": 2600 + }, + { + "speaker": "Ghost", + "portrait": "resources/ghost_avatar.png", + "text": "This test checks overlap and alpha blending.", + "durationMs": 2600 + } + ] + }, + { + "id": "test_cutscene_images_silent_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 11000, + "cameraTrack": [ + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopRight", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseOutCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutQuad" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 3500, + "fadeInMs": 0, + "fadeOutMs": 800 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 3000, + "endMs": 7500, + "fadeInMs": 800, + "fadeOutMs": 1000 + }, + { + "path": "resources/loading.png", + "startMs": 7000, + "endMs": 11000, + "fadeInMs": 1000, + "fadeOutMs": 0 + } + ], + "lines": [] + } + ] +} diff --git a/resources/second_cutscene.png b/resources/second_cutscene.png new file mode 100644 index 0000000..e77f337 --- /dev/null +++ b/resources/second_cutscene.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bbc3563a65a71ed2cd6c708217da7c787c8561a5bbabf8d5ee4f14f91e06423 +size 2132238 diff --git a/resources/shaders/cutscene_fade_desktop.fragment b/resources/shaders/cutscene_fade_desktop.fragment new file mode 100644 index 0000000..6ceb9b8 --- /dev/null +++ b/resources/shaders/cutscene_fade_desktop.fragment @@ -0,0 +1,11 @@ +//precision mediump float; +uniform sampler2D Texture; +uniform float uAlpha; +varying vec2 texCoord; + +void main() +{ + vec4 color = texture2D(Texture, texCoord).rgba; + color.a *= uAlpha; + gl_FragColor = color; +} diff --git a/resources/shaders/cutscene_fade_web.fragment b/resources/shaders/cutscene_fade_web.fragment new file mode 100644 index 0000000..eff9cf5 --- /dev/null +++ b/resources/shaders/cutscene_fade_web.fragment @@ -0,0 +1,11 @@ +precision mediump float; +uniform sampler2D Texture; +uniform float uAlpha; +varying vec2 texCoord; + +void main() +{ + vec4 color = texture2D(Texture, texCoord).rgba; + color.a *= uAlpha; + gl_FragColor = color; +} diff --git a/resources/shaders/default_shadow.vertex b/resources/shaders/default_shadow.vertex index 7f3b9c5..a0b1034 100644 --- a/resources/shaders/default_shadow.vertex +++ b/resources/shaders/default_shadow.vertex @@ -1,3 +1,5 @@ +#version 120 + attribute vec3 vPosition; attribute vec2 vTexCoord; attribute vec3 vNormal; diff --git a/resources/shaders/skinning_shadow.vertex b/resources/shaders/skinning_shadow.vertex index 4aa14cb..ba8ae2f 100644 --- a/resources/shaders/skinning_shadow.vertex +++ b/resources/shaders/skinning_shadow.vertex @@ -1,3 +1,5 @@ +#version 120 + attribute vec3 vPosition; attribute vec2 vTexCoord; attribute vec3 vNormal; diff --git a/src/Game.cpp b/src/Game.cpp index d6d19f9..90a73ae 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -81,9 +81,11 @@ namespace ZL // so they are available immediately without waiting for resources.zip. renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_web.fragment", ""); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_web.fragment", ""); + renderer.shaderManager.AddShaderFromFiles("cutsceneFade", "resources/shaders/default.vertex", "resources/shaders/cutscene_fade_web.fragment", CONST_ZIP_FILE); #else renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); + renderer.shaderManager.AddShaderFromFiles("cutsceneFade", "resources/shaders/default.vertex", "resources/shaders/cutscene_fade_desktop.fragment", CONST_ZIP_FILE); #endif loadingTexture = std::make_unique(CreateTextureDataFromPng("resources/loading.png", "")); @@ -421,6 +423,24 @@ namespace ZL if (event.button.button == SDL_BUTTON_LEFT) { int mx = static_cast((float)event.button.x / Environment::width * Environment::projectionWidth); int my = static_cast((float)event.button.y / Environment::height * Environment::projectionHeight); + + // Dialogue/cutscene input must be routed before UiManager and world picking. + // Otherwise cutscene global hold-to-skip can be swallowed by regular UI handling. + if (currentLocation && currentLocation->dialogueSystem.blocksGameplayInput()) { + if (event.type == SDL_MOUSEBUTTONDOWN) { + currentLocation->dialogueSystem.handlePointerDown( + static_cast(mx), + Environment::projectionHeight - static_cast(my) + ); + } + else { + currentLocation->dialogueSystem.handlePointerReleased( + static_cast(mx), + Environment::projectionHeight - static_cast(my) + ); + } + continue; + } if (event.type == SDL_MOUSEBUTTONDOWN) { std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl; @@ -464,11 +484,20 @@ namespace ZL else if (event.type == SDL_MOUSEMOTION) { int mx = static_cast((float)event.motion.x / Environment::width * Environment::projectionWidth); int my = static_cast((float)event.motion.y / Environment::height * Environment::projectionHeight); + + if (currentLocation && currentLocation->dialogueSystem.blocksGameplayInput()) { + currentLocation->dialogueSystem.handlePointerMoved( + static_cast(mx), + Environment::projectionHeight - static_cast(my) + ); + continue; + } + handleMotion(ZL::UiManager::MOUSE_FINGER_ID, mx, my); if (currentLocation) { currentLocation->handleMotion(ZL::UiManager::MOUSE_FINGER_ID, event.motion.x, event.motion.y, mx, my); - } + } } if (event.type == SDL_MOUSEWHEEL) { @@ -495,6 +524,18 @@ namespace ZL case SDLK_3: if (audioPlayer) audioPlayer->stopMusicAsync(); break; + case SDLK_7: + currentLocation->dialogueSystem.startDialogue("test_cutscene_skip_hold_dialogue"); + break; + case SDLK_8: + currentLocation->dialogueSystem.startDialogue("test_cutscene_images_hardcut_dialogue"); + break; + case SDLK_9: + currentLocation->dialogueSystem.startDialogue("test_cutscene_images_crossfade_dialogue"); + break; + case SDLK_0: + currentLocation->dialogueSystem.startDialogue("test_cutscene_images_silent_dialogue"); + break; case SDLK_f: currentLocation->dialogueSystem.startDialogue("test_choice_dialogue"); break; diff --git a/src/Location.cpp b/src/Location.cpp index 71e6884..168955d 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -102,7 +102,7 @@ namespace ZL dialogueSystem.init(renderer, CONST_ZIP_FILE); - dialogueSystem.loadDatabase("resources/dialogue/sample_dialogues.json"); + dialogueSystem.loadDatabase("resources/dialogue/cutscene_image_tests.json"); /*dialogueSystem.addTriggerZone({ "ghost_room_trigger", "test_line_dialogue", @@ -624,6 +624,10 @@ namespace ZL } void Location::handleUp(int64_t fingerId, int mx, int my) { + if (dialogueSystem.blocksGameplayInput()) { + dialogueSystem.handlePointerReleased(static_cast(mx), Environment::projectionHeight - static_cast(my)); + return; + } } void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my) diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index bc5db6d..88841d2 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -180,6 +180,16 @@ CutsceneCameraSegment DialogueDatabase::parseCutsceneCameraSegment(const json& j return segment; } +CutsceneImageCue DialogueDatabase::parseCutsceneImageCue(const json& j) { + CutsceneImageCue cue; + cue.path = j.value("path", ""); + cue.startMs = j.value("startMs", 0); + cue.endMs = j.value("endMs", 0); + cue.fadeInMs = j.value("fadeInMs", 0); + cue.fadeOutMs = j.value("fadeOutMs", 0); + return cue; +} + StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { StaticCutsceneDefinition cutscene; cutscene.id = j.value("id", ""); @@ -194,6 +204,12 @@ StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { } } + if (j.contains("images") && j["images"].is_array()) { + for (const auto& item : j["images"]) { + cutscene.images.push_back(parseCutsceneImageCue(item)); + } + } + if (j.contains("lines") && j["lines"].is_array()) { for (const auto& item : j["lines"]) { cutscene.lines.push_back(parseCutsceneLine(item)); diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h index 01732c1..50946d2 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -34,6 +34,7 @@ private: static CutsceneLine parseCutsceneLine(const json& j); static CutsceneCameraPose parseCutsceneCameraPose(const json& j); static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j); + static CutsceneImageCue parseCutsceneImageCue(const json& j); static StaticCutsceneDefinition parseCutscene(const json& j); }; diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp index a74a1c8..a59b691 100644 --- a/src/dialogue/DialogueOverlay.cpp +++ b/src/dialogue/DialogueOverlay.cpp @@ -85,17 +85,67 @@ bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) { } void DialogueOverlay::update(const PresentationModel& model, int deltaMs) { + if (model.mode == PresentationMode::Hidden) { + hoveredChoiceIndex = -1; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + lastChoiceRects.clear(); + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = {}; + return; + } + if (model.mode != PresentationMode::Choice) { hoveredChoiceIndex = -1; } + + if (model.mode != PresentationMode::Cutscene || !model.cutsceneSkippable) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + return; + } + + const int safeDeltaMs = max(deltaMs, 0); + + if (cutsceneSkipHintVisible) { + cutsceneSkipHintRemainingMs -= safeDeltaMs; + if (cutsceneSkipHintRemainingMs <= 0) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + } + } + + if (cutsceneSkipHolding && cutsceneSkipArmed) { + cutsceneSkipHoldElapsedMs += safeDeltaMs; + if (cutsceneSkipHoldElapsedMs >= CutsceneSkipHoldDurationMs) { + cutsceneSkipTriggered = true; + cutsceneSkipHolding = false; + cutsceneSkipHoldElapsedMs = CutsceneSkipHoldDurationMs; + } + } } void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { if (model.mode == PresentationMode::Hidden) { lastChoiceRects.clear(); - lastDialogueAdvanceRect = {}; - lastCutsceneAdvanceRect = {}; - cutsceneAdvanceEnabled = false; + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = {}; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; return; } @@ -115,7 +165,11 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& 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; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { @@ -317,48 +371,57 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& lastDialogueAdvanceRect = {}; lastCutsceneAdvanceRect = subtitleRect; - cutsceneAdvanceEnabled = model.showCutsceneSubtitle; - - std::shared_ptr bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath); glEnable(GL_BLEND); - renderer.shaderManager.PushShader(defaultShaderName); + + renderer.shaderManager.PushShader("cutsceneFade"); renderer.RenderUniform1i(textureUniformName, 0); renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); - if (bgTexture) { - const float texW = static_cast(bgTexture->getWidth()); - const float texH = static_cast(bgTexture->getHeight()); + const UiRect screenRect{ 0.0f, 0.0f, W, H }; - ResolvedViewport currentViewport{}; + std::vector imageLayers = model.cutsceneImages; + if (imageLayers.empty() && !model.backgroundPath.empty()) { + imageLayers.push_back({ model.backgroundPath, 1.0f }); + } + + for (const PresentedCutsceneImage& layer : imageLayers) { + const auto texture = loadTextureCached(layer.path); + if (!texture) { + continue; + } + + const float texW = static_cast(texture->getWidth()); + const float texH = static_cast(texture->getHeight()); + + ResolvedViewport layerViewport{}; 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( + layerViewport = blendViewport( fromViewport, toViewport, std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f) ); } else { - currentViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H); + layerViewport = 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 halfW = layerViewport.widthPx * 0.5f; + const float halfH = layerViewport.heightPx * 0.5f; + const float rotationRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f; const float c = std::cos(rotationRad); const float s = std::sin(rotationRad); auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f { return { - currentViewport.centerXPx + x * c - y * s, - currentViewport.centerYPx + x * s + y * c + layerViewport.centerXPx + x * c - y * s, + layerViewport.centerYPx + x * s + y * c }; }; @@ -375,7 +438,6 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& }; }; - const UiRect screenRect{ 0.0f, 0.0f, W, H }; backgroundQuad.rebuildWithUV( screenRect, toUV(srcBL), @@ -384,14 +446,47 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& toUV(srcBR) ); - drawQuad(renderer, backgroundQuad, bgTexture); + renderer.RenderUniform1f("uAlpha", std::clamp(layer.alpha, 0.0f, 1.0f)); + drawQuad(renderer, backgroundQuad, texture); } + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + // UI quads over the image: subtitle panel and skip progress hint background. + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + if (model.showCutsceneSubtitle) { subtitleQuad.rebuild(subtitleRect); drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); } + if (model.cutsceneSkippable && cutsceneSkipHintVisible) { + const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f }; + skipHintBgQuad.rebuild(hintBg); + drawQuad(renderer, skipHintBgQuad, choiceOptionalTexture); + + const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f }; + skipProgressBgQuad.rebuild(progressBg); + drawQuad(renderer, skipProgressBgQuad, choiceOptionalTexture); + + if (cutsceneSkipHolding) { + const float progress = std::clamp( + static_cast(cutsceneSkipHoldElapsedMs) / static_cast(CutsceneSkipHoldDurationMs), + 0.0f, + 1.0f + ); + const UiRect progressFill{ progressBg.x, progressBg.y, progressBg.w * progress, progressBg.h }; + skipProgressFillQuad.rebuild(progressFill); + drawQuad(renderer, skipProgressFillQuad, choiceMainTexture); + } + } + renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); @@ -417,14 +512,65 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& ); } + if (model.cutsceneSkippable && cutsceneSkipHintVisible) { + choiceRenderer->drawText( + cutsceneSkipHolding ? "Hold to skip..." : "Hold LMB to skip", + W - 232.0f, + H - 50.0f, + 0.85f, + false, + { 1.0f, 1.0f, 1.0f, 0.95f } + ); + } + glDisable(GL_BLEND); } +bool DialogueOverlay::consumeSkipRequested() { + const bool result = cutsceneSkipTriggered; + cutsceneSkipTriggered = false; + + if (result) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + } + + return result; +} + void DialogueOverlay::handlePointerDown(float x, float y, const PresentationModel& model) { + (void)x; + (void)y; + if (model.mode == PresentationMode::Choice) { handlePointerMoved(x, y, model); return; } + + if (model.mode == PresentationMode::Cutscene && model.cutsceneSkippable) { + // First click/tap only arms skip and shows the hint for a short time. + // It does not immediately start skipping, to avoid accidental skip. + if (!cutsceneSkipArmed) { + cutsceneSkipHintVisible = true; + cutsceneSkipArmed = true; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; + cutsceneSkipHoldElapsedMs = 0; + return; + } + + // Once armed, holding anywhere on the screen starts skip progress. + cutsceneSkipHintVisible = true; + cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; + cutsceneSkipHolding = true; + cutsceneSkipTriggered = false; + cutsceneSkipHoldElapsedMs = 0; + return; + } } void DialogueOverlay::handlePointerMoved(float x, float y, const PresentationModel& model) { @@ -457,16 +603,17 @@ bool DialogueOverlay::handlePointerReleased(float x, float y, const Presentation } if (model.mode == PresentationMode::Dialogue) { - if (lastDialogueAdvanceRect.contains(x, y)) { - outAdvanceDialogue = true; - return true; - } - return false; + outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y); + return outAdvanceDialogue; } if (model.mode == PresentationMode::Cutscene) { - return cutsceneAdvanceEnabled && lastCutsceneAdvanceRect.contains(x, y); - } + if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) { + cutsceneSkipHolding = false; + cutsceneSkipHoldElapsedMs = 0; + } + return true; + } return false; } diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h index 66fc822..9ac3d9c 100644 --- a/src/dialogue/DialogueOverlay.h +++ b/src/dialogue/DialogueOverlay.h @@ -21,6 +21,7 @@ public: void handlePointerDown(float x, float y, const PresentationModel& model); void handlePointerMoved(float x, float y, const PresentationModel& model); bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue); + bool consumeSkipRequested(); private: struct TexturedQuad { @@ -59,10 +60,22 @@ private: mutable std::vector lastChoiceRects; mutable UiRect lastDialogueAdvanceRect{}; mutable UiRect lastCutsceneAdvanceRect{}; - mutable bool cutsceneAdvanceEnabled = false; + mutable UiRect lastCutsceneSkipRect{}; int hoveredChoiceIndex = -1; + // Cutscene skip UX: + // First LMB/tap anywhere arms skip and shows a hint for 5 seconds. + // While armed, holding LMB/touch anywhere for 3.5 seconds requests skip. + bool cutsceneSkipHintVisible = false; + bool cutsceneSkipArmed = false; + bool cutsceneSkipHolding = false; + bool cutsceneSkipTriggered = false; + int cutsceneSkipHintRemainingMs = 0; + int cutsceneSkipHoldElapsedMs = 0; + static constexpr int CutsceneSkipHintDurationMs = 5000; + static constexpr int CutsceneSkipHoldDurationMs = 3500; + std::unique_ptr nameRenderer; std::unique_ptr bodyRenderer; std::unique_ptr choiceRenderer; @@ -72,6 +85,9 @@ private: TexturedQuad textboxQuad; TexturedQuad subtitleQuad; TexturedQuad backgroundQuad; + TexturedQuad skipHintBgQuad; + TexturedQuad skipProgressBgQuad; + TexturedQuad skipProgressFillQuad; mutable std::vector choiceQuads; std::unordered_map> textureCache; diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index 5f0fcbd..436ac48 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -27,12 +27,13 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) { currentNodeId.clear(); pendingNodeAfterCutscene.clear(); visibleChoices.clear(); - selectedChoice = 0; + selectedChoice = -1; revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); presentation = {}; presentation.dialogueId = dialogue->id; @@ -45,12 +46,13 @@ void DialogueRuntime::stop() { currentNodeId.clear(); pendingNodeAfterCutscene.clear(); visibleChoices.clear(); - selectedChoice = 0; + selectedChoice = -1; revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); mode = Mode::Inactive; presentation = {}; } @@ -155,11 +157,11 @@ void DialogueRuntime::confirmAdvance() { } if (mode == Mode::WaitingForChoice) { - if (visibleChoices.empty()) { + if (visibleChoices.empty() || selectedChoice < 0 || selectedChoice >= static_cast(visibleChoices.size())) { return; } - const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1)]; + const Choice& choice = visibleChoices[selectedChoice]; if (choice.consumeOnce && !choice.id.empty()) { consumedChoices.insert(choice.id); } @@ -169,13 +171,7 @@ void DialogueRuntime::confirmAdvance() { } if (mode == Mode::PlayingCutscene) { - if (!activeCutscene || activeCutscene->lines.empty()) { - return; - } - - if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(activeCutscene->lines.size())) { - advanceCutsceneLine(); - } + return; } } @@ -208,6 +204,43 @@ void DialogueRuntime::selectChoice(int index) { presentation.selectedChoice = selectedChoice; } +bool DialogueRuntime::canSkipCurrentCutscene() const { + return mode == Mode::PlayingCutscene && activeCutscene && activeCutscene->skippable; +} + +void DialogueRuntime::skipCurrentCutscene() { + if (!canSkipCurrentCutscene()) { + return; + } + + // Multi-image cutscenes skip to the next image cue first. + // This matches the desired behavior: skip advances the visual chapter, + // not necessarily the whole cutscene immediately. + if (!activeCutscene->images.empty()) { + int nextImageStartMs = -1; + + for (const CutsceneImageCue& cue : activeCutscene->images) { + if (cue.path.empty()) { + continue; + } + if (cue.startMs > cutsceneElapsedMs) { + if (nextImageStartMs < 0 || cue.startMs < nextImageStartMs) { + nextImageStartMs = cue.startMs; + } + } + } + + if (nextImageStartMs >= 0) { + cutsceneElapsedMs = nextImageStartMs; + syncCutsceneLineToElapsedTime(); + refreshCutscenePresentation(); + return; + } + } + + finishCutscene(); +} + void DialogueRuntime::setFlag(const std::string& name, int value) { flags[name] = value; } @@ -325,10 +358,12 @@ void DialogueRuntime::presentLine(const Node& node) { presentation.portraitPath = node.portrait; presentation.backgroundPath.clear(); presentation.choices.clear(); - presentation.selectedChoice = 0; + presentation.selectedChoice = -1; presentation.revealCompleted = node.text.empty(); presentation.showCutsceneSubtitle = false; + presentation.cutsceneSkippable = false; presentation.cutsceneCamera = {}; + presentation.cutsceneImages.clear(); if (presentation.revealCompleted) { presentation.visibleText = node.text; @@ -372,7 +407,9 @@ void DialogueRuntime::presentChoices(const Node& node) { presentation.selectedChoice = -1; presentation.revealCompleted = true; presentation.showCutsceneSubtitle = false; + presentation.cutsceneSkippable = false; presentation.cutsceneCamera = {}; + presentation.cutsceneImages.clear(); } void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { @@ -400,7 +437,21 @@ void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::st cutsceneElapsedMs = 0; cutsceneTimerMs = 0; currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0; - cutsceneTotalDurationMs = std::max(activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene)); + int imageTrackDurationMs = 0; + for (size_t i = 0; i < activeCutscene->images.size(); ++i) { + const CutsceneImageCue& cue = activeCutscene->images[i]; + int cueEnd = cue.endMs; + if (cueEnd <= cue.startMs) { + if (i + 1 < activeCutscene->images.size()) { + cueEnd = std::max(activeCutscene->images[i + 1].startMs, cue.startMs); + } + else { + cueEnd = cue.startMs + std::max(cue.fadeInMs, 0) + std::max(cue.fadeOutMs, 0) + 1000; + } + } + imageTrackDurationMs = std::max(imageTrackDurationMs, cueEnd); + } + cutsceneTotalDurationMs = std::max({ activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene), imageTrackDurationMs }); if (cutsceneTotalDurationMs <= 0 && activeCutscene->lines.empty()) { cutsceneTotalDurationMs = 3000; } @@ -420,6 +471,7 @@ void DialogueRuntime::finishCutscene() { cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); if (!pendingNodeAfterCutscene.empty()) { const std::string nextNode = pendingNodeAfterCutscene; pendingNodeAfterCutscene.clear(); @@ -430,6 +482,35 @@ void DialogueRuntime::finishCutscene() { } } +void DialogueRuntime::syncCutsceneLineToElapsedTime() { + if (!activeCutscene || activeCutscene->lines.empty()) { + currentCutsceneLine = -1; + cutsceneTimerMs = 0; + return; + } + + int elapsed = std::max(cutsceneElapsedMs, 0); + int accumulatedMs = 0; + + for (size_t i = 0; i < activeCutscene->lines.size(); ++i) { + const CutsceneLine& line = activeCutscene->lines[i]; + const int durationMs = (line.durationMs > 0) + ? line.durationMs + : computeFallbackCutsceneDurationMs(line.text); + + if (elapsed < accumulatedMs + durationMs) { + currentCutsceneLine = static_cast(i); + cutsceneTimerMs = std::max(0, elapsed - accumulatedMs); + return; + } + + accumulatedMs += durationMs; + } + + currentCutsceneLine = -1; + cutsceneTimerMs = 0; +} + void DialogueRuntime::advanceCutsceneLine() { if (!activeCutscene) { stop(); @@ -491,6 +572,78 @@ CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const { return result; } +std::vector DialogueRuntime::evaluateCutsceneImages() const { + std::vector result; + if (!activeCutscene) { + return result; + } + + const std::string& fallbackPath = !currentCutsceneBackground.empty() + ? currentCutsceneBackground + : activeCutscene->background; + + if (activeCutscene->images.empty()) { + if (!fallbackPath.empty()) { + result.push_back({ fallbackPath, 1.0f }); + } + return result; + } + + const int effectiveTotalDuration = (cutsceneTotalDurationMs > 0) ? cutsceneTotalDurationMs : std::max(activeCutscene->durationMs, 1); + const int now = std::max(cutsceneElapsedMs, 0); + + for (size_t i = 0; i < activeCutscene->images.size(); ++i) { + const CutsceneImageCue& cue = activeCutscene->images[i]; + if (cue.path.empty()) { + continue; + } + + const int startMs = std::max(cue.startMs, 0); + int endMs = cue.endMs; + if (endMs <= startMs) { + if (i + 1 < activeCutscene->images.size()) { + endMs = std::max(activeCutscene->images[i + 1].startMs, startMs + 1); + } + else { + endMs = effectiveTotalDuration; + } + } + if (endMs <= startMs) { + endMs = startMs + 1; + } + + if (now < startMs || now > endMs) { + continue; + } + + float alpha = 1.0f; + if (cue.fadeInMs > 0 && now < startMs + cue.fadeInMs) { + alpha = std::clamp( + static_cast(now - startMs) / static_cast(cue.fadeInMs), + 0.0f, + 1.0f + ); + } + + if (alpha > 0.0f) { + result.push_back({ cue.path, alpha }); + } + } + + // Safety fallback: never leave the cutscene without an opaque image layer. + if (result.empty() && !fallbackPath.empty()) { + result.push_back({ fallbackPath, 1.0f }); + } + + // If the first active layer is still fading in, put an opaque fallback/base below it. + // This prevents the world from becoming visible behind the cutscene. + if (!result.empty() && result.front().alpha < 0.999f && !fallbackPath.empty() && result.front().path != fallbackPath) { + result.insert(result.begin(), { fallbackPath, 1.0f }); + } + + return result; +} + void DialogueRuntime::refreshCutscenePresentation() { if (!activeCutscene) { return; @@ -499,9 +652,11 @@ void DialogueRuntime::refreshCutscenePresentation() { presentation.mode = PresentationMode::Cutscene; presentation.backgroundPath = activeCutscene->background; presentation.cutsceneCamera = evaluateCutsceneCameraBlend(); + presentation.cutsceneImages = evaluateCutsceneImages(); + presentation.cutsceneSkippable = activeCutscene->skippable; presentation.choices.clear(); - presentation.selectedChoice = 0; + presentation.selectedChoice = -1; presentation.revealCompleted = true; const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(activeCutscene->lines.size()); @@ -516,26 +671,24 @@ void DialogueRuntime::refreshCutscenePresentation() { } 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 @@ -569,7 +722,6 @@ float DialogueRuntime::applyEasing(EasingType easing, float t) { default: return t; } -//>>>>>>> witcher001-cutscene } int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { @@ -630,13 +782,15 @@ bool DialogueRuntime::restoreSaveState(const json& state) { const std::string nodeId = state.value("currentNodeId", ""); pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", ""); - selectedChoice = state.value("selectedChoice", 0); + selectedChoice = state.value("selectedChoice", -1); currentCutsceneLine = state.value("currentCutsceneLine", -1); cutsceneTimerMs = state.value("cutsceneTimerMs", 0); const bool ok = nodeId.empty() ? true : enterNode(nodeId); if (mode == Mode::WaitingForChoice && !visibleChoices.empty()) { - selectedChoice = std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1); + if (selectedChoice >= 0) { + selectedChoice = std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1); + } presentation.selectedChoice = selectedChoice; } return ok; diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index 6021df8..e4eb135 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -27,6 +27,8 @@ public: void confirmAdvance(); void moveSelection(int delta); void selectChoice(int index); + bool canSkipCurrentCutscene() const; + void skipCurrentCutscene(); const PresentationModel& getPresentation() const { return presentation; } @@ -58,7 +60,7 @@ private: PresentationModel presentation; Mode mode = Mode::Inactive; - int selectedChoice = 0; + int selectedChoice = -1; float revealCharacters = 0.0f; float revealSpeedCharsPerSecond = 52.0f; @@ -77,10 +79,12 @@ private: void presentChoices(const Node& node); void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); void finishCutscene(); + void syncCutsceneLineToElapsedTime(); void advanceCutsceneLine(); void refreshCutscenePresentation(); CutsceneCameraBlendState evaluateCutsceneCameraBlend() const; + std::vector evaluateCutsceneImages() const; static float applyEasing(EasingType easing, float t); static int computeFallbackCutsceneDurationMs(const std::string& text); diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp index 38269b8..6b0f67a 100644 --- a/src/dialogue/DialogueSystem.cpp +++ b/src/dialogue/DialogueSystem.cpp @@ -29,6 +29,10 @@ void DialogueSystem::update(int deltaMs, const Eigen::Vector3f& playerPosition) } runtime.update(deltaMs); + overlay.update(runtime.getPresentation(), deltaMs); + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + } } void DialogueSystem::draw(Renderer& renderer) { @@ -40,6 +44,18 @@ bool DialogueSystem::handleKeyDown(SDL_Keycode key) { return false; } + if (runtime.isPlayingCutscene()) { + switch (key) { + case SDLK_RETURN: + case SDLK_SPACE: + case SDLK_e: + case SDLK_ESCAPE: + return true; + default: + return false; + } + } + switch (key) { case SDLK_RETURN: case SDLK_SPACE: @@ -89,7 +105,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) { bool advanceDialogue = false; const PresentationModel& model = runtime.getPresentation(); if (!overlay.handlePointerReleased(x, y, model, choiceIndex, advanceDialogue)) { - return false; + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + return true; + } + return runtime.isPlayingCutscene(); } if (choiceIndex >= 0) { @@ -103,6 +123,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) { return true; } + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + return true; + } + return true; } diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index dd73dd2..bb8f31f 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -135,6 +135,14 @@ struct CutsceneCameraSegment { EasingType easing = EasingType::EaseInOutSine; }; +struct CutsceneImageCue { + std::string path; + int startMs = 0; + int endMs = 0; + int fadeInMs = 0; + int fadeOutMs = 0; +}; + struct StaticCutsceneDefinition { std::string id; std::string background; @@ -142,6 +150,7 @@ struct StaticCutsceneDefinition { bool skippable = true; int durationMs = 0; std::vector cameraTrack; + std::vector images; std::vector lines; }; @@ -151,6 +160,11 @@ struct PresentedChoice { ChoiceKind kind = ChoiceKind::Main; }; +struct PresentedCutsceneImage { + std::string path; + float alpha = 1.0f; +}; + enum class PresentationMode { Hidden, Dialogue, @@ -177,17 +191,19 @@ struct PresentationModel { int selectedChoice = -1; bool revealCompleted = true; bool showCutsceneSubtitle = false; + bool cutsceneSkippable = false; CutsceneCameraBlendState cutsceneCamera; + std::vector cutsceneImages; }; struct SaveState { std::string dialogueId; std::string currentNodeId; std::string pendingNodeAfterCutscene; - std::unordered_set flags; + std::unordered_map flags; std::unordered_set consumedChoices; - int selectedChoice = 0; + int selectedChoice = -1; int currentCutsceneLine = -1; int cutsceneTimerMs = 0; bool active = false;