safe LMB hold-to-skip cutscenes and multiple images with crossfade

This commit is contained in:
vottozi 2026-04-25 01:32:16 +06:00
parent fe01a5d1b2
commit 0a073243ad
16 changed files with 801 additions and 58 deletions

View File

@ -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": []
}
]
}

BIN
resources/second_cutscene.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,3 +1,5 @@
#version 120
attribute vec3 vPosition; attribute vec3 vPosition;
attribute vec2 vTexCoord; attribute vec2 vTexCoord;
attribute vec3 vNormal; attribute vec3 vNormal;

View File

@ -1,3 +1,5 @@
#version 120
attribute vec3 vPosition; attribute vec3 vPosition;
attribute vec2 vTexCoord; attribute vec2 vTexCoord;
attribute vec3 vNormal; attribute vec3 vNormal;

View File

@ -81,9 +81,11 @@ namespace ZL
// so they are available immediately without waiting for resources.zip. // 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("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("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 #else
renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_desktop.fragment", CONST_ZIP_FILE); 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("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 #endif
loadingTexture = std::make_unique<Texture>(CreateTextureDataFromPng("resources/loading.png", "")); loadingTexture = std::make_unique<Texture>(CreateTextureDataFromPng("resources/loading.png", ""));
@ -422,6 +424,24 @@ namespace ZL
int mx = static_cast<int>((float)event.button.x / Environment::width * Environment::projectionWidth); int mx = static_cast<int>((float)event.button.x / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)event.button.y / Environment::height * Environment::projectionHeight); int my = static_cast<int>((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<float>(mx),
Environment::projectionHeight - static_cast<float>(my)
);
}
else {
currentLocation->dialogueSystem.handlePointerReleased(
static_cast<float>(mx),
Environment::projectionHeight - static_cast<float>(my)
);
}
continue;
}
if (event.type == SDL_MOUSEBUTTONDOWN) { if (event.type == SDL_MOUSEBUTTONDOWN) {
std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl; std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl;
handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my); handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
@ -464,6 +484,15 @@ namespace ZL
else if (event.type == SDL_MOUSEMOTION) { else if (event.type == SDL_MOUSEMOTION) {
int mx = static_cast<int>((float)event.motion.x / Environment::width * Environment::projectionWidth); int mx = static_cast<int>((float)event.motion.x / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)event.motion.y / Environment::height * Environment::projectionHeight); int my = static_cast<int>((float)event.motion.y / Environment::height * Environment::projectionHeight);
if (currentLocation && currentLocation->dialogueSystem.blocksGameplayInput()) {
currentLocation->dialogueSystem.handlePointerMoved(
static_cast<float>(mx),
Environment::projectionHeight - static_cast<float>(my)
);
continue;
}
handleMotion(ZL::UiManager::MOUSE_FINGER_ID, mx, my); handleMotion(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
if (currentLocation) if (currentLocation)
{ {
@ -495,6 +524,18 @@ namespace ZL
case SDLK_3: case SDLK_3:
if (audioPlayer) audioPlayer->stopMusicAsync(); if (audioPlayer) audioPlayer->stopMusicAsync();
break; 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: case SDLK_f:
currentLocation->dialogueSystem.startDialogue("test_choice_dialogue"); currentLocation->dialogueSystem.startDialogue("test_choice_dialogue");
break; break;

View File

@ -102,7 +102,7 @@ namespace ZL
dialogueSystem.init(renderer, CONST_ZIP_FILE); dialogueSystem.init(renderer, CONST_ZIP_FILE);
dialogueSystem.loadDatabase("resources/dialogue/sample_dialogues.json"); dialogueSystem.loadDatabase("resources/dialogue/cutscene_image_tests.json");
/*dialogueSystem.addTriggerZone({ /*dialogueSystem.addTriggerZone({
"ghost_room_trigger", "ghost_room_trigger",
"test_line_dialogue", "test_line_dialogue",
@ -624,6 +624,10 @@ namespace ZL
} }
void Location::handleUp(int64_t fingerId, int mx, int my) void Location::handleUp(int64_t fingerId, int mx, int my)
{ {
if (dialogueSystem.blocksGameplayInput()) {
dialogueSystem.handlePointerReleased(static_cast<float>(mx), Environment::projectionHeight - static_cast<float>(my));
return;
}
} }
void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my) void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my)

View File

@ -180,6 +180,16 @@ CutsceneCameraSegment DialogueDatabase::parseCutsceneCameraSegment(const json& j
return segment; 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 DialogueDatabase::parseCutscene(const json& j) {
StaticCutsceneDefinition cutscene; StaticCutsceneDefinition cutscene;
cutscene.id = j.value("id", ""); 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()) { if (j.contains("lines") && j["lines"].is_array()) {
for (const auto& item : j["lines"]) { for (const auto& item : j["lines"]) {
cutscene.lines.push_back(parseCutsceneLine(item)); cutscene.lines.push_back(parseCutsceneLine(item));

View File

@ -34,6 +34,7 @@ private:
static CutsceneLine parseCutsceneLine(const json& j); static CutsceneLine parseCutsceneLine(const json& j);
static CutsceneCameraPose parseCutsceneCameraPose(const json& j); static CutsceneCameraPose parseCutsceneCameraPose(const json& j);
static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j); static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j);
static CutsceneImageCue parseCutsceneImageCue(const json& j);
static StaticCutsceneDefinition parseCutscene(const json& j); static StaticCutsceneDefinition parseCutscene(const json& j);
}; };

View File

@ -85,17 +85,67 @@ bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) {
} }
void DialogueOverlay::update(const PresentationModel& model, int deltaMs) { 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) { if (model.mode != PresentationMode::Choice) {
hoveredChoiceIndex = -1; 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) { void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) {
if (model.mode == PresentationMode::Hidden) { if (model.mode == PresentationMode::Hidden) {
lastChoiceRects.clear(); lastChoiceRects.clear();
lastDialogueAdvanceRect = {}; lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = {}; lastCutsceneAdvanceRect = {};
cutsceneAdvanceEnabled = false; cutsceneSkipHintVisible = false;
cutsceneSkipArmed = false;
cutsceneSkipHolding = false;
cutsceneSkipHintRemainingMs = 0;
cutsceneSkipHoldElapsedMs = 0;
return; 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 }; 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 }; lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h };
lastCutsceneAdvanceRect = {}; 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 || if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h ||
portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) {
@ -317,48 +371,57 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
lastDialogueAdvanceRect = {}; lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = subtitleRect; lastCutsceneAdvanceRect = subtitleRect;
cutsceneAdvanceEnabled = model.showCutsceneSubtitle;
std::shared_ptr<Texture> bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath);
glEnable(GL_BLEND); glEnable(GL_BLEND);
renderer.shaderManager.PushShader(defaultShaderName);
renderer.shaderManager.PushShader("cutsceneFade");
renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform1i(textureUniformName, 0);
renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.LoadIdentity(); renderer.LoadIdentity();
if (bgTexture) { const UiRect screenRect{ 0.0f, 0.0f, W, H };
const float texW = static_cast<float>(bgTexture->getWidth());
const float texH = static_cast<float>(bgTexture->getHeight());
ResolvedViewport currentViewport{}; std::vector<PresentedCutsceneImage> 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<float>(texture->getWidth());
const float texH = static_cast<float>(texture->getHeight());
ResolvedViewport layerViewport{};
if (model.cutsceneCamera.active) { if (model.cutsceneCamera.active) {
const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, texW, texH, W, H); const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, texW, texH, W, H);
const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, texW, texH, W, H); const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, texW, texH, W, H);
layerViewport = blendViewport(
currentViewport = blendViewport(
fromViewport, fromViewport,
toViewport, toViewport,
std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f) std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f)
); );
} }
else { else {
currentViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H); layerViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H);
} }
const float halfW = currentViewport.widthPx * 0.5f; const float halfW = layerViewport.widthPx * 0.5f;
const float halfH = currentViewport.heightPx * 0.5f; const float halfH = layerViewport.heightPx * 0.5f;
const float rotationRad = currentViewport.rotationDeg * 3.14159265358979323846f / 180.0f; const float rotationRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f;
const float c = std::cos(rotationRad); const float c = std::cos(rotationRad);
const float s = std::sin(rotationRad); const float s = std::sin(rotationRad);
auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f { auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f {
return { return {
currentViewport.centerXPx + x * c - y * s, layerViewport.centerXPx + x * c - y * s,
currentViewport.centerYPx + x * s + y * c 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( backgroundQuad.rebuildWithUV(
screenRect, screenRect,
toUV(srcBL), toUV(srcBL),
@ -384,14 +446,47 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
toUV(srcBR) 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) { if (model.showCutsceneSubtitle) {
subtitleQuad.rebuild(subtitleRect); subtitleQuad.rebuild(subtitleRect);
drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); 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<float>(cutsceneSkipHoldElapsedMs) / static_cast<float>(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.PopMatrix();
renderer.PopProjectionMatrix(); renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader(); 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); 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 DialogueOverlay::handlePointerDown(float x, float y, const PresentationModel& model) {
(void)x;
(void)y;
if (model.mode == PresentationMode::Choice) { if (model.mode == PresentationMode::Choice) {
handlePointerMoved(x, y, model); handlePointerMoved(x, y, model);
return; 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) { 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 (model.mode == PresentationMode::Dialogue) {
if (lastDialogueAdvanceRect.contains(x, y)) { outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y);
outAdvanceDialogue = true; return outAdvanceDialogue;
return true;
}
return false;
} }
if (model.mode == PresentationMode::Cutscene) { if (model.mode == PresentationMode::Cutscene) {
return cutsceneAdvanceEnabled && lastCutsceneAdvanceRect.contains(x, y); if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) {
} cutsceneSkipHolding = false;
cutsceneSkipHoldElapsedMs = 0;
}
return true;
}
return false; return false;
} }

View File

@ -21,6 +21,7 @@ public:
void handlePointerDown(float x, float y, const PresentationModel& model); void handlePointerDown(float x, float y, const PresentationModel& model);
void handlePointerMoved(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 handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue);
bool consumeSkipRequested();
private: private:
struct TexturedQuad { struct TexturedQuad {
@ -59,10 +60,22 @@ private:
mutable std::vector<UiRect> lastChoiceRects; mutable std::vector<UiRect> lastChoiceRects;
mutable UiRect lastDialogueAdvanceRect{}; mutable UiRect lastDialogueAdvanceRect{};
mutable UiRect lastCutsceneAdvanceRect{}; mutable UiRect lastCutsceneAdvanceRect{};
mutable bool cutsceneAdvanceEnabled = false; mutable UiRect lastCutsceneSkipRect{};
int hoveredChoiceIndex = -1; 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<TextRenderer> nameRenderer; std::unique_ptr<TextRenderer> nameRenderer;
std::unique_ptr<TextRenderer> bodyRenderer; std::unique_ptr<TextRenderer> bodyRenderer;
std::unique_ptr<TextRenderer> choiceRenderer; std::unique_ptr<TextRenderer> choiceRenderer;
@ -72,6 +85,9 @@ private:
TexturedQuad textboxQuad; TexturedQuad textboxQuad;
TexturedQuad subtitleQuad; TexturedQuad subtitleQuad;
TexturedQuad backgroundQuad; TexturedQuad backgroundQuad;
TexturedQuad skipHintBgQuad;
TexturedQuad skipProgressBgQuad;
TexturedQuad skipProgressFillQuad;
mutable std::vector<TexturedQuad> choiceQuads; mutable std::vector<TexturedQuad> choiceQuads;
std::unordered_map<std::string, std::shared_ptr<Texture>> textureCache; std::unordered_map<std::string, std::shared_ptr<Texture>> textureCache;

View File

@ -27,12 +27,13 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) {
currentNodeId.clear(); currentNodeId.clear();
pendingNodeAfterCutscene.clear(); pendingNodeAfterCutscene.clear();
visibleChoices.clear(); visibleChoices.clear();
selectedChoice = 0; selectedChoice = -1;
revealCharacters = 0.0f; revealCharacters = 0.0f;
currentCutsceneLine = -1; currentCutsceneLine = -1;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
cutsceneElapsedMs = 0; cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0; cutsceneTotalDurationMs = 0;
currentCutsceneBackground.clear();
presentation = {}; presentation = {};
presentation.dialogueId = dialogue->id; presentation.dialogueId = dialogue->id;
@ -45,12 +46,13 @@ void DialogueRuntime::stop() {
currentNodeId.clear(); currentNodeId.clear();
pendingNodeAfterCutscene.clear(); pendingNodeAfterCutscene.clear();
visibleChoices.clear(); visibleChoices.clear();
selectedChoice = 0; selectedChoice = -1;
revealCharacters = 0.0f; revealCharacters = 0.0f;
currentCutsceneLine = -1; currentCutsceneLine = -1;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
cutsceneElapsedMs = 0; cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0; cutsceneTotalDurationMs = 0;
currentCutsceneBackground.clear();
mode = Mode::Inactive; mode = Mode::Inactive;
presentation = {}; presentation = {};
} }
@ -155,11 +157,11 @@ void DialogueRuntime::confirmAdvance() {
} }
if (mode == Mode::WaitingForChoice) { if (mode == Mode::WaitingForChoice) {
if (visibleChoices.empty()) { if (visibleChoices.empty() || selectedChoice < 0 || selectedChoice >= static_cast<int>(visibleChoices.size())) {
return; return;
} }
const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1)]; const Choice& choice = visibleChoices[selectedChoice];
if (choice.consumeOnce && !choice.id.empty()) { if (choice.consumeOnce && !choice.id.empty()) {
consumedChoices.insert(choice.id); consumedChoices.insert(choice.id);
} }
@ -169,13 +171,7 @@ void DialogueRuntime::confirmAdvance() {
} }
if (mode == Mode::PlayingCutscene) { if (mode == Mode::PlayingCutscene) {
if (!activeCutscene || activeCutscene->lines.empty()) { return;
return;
}
if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
advanceCutsceneLine();
}
} }
} }
@ -208,6 +204,43 @@ void DialogueRuntime::selectChoice(int index) {
presentation.selectedChoice = selectedChoice; 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) { void DialogueRuntime::setFlag(const std::string& name, int value) {
flags[name] = value; flags[name] = value;
} }
@ -325,10 +358,12 @@ void DialogueRuntime::presentLine(const Node& node) {
presentation.portraitPath = node.portrait; presentation.portraitPath = node.portrait;
presentation.backgroundPath.clear(); presentation.backgroundPath.clear();
presentation.choices.clear(); presentation.choices.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = -1;
presentation.revealCompleted = node.text.empty(); presentation.revealCompleted = node.text.empty();
presentation.showCutsceneSubtitle = false; presentation.showCutsceneSubtitle = false;
presentation.cutsceneSkippable = false;
presentation.cutsceneCamera = {}; presentation.cutsceneCamera = {};
presentation.cutsceneImages.clear();
if (presentation.revealCompleted) { if (presentation.revealCompleted) {
presentation.visibleText = node.text; presentation.visibleText = node.text;
@ -372,7 +407,9 @@ void DialogueRuntime::presentChoices(const Node& node) {
presentation.selectedChoice = -1; presentation.selectedChoice = -1;
presentation.revealCompleted = true; presentation.revealCompleted = true;
presentation.showCutsceneSubtitle = false; presentation.showCutsceneSubtitle = false;
presentation.cutsceneSkippable = false;
presentation.cutsceneCamera = {}; presentation.cutsceneCamera = {};
presentation.cutsceneImages.clear();
} }
void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { 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; cutsceneElapsedMs = 0;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 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()) { if (cutsceneTotalDurationMs <= 0 && activeCutscene->lines.empty()) {
cutsceneTotalDurationMs = 3000; cutsceneTotalDurationMs = 3000;
} }
@ -420,6 +471,7 @@ void DialogueRuntime::finishCutscene() {
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
cutsceneElapsedMs = 0; cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0; cutsceneTotalDurationMs = 0;
currentCutsceneBackground.clear();
if (!pendingNodeAfterCutscene.empty()) { if (!pendingNodeAfterCutscene.empty()) {
const std::string nextNode = pendingNodeAfterCutscene; const std::string nextNode = pendingNodeAfterCutscene;
pendingNodeAfterCutscene.clear(); 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<int>(i);
cutsceneTimerMs = std::max(0, elapsed - accumulatedMs);
return;
}
accumulatedMs += durationMs;
}
currentCutsceneLine = -1;
cutsceneTimerMs = 0;
}
void DialogueRuntime::advanceCutsceneLine() { void DialogueRuntime::advanceCutsceneLine() {
if (!activeCutscene) { if (!activeCutscene) {
stop(); stop();
@ -491,6 +572,78 @@ CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const {
return result; return result;
} }
std::vector<PresentedCutsceneImage> DialogueRuntime::evaluateCutsceneImages() const {
std::vector<PresentedCutsceneImage> 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<float>(now - startMs) / static_cast<float>(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() { void DialogueRuntime::refreshCutscenePresentation() {
if (!activeCutscene) { if (!activeCutscene) {
return; return;
@ -499,9 +652,11 @@ void DialogueRuntime::refreshCutscenePresentation() {
presentation.mode = PresentationMode::Cutscene; presentation.mode = PresentationMode::Cutscene;
presentation.backgroundPath = activeCutscene->background; presentation.backgroundPath = activeCutscene->background;
presentation.cutsceneCamera = evaluateCutsceneCameraBlend(); presentation.cutsceneCamera = evaluateCutsceneCameraBlend();
presentation.cutsceneImages = evaluateCutsceneImages();
presentation.cutsceneSkippable = activeCutscene->skippable;
presentation.choices.clear(); presentation.choices.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = -1;
presentation.revealCompleted = true; presentation.revealCompleted = true;
const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size()); const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size());
@ -516,26 +671,24 @@ void DialogueRuntime::refreshCutscenePresentation() {
} }
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
/*<<<<<<< HEAD
if (!line.background.empty()) { if (!line.background.empty()) {
currentCutsceneBackground = line.background; currentCutsceneBackground = line.background;
} }
presentation.mode = PresentationMode::Cutscene; presentation.mode = PresentationMode::Cutscene;
=======
>>>>>>> witcher001-cutscene*/
presentation.speaker = line.speaker; presentation.speaker = line.speaker;
presentation.fullText = line.text; presentation.fullText = line.text;
presentation.visibleText = line.text; presentation.visibleText = line.text;
presentation.portraitPath = line.portrait; presentation.portraitPath = line.portrait;
/*<<<<<<< HEAD
//presentation.backgroundPath = activeCutscene->background; //presentation.backgroundPath = activeCutscene->background;
presentation.backgroundPath = currentCutsceneBackground; presentation.backgroundPath = currentCutsceneBackground;
presentation.choices.clear(); presentation.choices.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = 0;
presentation.revealCompleted = true; presentation.revealCompleted = true;
=======*/
std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size() std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size()
<< " current=" << currentCutsceneLine << " current=" << currentCutsceneLine
@ -569,7 +722,6 @@ float DialogueRuntime::applyEasing(EasingType easing, float t) {
default: default:
return t; return t;
} }
//>>>>>>> witcher001-cutscene
} }
int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) {
@ -630,13 +782,15 @@ bool DialogueRuntime::restoreSaveState(const json& state) {
const std::string nodeId = state.value("currentNodeId", ""); const std::string nodeId = state.value("currentNodeId", "");
pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", ""); pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", "");
selectedChoice = state.value("selectedChoice", 0); selectedChoice = state.value("selectedChoice", -1);
currentCutsceneLine = state.value("currentCutsceneLine", -1); currentCutsceneLine = state.value("currentCutsceneLine", -1);
cutsceneTimerMs = state.value("cutsceneTimerMs", 0); cutsceneTimerMs = state.value("cutsceneTimerMs", 0);
const bool ok = nodeId.empty() ? true : enterNode(nodeId); const bool ok = nodeId.empty() ? true : enterNode(nodeId);
if (mode == Mode::WaitingForChoice && !visibleChoices.empty()) { if (mode == Mode::WaitingForChoice && !visibleChoices.empty()) {
selectedChoice = std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1); if (selectedChoice >= 0) {
selectedChoice = std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1);
}
presentation.selectedChoice = selectedChoice; presentation.selectedChoice = selectedChoice;
} }
return ok; return ok;

View File

@ -27,6 +27,8 @@ public:
void confirmAdvance(); void confirmAdvance();
void moveSelection(int delta); void moveSelection(int delta);
void selectChoice(int index); void selectChoice(int index);
bool canSkipCurrentCutscene() const;
void skipCurrentCutscene();
const PresentationModel& getPresentation() const { return presentation; } const PresentationModel& getPresentation() const { return presentation; }
@ -58,7 +60,7 @@ private:
PresentationModel presentation; PresentationModel presentation;
Mode mode = Mode::Inactive; Mode mode = Mode::Inactive;
int selectedChoice = 0; int selectedChoice = -1;
float revealCharacters = 0.0f; float revealCharacters = 0.0f;
float revealSpeedCharsPerSecond = 52.0f; float revealSpeedCharsPerSecond = 52.0f;
@ -77,10 +79,12 @@ private:
void presentChoices(const Node& node); void presentChoices(const Node& node);
void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene);
void finishCutscene(); void finishCutscene();
void syncCutsceneLineToElapsedTime();
void advanceCutsceneLine(); void advanceCutsceneLine();
void refreshCutscenePresentation(); void refreshCutscenePresentation();
CutsceneCameraBlendState evaluateCutsceneCameraBlend() const; CutsceneCameraBlendState evaluateCutsceneCameraBlend() const;
std::vector<PresentedCutsceneImage> evaluateCutsceneImages() const;
static float applyEasing(EasingType easing, float t); static float applyEasing(EasingType easing, float t);
static int computeFallbackCutsceneDurationMs(const std::string& text); static int computeFallbackCutsceneDurationMs(const std::string& text);

View File

@ -29,6 +29,10 @@ void DialogueSystem::update(int deltaMs, const Eigen::Vector3f& playerPosition)
} }
runtime.update(deltaMs); runtime.update(deltaMs);
overlay.update(runtime.getPresentation(), deltaMs);
if (overlay.consumeSkipRequested()) {
runtime.skipCurrentCutscene();
}
} }
void DialogueSystem::draw(Renderer& renderer) { void DialogueSystem::draw(Renderer& renderer) {
@ -40,6 +44,18 @@ bool DialogueSystem::handleKeyDown(SDL_Keycode key) {
return false; 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) { switch (key) {
case SDLK_RETURN: case SDLK_RETURN:
case SDLK_SPACE: case SDLK_SPACE:
@ -89,7 +105,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) {
bool advanceDialogue = false; bool advanceDialogue = false;
const PresentationModel& model = runtime.getPresentation(); const PresentationModel& model = runtime.getPresentation();
if (!overlay.handlePointerReleased(x, y, model, choiceIndex, advanceDialogue)) { if (!overlay.handlePointerReleased(x, y, model, choiceIndex, advanceDialogue)) {
return false; if (overlay.consumeSkipRequested()) {
runtime.skipCurrentCutscene();
return true;
}
return runtime.isPlayingCutscene();
} }
if (choiceIndex >= 0) { if (choiceIndex >= 0) {
@ -103,6 +123,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) {
return true; return true;
} }
if (overlay.consumeSkipRequested()) {
runtime.skipCurrentCutscene();
return true;
}
return true; return true;
} }

View File

@ -135,6 +135,14 @@ struct CutsceneCameraSegment {
EasingType easing = EasingType::EaseInOutSine; EasingType easing = EasingType::EaseInOutSine;
}; };
struct CutsceneImageCue {
std::string path;
int startMs = 0;
int endMs = 0;
int fadeInMs = 0;
int fadeOutMs = 0;
};
struct StaticCutsceneDefinition { struct StaticCutsceneDefinition {
std::string id; std::string id;
std::string background; std::string background;
@ -142,6 +150,7 @@ struct StaticCutsceneDefinition {
bool skippable = true; bool skippable = true;
int durationMs = 0; int durationMs = 0;
std::vector<CutsceneCameraSegment> cameraTrack; std::vector<CutsceneCameraSegment> cameraTrack;
std::vector<CutsceneImageCue> images;
std::vector<CutsceneLine> lines; std::vector<CutsceneLine> lines;
}; };
@ -151,6 +160,11 @@ struct PresentedChoice {
ChoiceKind kind = ChoiceKind::Main; ChoiceKind kind = ChoiceKind::Main;
}; };
struct PresentedCutsceneImage {
std::string path;
float alpha = 1.0f;
};
enum class PresentationMode { enum class PresentationMode {
Hidden, Hidden,
Dialogue, Dialogue,
@ -177,17 +191,19 @@ struct PresentationModel {
int selectedChoice = -1; int selectedChoice = -1;
bool revealCompleted = true; bool revealCompleted = true;
bool showCutsceneSubtitle = false; bool showCutsceneSubtitle = false;
bool cutsceneSkippable = false;
CutsceneCameraBlendState cutsceneCamera; CutsceneCameraBlendState cutsceneCamera;
std::vector<PresentedCutsceneImage> cutsceneImages;
}; };
struct SaveState { struct SaveState {
std::string dialogueId; std::string dialogueId;
std::string currentNodeId; std::string currentNodeId;
std::string pendingNodeAfterCutscene; std::string pendingNodeAfterCutscene;
std::unordered_set<std::string, int> flags; std::unordered_map<std::string, int> flags;
std::unordered_set<std::string> consumedChoices; std::unordered_set<std::string> consumedChoices;
int selectedChoice = 0; int selectedChoice = -1;
int currentCutsceneLine = -1; int currentCutsceneLine = -1;
int cutsceneTimerMs = 0; int cutsceneTimerMs = 0;
bool active = false; bool active = false;