safe LMB hold-to-skip cutscenes and multiple images with crossfade
This commit is contained in:
parent
fe01a5d1b2
commit
0a073243ad
290
resources/dialogue/cutscene_image_tests.json
Normal file
290
resources/dialogue/cutscene_image_tests.json
Normal 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
BIN
resources/second_cutscene.png
(Stored with Git LFS)
Normal file
Binary file not shown.
11
resources/shaders/cutscene_fade_desktop.fragment
Normal file
11
resources/shaders/cutscene_fade_desktop.fragment
Normal 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;
|
||||
}
|
||||
11
resources/shaders/cutscene_fade_web.fragment
Normal file
11
resources/shaders/cutscene_fade_web.fragment
Normal 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;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
#version 120
|
||||
|
||||
attribute vec3 vPosition;
|
||||
attribute vec2 vTexCoord;
|
||||
attribute vec3 vNormal;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
#version 120
|
||||
|
||||
attribute vec3 vPosition;
|
||||
attribute vec2 vTexCoord;
|
||||
attribute vec3 vNormal;
|
||||
|
||||
43
src/Game.cpp
43
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<Texture>(CreateTextureDataFromPng("resources/loading.png", ""));
|
||||
|
||||
@ -421,6 +423,24 @@ namespace ZL
|
||||
if (event.button.button == SDL_BUTTON_LEFT) {
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl;
|
||||
@ -464,11 +484,20 @@ namespace ZL
|
||||
else if (event.type == SDL_MOUSEMOTION) {
|
||||
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);
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
@ -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<float>(mx), Environment::projectionHeight - static_cast<float>(my));
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my)
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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<Texture> 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<float>(bgTexture->getWidth());
|
||||
const float texH = static_cast<float>(bgTexture->getHeight());
|
||||
const UiRect screenRect{ 0.0f, 0.0f, W, H };
|
||||
|
||||
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) {
|
||||
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<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.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;
|
||||
}
|
||||
|
||||
@ -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<UiRect> 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<TextRenderer> nameRenderer;
|
||||
std::unique_ptr<TextRenderer> bodyRenderer;
|
||||
std::unique_ptr<TextRenderer> choiceRenderer;
|
||||
@ -72,6 +85,9 @@ private:
|
||||
TexturedQuad textboxQuad;
|
||||
TexturedQuad subtitleQuad;
|
||||
TexturedQuad backgroundQuad;
|
||||
TexturedQuad skipHintBgQuad;
|
||||
TexturedQuad skipProgressBgQuad;
|
||||
TexturedQuad skipProgressFillQuad;
|
||||
mutable std::vector<TexturedQuad> choiceQuads;
|
||||
|
||||
std::unordered_map<std::string, std::shared_ptr<Texture>> textureCache;
|
||||
|
||||
@ -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<int>(visibleChoices.size())) {
|
||||
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()) {
|
||||
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<int>(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<int>(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<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() {
|
||||
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<int>(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<int>(visibleChoices.size()) - 1);
|
||||
if (selectedChoice >= 0) {
|
||||
selectedChoice = std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1);
|
||||
}
|
||||
presentation.selectedChoice = selectedChoice;
|
||||
}
|
||||
return ok;
|
||||
|
||||
@ -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<PresentedCutsceneImage> evaluateCutsceneImages() const;
|
||||
|
||||
static float applyEasing(EasingType easing, float t);
|
||||
static int computeFallbackCutsceneDurationMs(const std::string& text);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<CutsceneCameraSegment> cameraTrack;
|
||||
std::vector<CutsceneImageCue> images;
|
||||
std::vector<CutsceneLine> 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<PresentedCutsceneImage> cutsceneImages;
|
||||
};
|
||||
|
||||
struct SaveState {
|
||||
std::string dialogueId;
|
||||
std::string currentNodeId;
|
||||
std::string pendingNodeAfterCutscene;
|
||||
std::unordered_set<std::string, int> flags;
|
||||
std::unordered_map<std::string, int> flags;
|
||||
std::unordered_set<std::string> consumedChoices;
|
||||
int selectedChoice = 0;
|
||||
int selectedChoice = -1;
|
||||
int currentCutsceneLine = -1;
|
||||
int cutsceneTimerMs = 0;
|
||||
bool active = false;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user