improve dialogue advance and add flexible cutscene camera system

This commit is contained in:
vottozi 2026-04-15 20:42:41 +06:00
parent 422ff1fbe3
commit ceebb13719
10 changed files with 502 additions and 74 deletions

View File

@ -16,7 +16,7 @@
"id": "line_2", "id": "line_2",
"type": "Line", "type": "Line",
"speaker": "Hero", "speaker": "Hero",
"portrait": "", "portrait": "resources/hero.png",
"text": "I need answers.", "text": "I need answers.",
"next": "end_1" "next": "end_1"
}, },
@ -26,7 +26,6 @@
} }
] ]
}, },
{ {
"id": "test_choice_dialogue", "id": "test_choice_dialogue",
"start": "line_1", "start": "line_1",
@ -43,7 +42,7 @@
"id": "choice_1", "id": "choice_1",
"type": "Choice", "type": "Choice",
"speaker": "Hero", "speaker": "Hero",
"portrait": "", "portrait": "resources/hero.png",
"text": "Choose your answer.", "text": "Choose your answer.",
"choices": [ "choices": [
{ {
@ -74,7 +73,7 @@
"speaker": "Merchant", "speaker": "Merchant",
"portrait": "resources/ghost_avatar.png", "portrait": "resources/ghost_avatar.png",
"text": "Just a trader passing through.", "text": "Just a trader passing through.",
"next": "end_1" "next": "choice_1"
}, },
{ {
"id": "end_1", "id": "end_1",
@ -82,7 +81,6 @@
} }
] ]
}, },
{ {
"id": "test_condition_dialogue", "id": "test_condition_dialogue",
"start": "set_flag_1", "start": "set_flag_1",
@ -126,7 +124,6 @@
} }
] ]
}, },
{ {
"id": "test_cutscene_dialogue", "id": "test_cutscene_dialogue",
"start": "cutscene_start", "start": "cutscene_start",
@ -142,17 +139,69 @@
"type": "End" "type": "End"
} }
] ]
},
{
"id": "test_silent_cutscene_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_silent_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_cutscene_pan_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_pan_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
} }
], ],
"cutscenes": [ "cutscenes": [
{ {
"id": "test_cutscene_01", "id": "test_cutscene_01",
"background": "resources/first_cutscene.png", "background": "resources/first_cutscene.png",
"durationMs": 6800,
"cameraTrack": [
{
"durationMs": 2400,
"from": { "focusX": 0.50, "focusY": 0.55, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2200,
"from": { "focusX": 0.63, "focusY": 0.58, "zoom": 1.16, "rotationDeg": -1.0 },
"to": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 },
"easing": "EaseInOutCubic"
},
{
"durationMs": 2200,
"from": { "focusX": 0.74, "focusY": 0.52, "zoom": 1.30, "rotationDeg": -2.4 },
"to": { "focusX": 0.58, "focusY": 0.46, "zoom": 1.10, "rotationDeg": -0.6 },
"easing": "EaseOutSine"
}
],
"lines": [ "lines": [
{ {
"speaker": "Narrator", "speaker": "Narrator",
"portrait": "", "portrait": "resources/hero.png",
"text": "The air in the room turned cold.", "text": "The air in the room turned cold.",
"durationMs": 2200 "durationMs": 2200
}, },
@ -163,6 +212,83 @@
"durationMs": 2600 "durationMs": 2600
} }
] ]
},
{
"id": "test_cutscene_silent_01",
"background": "resources/first_cutscene.png",
"durationMs": 5200,
"cameraTrack": [
{
"durationMs": 2600,
"from": { "focusX": 0.40, "focusY": 0.54, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "focusX": 0.58, "focusY": 0.54, "zoom": 1.22, "rotationDeg": 0.8 },
"to": { "focusX": 0.72, "focusY": 0.48, "zoom": 1.34, "rotationDeg": -0.5 },
"easing": "EaseOutCubic"
}
],
"lines": []
},
{
"id": "test_cutscene_pan_01",
"background": "resources/first_cutscene.png",
"durationMs": 12000,
"cameraTrack": [
{
"durationMs": 1200,
"from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 },
"easing": "Linear"
},
{
"durationMs": 2500,
"from": { "focusX": 0.50, "focusY": 0.50, "zoom": 1.00, "rotationDeg": 0.0 },
"to": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 2600,
"from": { "focusX": 0.18, "focusY": 0.18, "zoom": 1.45, "rotationDeg": 0.0 },
"to": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
},
{
"durationMs": 1800,
"from": { "focusX": 0.82, "focusY": 0.18, "zoom": 1.48, "rotationDeg": 0.0 },
"to": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 },
"easing": "EaseInCubic"
},
{
"durationMs": 3900,
"from": { "focusX": 0.84, "focusY": 0.82, "zoom": 1.62, "rotationDeg": 0.0 },
"to": { "focusX": 0.16, "focusY": 0.84, "zoom": 1.35, "rotationDeg": 0.0 },
"easing": "EaseInOutSine"
}
],
"lines": [
{
"speaker": "Narrator",
"portrait": "",
"text": "The memory begins in silence.",
"durationMs": 2200
},
{
"speaker": "Narrator",
"portrait": "",
"text": "Something is drawing your eyes across the whole scene.",
"durationMs": 2800
},
{
"speaker": "Ghost",
"portrait": "resources/w/ghost_skin001.png",
"text": "Do not look away.",
"durationMs": 2400
}
]
} }
] ]
} }

View File

@ -516,14 +516,14 @@ namespace ZL
targetInteractiveObject = nullptr; targetInteractiveObject = nullptr;
} }
//for (auto& npc : npcs) npc->update(delta); //for (auto& npc : npcs) npc->update(delta);
}
if (player) { if (player) {
dialogueSystem.update(static_cast<int>(delta), player->position); dialogueSystem.update(static_cast<int>(delta), player->position);
} }
} }
} }
}
void Game::render() { void Game::render() {
ZL::CheckGlError(); ZL::CheckGlError();
@ -732,11 +732,11 @@ namespace ZL
if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { if (event.type == SDL_KEYDOWN && event.key.repeat == 0) {
switch (event.key.keysym.sym) { switch (event.key.keysym.sym) {
case SDLK_f: case SDLK_f:
dialogueSystem.startDialogue("test_line_dialogue"); dialogueSystem.startDialogue("test_choice_dialogue");
break; break;
case SDLK_e: case SDLK_e:
dialogueSystem.startDialogue("test_cutscene_dialogue"); dialogueSystem.startDialogue("test_cutscene_pan_dialogue");
break; break;
case SDLK_RETURN: case SDLK_RETURN:

View File

@ -29,6 +29,19 @@ ComparisonOp DialogueDatabase::parseComparisonOp(const std::string& value) {
return ComparisonOp::Exists; return ComparisonOp::Exists;
} }
EasingType DialogueDatabase::parseEasingType(const std::string& value) {
if (value == "EaseInSine") return EasingType::EaseInSine;
if (value == "EaseOutSine") return EasingType::EaseOutSine;
if (value == "EaseInOutSine") return EasingType::EaseInOutSine;
if (value == "EaseInQuad") return EasingType::EaseInQuad;
if (value == "EaseOutQuad") return EasingType::EaseOutQuad;
if (value == "EaseInOutQuad") return EasingType::EaseInOutQuad;
if (value == "EaseInCubic") return EasingType::EaseInCubic;
if (value == "EaseOutCubic") return EasingType::EaseOutCubic;
if (value == "EaseInOutCubic") return EasingType::EaseInOutCubic;
return EasingType::Linear;
}
Condition DialogueDatabase::parseCondition(const json& j) { Condition DialogueDatabase::parseCondition(const json& j) {
Condition c; Condition c;
c.flag = j.value("flag", ""); c.flag = j.value("flag", "");
@ -126,12 +139,44 @@ CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) {
return line; return line;
} }
CutsceneCameraPose DialogueDatabase::parseCutsceneCameraPose(const json& j) {
CutsceneCameraPose pose;
pose.focusX = j.value("focusX", 0.5f);
pose.focusY = j.value("focusY", 0.5f);
pose.zoom = j.value("zoom", 1.0f);
pose.rotationDeg = j.value("rotationDeg", 0.0f);
return pose;
}
CutsceneCameraSegment DialogueDatabase::parseCutsceneCameraSegment(const json& j) {
CutsceneCameraSegment segment;
segment.durationMs = j.value("durationMs", 0);
segment.easing = parseEasingType(j.value("easing", "EaseInOutSine"));
if (j.contains("from") && j["from"].is_object()) {
segment.from = parseCutsceneCameraPose(j["from"]);
}
if (j.contains("to") && j["to"].is_object()) {
segment.to = parseCutsceneCameraPose(j["to"]);
}
else {
segment.to = segment.from;
}
return segment;
}
StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) {
StaticCutsceneDefinition cutscene; StaticCutsceneDefinition cutscene;
cutscene.id = j.value("id", ""); cutscene.id = j.value("id", "");
cutscene.background = j.value("background", ""); cutscene.background = j.value("background", "");
cutscene.music = j.value("music", ""); cutscene.music = j.value("music", "");
cutscene.skippable = j.value("skippable", true); cutscene.skippable = j.value("skippable", true);
cutscene.durationMs = j.value("durationMs", 0);
if (j.contains("cameraTrack") && j["cameraTrack"].is_array()) {
for (const auto& item : j["cameraTrack"]) {
cutscene.cameraTrack.push_back(parseCutsceneCameraSegment(item));
}
}
if (j.contains("lines") && j["lines"].is_array()) { if (j.contains("lines") && j["lines"].is_array()) {
for (const auto& item : j["lines"]) { for (const auto& item : j["lines"]) {

View File

@ -23,6 +23,7 @@ private:
static NodeType parseNodeType(const std::string& value); static NodeType parseNodeType(const std::string& value);
static ChoiceKind parseChoiceKind(const std::string& value); static ChoiceKind parseChoiceKind(const std::string& value);
static ComparisonOp parseComparisonOp(const std::string& value); static ComparisonOp parseComparisonOp(const std::string& value);
static EasingType parseEasingType(const std::string& value);
static Condition parseCondition(const json& j); static Condition parseCondition(const json& j);
static Effect parseEffect(const json& j); static Effect parseEffect(const json& j);
@ -30,9 +31,9 @@ private:
static Node parseNode(const json& j); static Node parseNode(const json& j);
static DialogueDefinition parseDialogue(const json& j); static DialogueDefinition parseDialogue(const json& j);
static CutsceneLine parseCutsceneLine(const json& j); static CutsceneLine parseCutsceneLine(const json& j);
static CutsceneCameraPose parseCutsceneCameraPose(const json& j);
static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j);
static StaticCutsceneDefinition parseCutscene(const json& j); static StaticCutsceneDefinition parseCutscene(const json& j);
}; };
} // namespace ZL::Dialogue } // namespace ZL::Dialogue

View File

@ -4,6 +4,8 @@
#include "GameConstants.h" #include "GameConstants.h"
#include "Environment.h" #include "Environment.h"
#include <algorithm> #include <algorithm>
#include <array>
#include <cmath>
namespace ZL::Dialogue { namespace ZL::Dialogue {
@ -46,6 +48,9 @@ bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) {
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 = {};
lastCutsceneAdvanceRect = {};
cutsceneAdvanceEnabled = false;
return; return;
} }
@ -59,10 +64,13 @@ void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) {
void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) { void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) {
const float W = Environment::projectionWidth; const float W = Environment::projectionWidth;
const float H = Environment::projectionHeight; // const float H = Environment::projectionHeight;
const UiRect portraitRect{ 24.0f, 24.0f, 182.0f, 182.0f }; const UiRect portraitRect{ 24.0f, 24.0f, 182.0f, 182.0f };
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 };
lastCutsceneAdvanceRect = {};
cutsceneAdvanceEnabled = false;
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) {
@ -76,7 +84,7 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel&
glEnable(GL_BLEND); glEnable(GL_BLEND);
renderer.shaderManager.PushShader(defaultShaderName); renderer.shaderManager.PushShader(defaultShaderName);
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, Environment::projectionHeight, -10.0f, 10.0f);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.LoadIdentity(); renderer.LoadIdentity();
@ -113,7 +121,7 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel&
renderer.shaderManager.PushShader(defaultShaderName); renderer.shaderManager.PushShader(defaultShaderName);
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, Environment::projectionHeight, -10.0f, 10.0f);
renderer.PushMatrix(); renderer.PushMatrix();
renderer.LoadIdentity(); renderer.LoadIdentity();
@ -162,11 +170,12 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel&
void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) { void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) {
const float W = Environment::projectionWidth; const float W = Environment::projectionWidth;
const float H = Environment::projectionHeight; const float H = Environment::projectionHeight;
const UiRect fullscreenRect{ 0.0f, 0.0f, W, H };
const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f }; const UiRect subtitleRect{ W * 0.12f, 22.0f, W * 0.76f, 110.0f };
lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = subtitleRect;
cutsceneAdvanceEnabled = model.showCutsceneSubtitle;
backgroundQuad.rebuild(fullscreenRect); std::shared_ptr<Texture> bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath);
subtitleQuad.rebuild(subtitleRect);
glEnable(GL_BLEND); glEnable(GL_BLEND);
renderer.shaderManager.PushShader(defaultShaderName); renderer.shaderManager.PushShader(defaultShaderName);
@ -175,15 +184,39 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
renderer.PushMatrix(); renderer.PushMatrix();
renderer.LoadIdentity(); renderer.LoadIdentity();
if (!model.backgroundPath.empty()) { if (bgTexture) {
drawQuad(renderer, backgroundQuad, loadTextureCached(model.backgroundPath)); const float texW = static_cast<float>(bgTexture->getWidth());
const float texH = static_cast<float>(bgTexture->getHeight());
const float baseScale = max(W / max(texW, 1.0f), H / max(texH, 1.0f));
const float zoom = max(model.cutsceneCamera.zoom, 0.01f);
const float drawW = texW * baseScale * zoom;
const float drawH = texH * baseScale * zoom;
const float focusX = std::clamp(model.cutsceneCamera.focusX, 0.0f, 1.0f);
const float focusY = std::clamp(model.cutsceneCamera.focusY, 0.0f, 1.0f);
const float localFocusX = -drawW * 0.5f + drawW * focusX;
const float localFocusY = -drawH * 0.5f + drawH * focusY;
const float rotationRad = model.cutsceneCamera.rotationDeg * 3.14159265358979323846f / 180.0f;
const UiRect backgroundRect{ -drawW * 0.5f, -drawH * 0.5f, drawW, drawH };
backgroundQuad.rebuild(backgroundRect);
renderer.TranslateMatrix({ W * 0.5f, H * 0.5f, 0.0f });
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(rotationRad, Eigen::Vector3f::UnitZ())));
renderer.TranslateMatrix({ -localFocusX, -localFocusY, 0.0f });
drawQuad(renderer, backgroundQuad, bgTexture);
renderer.LoadIdentity();
} }
if (model.showCutsceneSubtitle) {
subtitleQuad.rebuild(subtitleRect);
drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture);
}
renderer.PopMatrix(); renderer.PopMatrix();
renderer.PopProjectionMatrix(); renderer.PopProjectionMatrix();
renderer.shaderManager.PopShader(); renderer.shaderManager.PopShader();
if (model.showCutsceneSubtitle) {
if (!model.speaker.empty()) { if (!model.speaker.empty()) {
nameRenderer->drawText(model.speaker, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 32.0f, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }); nameRenderer->drawText(model.speaker, subtitleRect.x + 24.0f, subtitleRect.y + subtitleRect.h - 32.0f, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f });
} }
@ -195,22 +228,32 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel&
false, false,
{ 1.0f, 1.0f, 1.0f, 1.0f } { 1.0f, 1.0f, 1.0f, 1.0f }
); );
}
glDisable(GL_BLEND); glDisable(GL_BLEND);
} }
bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const { bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const {
outChoiceIndex = -1; outChoiceIndex = -1;
if (model.mode != PresentationMode::Choice) {
return false;
}
if (model.mode == PresentationMode::Choice) {
for (size_t i = 0; i < lastChoiceRects.size(); ++i) { for (size_t i = 0; i < lastChoiceRects.size(); ++i) {
if (lastChoiceRects[i].contains(x, y)) { if (lastChoiceRects[i].contains(x, y)) {
outChoiceIndex = static_cast<int>(i); outChoiceIndex = static_cast<int>(i);
return true; return true;
} }
} }
return lastDialogueAdvanceRect.contains(x, y);
}
if (model.mode == PresentationMode::Dialogue) {
return lastDialogueAdvanceRect.contains(x, y);
}
if (model.mode == PresentationMode::Cutscene) {
return cutsceneAdvanceEnabled && lastCutsceneAdvanceRect.contains(x, y);
}
return false; return false;
} }

View File

@ -18,6 +18,7 @@ public:
void draw(Renderer& renderer, const PresentationModel& model); void draw(Renderer& renderer, const PresentationModel& model);
// Coordinates are expected in the game's UI projection space // Coordinates are expected in the game's UI projection space
// Returns true only when the click should advance/select.
bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const; bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const;
private: private:
@ -40,6 +41,9 @@ private:
std::shared_ptr<Texture> cutsceneSubtitleTexture; std::shared_ptr<Texture> cutsceneSubtitleTexture;
mutable std::vector<UiRect> lastChoiceRects; mutable std::vector<UiRect> lastChoiceRects;
mutable UiRect lastDialogueAdvanceRect{};
mutable UiRect lastCutsceneAdvanceRect{};
mutable bool cutsceneAdvanceEnabled = false;
std::unique_ptr<TextRenderer> nameRenderer; std::unique_ptr<TextRenderer> nameRenderer;
std::unique_ptr<TextRenderer> bodyRenderer; std::unique_ptr<TextRenderer> bodyRenderer;

View File

@ -1,6 +1,7 @@
#include "dialogue/DialogueRuntime.h" #include "dialogue/DialogueRuntime.h"
#include <algorithm> #include <algorithm>
#include <cmath>
#include <iostream> #include <iostream>
namespace ZL::Dialogue { namespace ZL::Dialogue {
@ -30,6 +31,8 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) {
revealCharacters = 0.0f; revealCharacters = 0.0f;
currentCutsceneLine = -1; currentCutsceneLine = -1;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0;
presentation = {}; presentation = {};
presentation.dialogueId = dialogue->id; presentation.dialogueId = dialogue->id;
@ -46,6 +49,8 @@ void DialogueRuntime::stop() {
revealCharacters = 0.0f; revealCharacters = 0.0f;
currentCutsceneLine = -1; currentCutsceneLine = -1;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0;
mode = Mode::Inactive; mode = Mode::Inactive;
presentation = {}; presentation = {};
} }
@ -63,20 +68,59 @@ void DialogueRuntime::update(int deltaMs) {
} }
if (mode == Mode::PlayingCutscene && activeCutscene) { if (mode == Mode::PlayingCutscene && activeCutscene) {
if (currentCutsceneLine < 0 || currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size())) { cutsceneElapsedMs += deltaMs;
advanceCutsceneLine();
return; if (!activeCutscene->lines.empty() &&
} currentCutsceneLine >= 0 &&
currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
if (line.waitForConfirm) { if (!line.waitForConfirm) {
cutsceneTimerMs += deltaMs;
const int durationMs =
(line.durationMs > 0)
? line.durationMs
: computeFallbackCutsceneDurationMs(line.text);
if (cutsceneTimerMs >= durationMs) {
advanceCutsceneLine();
// ВАЖНО: после advance катсцена могла завершиться
if (!activeCutscene || mode != Mode::PlayingCutscene) {
return;
}
}
}
}
if (!activeCutscene || mode != Mode::PlayingCutscene) {
return; return;
} }
cutsceneTimerMs += deltaMs; refreshCutscenePresentation();
const int durationMs = (line.durationMs > 0) ? line.durationMs : computeFallbackCutsceneDurationMs(line.text);
if (cutsceneTimerMs >= durationMs) { if (!activeCutscene || mode != Mode::PlayingCutscene) {
advanceCutsceneLine(); return;
}
const bool subtitlesFinished =
activeCutscene->lines.empty() ||
currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size());
const bool durationFinished =
cutsceneTotalDurationMs > 0 &&
cutsceneElapsedMs >= cutsceneTotalDurationMs;
if (activeCutscene->lines.empty()) {
if (durationFinished) {
finishCutscene();
}
return;
}
if (subtitlesFinished && (cutsceneTotalDurationMs <= 0 || durationFinished)) {
finishCutscene();
return;
} }
} }
} }
@ -125,9 +169,15 @@ void DialogueRuntime::confirmAdvance() {
} }
if (mode == Mode::PlayingCutscene) { if (mode == Mode::PlayingCutscene) {
if (!activeCutscene || activeCutscene->lines.empty()) {
return;
}
if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
advanceCutsceneLine(); advanceCutsceneLine();
} }
} }
}
void DialogueRuntime::moveSelection(int delta) { void DialogueRuntime::moveSelection(int delta) {
if (mode != Mode::WaitingForChoice || visibleChoices.empty()) { if (mode != Mode::WaitingForChoice || visibleChoices.empty()) {
@ -261,6 +311,8 @@ void DialogueRuntime::presentLine(const Node& node) {
presentation.choices.clear(); presentation.choices.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = 0;
presentation.revealCompleted = node.text.empty(); presentation.revealCompleted = node.text.empty();
presentation.showCutsceneSubtitle = false;
presentation.cutsceneCamera = {};
if (presentation.revealCompleted) { if (presentation.revealCompleted) {
presentation.visibleText = node.text; presentation.visibleText = node.text;
@ -303,6 +355,8 @@ void DialogueRuntime::presentChoices(const Node& node) {
presentation.backgroundPath.clear(); presentation.backgroundPath.clear();
presentation.selectedChoice = 0; presentation.selectedChoice = 0;
presentation.revealCompleted = true; presentation.revealCompleted = true;
presentation.showCutsceneSubtitle = false;
presentation.cutsceneCamera = {};
} }
void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) {
@ -326,22 +380,29 @@ void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::st
activeCutscene = cutscene; activeCutscene = cutscene;
pendingNodeAfterCutscene = nextNodeAfterCutscene; pendingNodeAfterCutscene = nextNodeAfterCutscene;
mode = Mode::PlayingCutscene; mode = Mode::PlayingCutscene;
cutsceneElapsedMs = 0;
cutsceneTimerMs = 0;
currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0;
cutsceneTotalDurationMs = std::max(activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene));
if (cutsceneTotalDurationMs <= 0 && activeCutscene->lines.empty()) {
cutsceneTotalDurationMs = 3000;
}
refreshCutscenePresentation();
std::cout << "[CUTSCENE] start id=" << cutsceneId
<< " lines=" << activeCutscene->lines.size()
<< " totalDuration=" << cutsceneTotalDurationMs
<< std::endl;
}
void DialogueRuntime::finishCutscene() {
std::cout << "[CUTSCENE] finish nextNode=" << pendingNodeAfterCutscene << std::endl;
activeCutscene = nullptr;
currentCutsceneLine = -1; currentCutsceneLine = -1;
cutsceneTimerMs = 0; cutsceneTimerMs = 0;
advanceCutsceneLine(); cutsceneElapsedMs = 0;
} cutsceneTotalDurationMs = 0;
void DialogueRuntime::advanceCutsceneLine() {
if (!activeCutscene) {
stop();
return;
}
++currentCutsceneLine;
cutsceneTimerMs = 0;
if (currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size())) {
activeCutscene = nullptr;
if (!pendingNodeAfterCutscene.empty()) { if (!pendingNodeAfterCutscene.empty()) {
const std::string nextNode = pendingNodeAfterCutscene; const std::string nextNode = pendingNodeAfterCutscene;
pendingNodeAfterCutscene.clear(); pendingNodeAfterCutscene.clear();
@ -350,28 +411,129 @@ void DialogueRuntime::advanceCutsceneLine() {
else { else {
stop(); stop();
} }
}
void DialogueRuntime::advanceCutsceneLine() {
if (!activeCutscene) {
stop();
return;
}
if (activeCutscene->lines.empty()) {
return;
}
std::cout << "[CUTSCENE] advance before current=" << currentCutsceneLine << std::endl;
++currentCutsceneLine;
std::cout << "[CUTSCENE] advance after current=" << currentCutsceneLine << std::endl;
cutsceneTimerMs = 0;
if (currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size())) {
refreshCutscenePresentation();
if (cutsceneTotalDurationMs <= 0 || cutsceneElapsedMs >= cutsceneTotalDurationMs) {
finishCutscene();
}
return; return;
} }
refreshCutscenePresentation(); refreshCutscenePresentation();
} }
CutsceneCameraPose DialogueRuntime::evaluateCutsceneCameraPose() const {
CutsceneCameraPose defaultPose{};
if (!activeCutscene || activeCutscene->cameraTrack.empty()) {
return defaultPose;
}
int elapsed = cutsceneElapsedMs;
for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) {
const int durationMs = std::max(segment.durationMs, 1);
if (elapsed <= durationMs) {
const float rawT = static_cast<float>(elapsed) / static_cast<float>(durationMs);
const float t = applyEasing(segment.easing, std::clamp(rawT, 0.0f, 1.0f));
CutsceneCameraPose pose;
pose.focusX = segment.from.focusX + (segment.to.focusX - segment.from.focusX) * t;
pose.focusY = segment.from.focusY + (segment.to.focusY - segment.from.focusY) * t;
pose.zoom = segment.from.zoom + (segment.to.zoom - segment.from.zoom) * t;
pose.rotationDeg = segment.from.rotationDeg + (segment.to.rotationDeg - segment.from.rotationDeg) * t;
return pose;
}
elapsed -= durationMs;
}
return activeCutscene->cameraTrack.back().to;
}
void DialogueRuntime::refreshCutscenePresentation() { void DialogueRuntime::refreshCutscenePresentation() {
if (!activeCutscene || currentCutsceneLine < 0 || if (!activeCutscene) {
currentCutsceneLine >= static_cast<int>(activeCutscene->lines.size())) { return;
}
presentation.mode = PresentationMode::Cutscene;
presentation.backgroundPath = activeCutscene->background;
presentation.cutsceneCamera = evaluateCutsceneCameraPose();
std::cout << "[CUTSCENE] pose focus=("
<< presentation.cutsceneCamera.focusX << ", "
<< presentation.cutsceneCamera.focusY << ") zoom="
<< presentation.cutsceneCamera.zoom
<< " rot=" << presentation.cutsceneCamera.rotationDeg
<< " line=" << currentCutsceneLine
<< std::endl;
presentation.choices.clear();
presentation.selectedChoice = 0;
presentation.revealCompleted = true;
const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size());
presentation.showCutsceneSubtitle = hasSubtitle;
if (!hasSubtitle) {
presentation.speaker.clear();
presentation.fullText.clear();
presentation.visibleText.clear();
presentation.portraitPath.clear();
return; return;
} }
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
presentation.mode = PresentationMode::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;
presentation.backgroundPath = activeCutscene->background;
presentation.choices.clear(); std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size()
presentation.selectedChoice = 0; << " current=" << currentCutsceneLine
presentation.revealCompleted = true; << std::endl;
}
float DialogueRuntime::applyEasing(EasingType easing, float t) {
t = std::clamp(t, 0.0f, 1.0f);
constexpr float PI = 3.14159265358979323846f;
switch (easing) {
case EasingType::EaseInSine:
return 1.0f - std::cos((t * PI) * 0.5f);
case EasingType::EaseOutSine:
return std::sin((t * PI) * 0.5f);
case EasingType::EaseInOutSine:
return -(std::cos(PI * t) - 1.0f) * 0.5f;
case EasingType::EaseInQuad:
return t * t;
case EasingType::EaseOutQuad:
return 1.0f - (1.0f - t) * (1.0f - t);
case EasingType::EaseInOutQuad:
return (t < 0.5f) ? 2.0f * t * t : 1.0f - std::pow(-2.0f * t + 2.0f, 2.0f) * 0.5f;
case EasingType::EaseInCubic:
return t * t * t;
case EasingType::EaseOutCubic:
return 1.0f - std::pow(1.0f - t, 3.0f);
case EasingType::EaseInOutCubic:
return (t < 0.5f) ? 4.0f * t * t * t : 1.0f - std::pow(-2.0f * t + 2.0f, 3.0f) * 0.5f;
case EasingType::Linear:
default:
return t;
}
} }
int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) {
@ -382,6 +544,14 @@ int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text)
return std::max(minDuration, calculated + linger); return std::max(minDuration, calculated + linger);
} }
int DialogueRuntime::computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene) {
int total = 0;
for (const CutsceneCameraSegment& segment : cutscene.cameraTrack) {
total += std::max(segment.durationMs, 0);
}
return total;
}
DialogueRuntime::json DialogueRuntime::buildSaveState() const { DialogueRuntime::json DialogueRuntime::buildSaveState() const {
json result; json result;
result["active"] = isActive(); result["active"] = isActive();

View File

@ -63,6 +63,8 @@ private:
int currentCutsceneLine = -1; int currentCutsceneLine = -1;
int cutsceneTimerMs = 0; int cutsceneTimerMs = 0;
int cutsceneElapsedMs = 0;
int cutsceneTotalDurationMs = 0;
bool evaluateConditions(const std::vector<Condition>& conditions) const; bool evaluateConditions(const std::vector<Condition>& conditions) const;
void applyEffects(const std::vector<Effect>& effects); void applyEffects(const std::vector<Effect>& effects);
@ -71,11 +73,15 @@ private:
void presentLine(const Node& node); void presentLine(const Node& node);
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 advanceCutsceneLine(); void advanceCutsceneLine();
void refreshCutscenePresentation(); void refreshCutscenePresentation();
CutsceneCameraPose evaluateCutsceneCameraPose() const;
static float applyEasing(EasingType easing, float t);
static int computeFallbackCutsceneDurationMs(const std::string& text); static int computeFallbackCutsceneDurationMs(const std::string& text);
static int computeCameraTrackDurationMs(const StaticCutsceneDefinition& cutscene);
}; };
} // namespace ZL::Dialogue } // namespace ZL::Dialogue

View File

@ -73,12 +73,14 @@ bool DialogueSystem::handlePointerReleased(float x, float y) {
int choiceIndex = -1; int choiceIndex = -1;
const PresentationModel& model = runtime.getPresentation(); const PresentationModel& model = runtime.getPresentation();
if (overlay.handlePointerReleased(x, y, model, choiceIndex)) { if (!overlay.handlePointerReleased(x, y, model, choiceIndex)) {
while (model.selectedChoice != choiceIndex) { return false;
}
if (choiceIndex >= 0) {
while (runtime.getPresentation().selectedChoice != choiceIndex) {
runtime.moveSelection(1); runtime.moveSelection(1);
} }
runtime.confirmAdvance();
return true;
} }
runtime.confirmAdvance(); runtime.confirmAdvance();

View File

@ -31,6 +31,19 @@ enum class ComparisonOp {
LessOrEqual LessOrEqual
}; };
enum class EasingType {
Linear,
EaseInSine,
EaseOutSine,
EaseInOutSine,
EaseInQuad,
EaseOutQuad,
EaseInOutQuad,
EaseInCubic,
EaseOutCubic,
EaseInOutCubic
};
struct Condition { struct Condition {
std::string flag; std::string flag;
ComparisonOp op = ComparisonOp::Exists; ComparisonOp op = ComparisonOp::Exists;
@ -91,11 +104,27 @@ struct CutsceneLine {
bool waitForConfirm = false; bool waitForConfirm = false;
}; };
struct CutsceneCameraPose {
float focusX = 0.5f;
float focusY = 0.5f;
float zoom = 1.0f;
float rotationDeg = 0.0f;
};
struct CutsceneCameraSegment {
int durationMs = 0;
CutsceneCameraPose from;
CutsceneCameraPose to;
EasingType easing = EasingType::EaseInOutSine;
};
struct StaticCutsceneDefinition { struct StaticCutsceneDefinition {
std::string id; std::string id;
std::string background; std::string background;
std::string music; std::string music;
bool skippable = true; bool skippable = true;
int durationMs = 0;
std::vector<CutsceneCameraSegment> cameraTrack;
std::vector<CutsceneLine> lines; std::vector<CutsceneLine> lines;
}; };
@ -123,6 +152,8 @@ struct PresentationModel {
std::vector<PresentedChoice> choices; std::vector<PresentedChoice> choices;
int selectedChoice = 0; int selectedChoice = 0;
bool revealCompleted = true; bool revealCompleted = true;
bool showCutsceneSubtitle = false;
CutsceneCameraPose cutsceneCamera;
}; };
struct SaveState { struct SaveState {