shadow-over-bishkek001/src/dialogue/DialogueRuntime.cpp
2026-05-09 23:31:49 +03:00

875 lines
24 KiB
C++

#include "dialogue/DialogueRuntime.h"
#include <algorithm>
#include <cmath>
#include <iostream>
namespace ZL::Dialogue {
void DialogueRuntime::setDatabase(const DialogueDatabase* value) {
database = value;
}
bool DialogueRuntime::startDialogue(const std::string& dialogueId) {
if (!database) {
std::cerr << "[dialogue] No database assigned to runtime\n";
return false;
}
const DialogueDefinition* dialogue = database->findDialogue(dialogueId);
if (!dialogue) {
std::cerr << "[dialogue] Dialogue not found: " << dialogueId << "\n";
return false;
}
activeDialogue = dialogue;
activeCutscene = nullptr;
currentNodeId.clear();
pendingNodeAfterCutscene.clear();
visibleChoices.clear();
selectedChoice = -1;
revealCharacters = 0.0f;
currentCutsceneLine = -1;
cutsceneTimerMs = 0;
cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0;
cutsceneContentDurationMs = 0;
currentCutsceneBackground.clear();
presentation = {};
presentation.dialogueId = dialogue->id;
return enterNode(dialogue->startNode);
}
bool DialogueRuntime::startStandaloneCutscene(const std::string& cutsceneId) {
if (!database) {
std::cerr << "[dialogue] No database assigned to runtime\n";
return false;
}
const StaticCutsceneDefinition* def = database->findCutscene(cutsceneId);
if (!def) {
std::cerr << "[dialogue] Cutscene not found: " << cutsceneId << "\n";
return false;
}
activeDialogue = nullptr;
activeCutsceneId = cutsceneId;
presentation = {};
startCutscene(cutsceneId, "");
return true;
}
void DialogueRuntime::setOnCutsceneFinished(std::function<void(const std::string&)> cb) {
onCutsceneFinished = std::move(cb);
}
void DialogueRuntime::stop() {
activeDialogue = nullptr;
activeCutscene = nullptr;
currentNodeId.clear();
pendingNodeAfterCutscene.clear();
visibleChoices.clear();
selectedChoice = -1;
revealCharacters = 0.0f;
currentCutsceneLine = -1;
cutsceneTimerMs = 0;
cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0;
cutsceneContentDurationMs = 0;
currentCutsceneBackground.clear();
mode = Mode::Inactive;
presentation = {};
}
void DialogueRuntime::update(int deltaMs) {
if (mode == Mode::PresentingLine) {
if (!presentation.revealCompleted) {
revealCharacters += revealSpeedCharsPerSecond * (static_cast<float>(deltaMs) / 1000.0f);
const size_t fullLen = presentation.fullText.size();
const size_t visibleLen = static_cast<size_t>(std::min<float>(revealCharacters, static_cast<float>(fullLen)));
presentation.visibleText = presentation.fullText.substr(0, visibleLen);
presentation.revealCompleted = (visibleLen >= fullLen);
}
return;
}
if (mode == Mode::PlayingCutscene && activeCutscene) {
cutsceneElapsedMs += deltaMs;
if (!activeCutscene->lines.empty() &&
currentCutsceneLine >= 0 &&
currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
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;
}
refreshCutscenePresentation();
if (!activeCutscene || mode != Mode::PlayingCutscene) {
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;
}
}
}
void DialogueRuntime::confirmAdvance() {
if (mode == Mode::PresentingLine) {
if (!presentation.revealCompleted) {
presentation.visibleText = presentation.fullText;
presentation.revealCompleted = true;
revealCharacters = static_cast<float>(presentation.fullText.size());
return;
}
if (!activeDialogue) {
stop();
return;
}
auto it = activeDialogue->nodes.find(currentNodeId);
if (it == activeDialogue->nodes.end()) {
stop();
return;
}
if (!it->second.next.empty()) {
enterNode(it->second.next);
}
else {
stop();
}
return;
}
if (mode == Mode::WaitingForChoice) {
if (visibleChoices.empty() || selectedChoice < 0 || selectedChoice >= static_cast<int>(visibleChoices.size())) {
return;
}
const Choice& choice = visibleChoices[selectedChoice];
if (choice.consumeOnce && !choice.id.empty()) {
consumedChoices.insert(choice.id);
}
applyEffects(choice.effects);
enterNode(choice.next);
return;
}
if (mode == Mode::PlayingCutscene) {
return;
}
}
void DialogueRuntime::moveSelection(int delta) {
if (mode != Mode::WaitingForChoice || visibleChoices.empty()) {
return;
}
const int count = static_cast<int>(visibleChoices.size());
if (selectedChoice < 0 || selectedChoice >= count) {
selectedChoice = (delta >= 0) ? 0 : (count - 1);
}
else {
selectedChoice = (selectedChoice + delta) % count;
if (selectedChoice < 0) {
selectedChoice += count;
}
}
presentation.selectedChoice = selectedChoice;
}
void DialogueRuntime::selectChoice(int index) {
if (mode != Mode::WaitingForChoice || visibleChoices.empty()) {
return;
}
if (index < 0 || index >= static_cast<int>(visibleChoices.size())) {
return;
}
selectedChoice = 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;
}
int DialogueRuntime::getFlag(const std::string& name) const {
auto it = flags.find(name);
return (it != flags.end()) ? it->second : 0;
}
bool DialogueRuntime::evaluateConditions(const std::vector<Condition>& conditions) const {
for (const Condition& condition : conditions) {
const int currentValue = getFlag(condition.flag);
switch (condition.op) {
case ComparisonOp::Exists:
if (currentValue == 0) return false;
break;
case ComparisonOp::Equals:
if (currentValue != condition.value) return false;
break;
case ComparisonOp::NotEquals:
if (currentValue == condition.value) return false;
break;
case ComparisonOp::GreaterOrEqual:
if (currentValue < condition.value) return false;
break;
case ComparisonOp::LessOrEqual:
if (currentValue > condition.value) return false;
break;
}
}
return true;
}
void DialogueRuntime::applyEffects(const std::vector<Effect>& effects) {
for (const Effect& effect : effects) {
if (effect.flag.empty()) {
continue;
}
if (effect.relative) {
flags[effect.flag] += effect.value;
}
else {
flags[effect.flag] = effect.value;
}
}
}
bool DialogueRuntime::enterNode(const std::string& nodeId) {
if (!activeDialogue) {
stop();
return false;
}
auto it = activeDialogue->nodes.find(nodeId);
if (it == activeDialogue->nodes.end()) {
std::cerr << "[dialogue] Node not found: " << nodeId << " in dialogue " << activeDialogue->id << "\n";
stop();
return false;
}
const Node& node = it->second;
currentNodeId = node.id;
presentation.dialogueId = activeDialogue->id;
switch (node.type) {
case NodeType::Line:
presentLine(node);
return true;
case NodeType::Choice:
presentChoices(node);
return true;
case NodeType::Condition:
if (evaluateConditions(node.conditions)) {
return enterNode(!node.trueNext.empty() ? node.trueNext : node.next);
}
return enterNode(node.falseNext);
case NodeType::SetFlag:
applyEffects(node.effects);
if (!node.next.empty()) {
return enterNode(node.next);
}
stop();
return true;
case NodeType::Jump:
if (!node.next.empty()) {
return enterNode(node.next);
}
stop();
return true;
case NodeType::End:
stop();
return true;
case NodeType::CutsceneStart:
activeCutsceneId = node.cutsceneId;
startCutscene(node.cutsceneId, node.next);
return true;
}
stop();
return false;
}
void DialogueRuntime::presentLine(const Node& node) {
mode = Mode::PresentingLine;
revealCharacters = 0.0f;
presentation.mode = PresentationMode::Dialogue;
presentation.speaker = node.speaker;
presentation.fullText = node.text;
presentation.visibleText.clear();
presentation.portraitPath = node.portrait;
presentation.backgroundPath.clear();
presentation.choices.clear();
presentation.selectedChoice = -1;
presentation.revealCompleted = node.text.empty();
presentation.showCutsceneSubtitle = false;
presentation.cutsceneSkippable = false;
presentation.cutsceneCamera = {};
presentation.cutsceneImages.clear();
presentation.cutsceneGlobalFadeAlpha = 1.0f;
presentation.cutsceneBlackAlpha = 0.0f;
if (presentation.revealCompleted) {
presentation.visibleText = node.text;
revealCharacters = static_cast<float>(node.text.size());
}
}
void DialogueRuntime::presentChoices(const Node& node) {
visibleChoices.clear();
presentation.choices.clear();
for (const Choice& choice : node.choices) {
if (!choice.id.empty() && consumedChoices.count(choice.id) > 0) {
continue;
}
if (!evaluateConditions(choice.conditions)) {
continue;
}
visibleChoices.push_back(choice);
presentation.choices.push_back({ choice.id, choice.text, choice.kind });
}
if (visibleChoices.empty()) {
if (!node.next.empty()) {
enterNode(node.next);
}
else {
stop();
}
return;
}
mode = Mode::WaitingForChoice;
selectedChoice = -1;
presentation.mode = PresentationMode::Choice;
presentation.speaker = node.speaker;
presentation.fullText = node.text;
presentation.visibleText = node.text;
presentation.portraitPath = node.portrait;
presentation.backgroundPath.clear();
presentation.selectedChoice = -1;
presentation.revealCompleted = true;
presentation.showCutsceneSubtitle = false;
presentation.cutsceneSkippable = false;
presentation.cutsceneCamera = {};
presentation.cutsceneImages.clear();
presentation.cutsceneGlobalFadeAlpha = 1.0f;
presentation.cutsceneBlackAlpha = 0.0f;
}
void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) {
if (!database) {
stop();
return;
}
const StaticCutsceneDefinition* cutscene = database->findCutscene(cutsceneId);
if (!cutscene) {
std::cerr << "[dialogue] Cutscene not found: " << cutsceneId << "\n";
if (!nextNodeAfterCutscene.empty()) {
enterNode(nextNodeAfterCutscene);
}
else {
stop();
}
return;
}
activeCutscene = cutscene;
pendingNodeAfterCutscene = nextNodeAfterCutscene;
currentCutsceneBackground = cutscene->background;
mode = Mode::PlayingCutscene;
cutsceneElapsedMs = 0;
cutsceneTimerMs = 0;
currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0;
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);
}
cutsceneContentDurationMs = std::max({ activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene), imageTrackDurationMs });
if (cutsceneContentDurationMs <= 0 && activeCutscene->lines.empty()) {
cutsceneContentDurationMs = 3000;
}
cutsceneTotalDurationMs = cutsceneContentDurationMs + activeCutscene->endFadeOutMs + activeCutscene->endFadeInMs;
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;
const std::string finishedId = activeCutsceneId;
activeCutsceneId.clear();
activeCutscene = nullptr;
currentCutsceneLine = -1;
cutsceneTimerMs = 0;
cutsceneElapsedMs = 0;
cutsceneTotalDurationMs = 0;
cutsceneContentDurationMs = 0;
currentCutsceneBackground.clear();
if (onCutsceneFinished && !finishedId.empty())
onCutsceneFinished(finishedId);
if (!pendingNodeAfterCutscene.empty()) {
const std::string nextNode = pendingNodeAfterCutscene;
pendingNodeAfterCutscene.clear();
enterNode(nextNode);
}
else {
stop();
}
}
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();
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 (cutsceneContentDurationMs <= 0 || cutsceneElapsedMs >= cutsceneContentDurationMs) {
// Only finish immediately if there's no end transition to play
if (activeCutscene->endFadeOutMs <= 0 && activeCutscene->endFadeInMs <= 0) {
finishCutscene();
}
}
return;
}
refreshCutscenePresentation();
}
CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const {
CutsceneCameraBlendState result;
result.active = false;
result.from = {};
result.to = {};
result.t = 1.0f;
if (!activeCutscene || activeCutscene->cameraTrack.empty()) {
return result;
}
int elapsed = cutsceneElapsedMs;
for (const CutsceneCameraSegment& segment : activeCutscene->cameraTrack) {
const int durationMs = std::max(segment.durationMs, 1);
if (elapsed <= durationMs) {
result.active = true;
result.from = segment.from;
result.to = segment.to;
result.t = applyEasing(
segment.easing,
std::clamp(static_cast<float>(elapsed) / static_cast<float>(durationMs), 0.0f, 1.0f)
);
return result;
}
elapsed -= durationMs;
}
result.active = true;
result.from = activeCutscene->cameraTrack.back().to;
result.to = activeCutscene->cameraTrack.back().to;
result.t = 1.0f;
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 = (cutsceneContentDurationMs > 0) ? cutsceneContentDurationMs : 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;
}
presentation.mode = PresentationMode::Cutscene;
presentation.backgroundPath = activeCutscene->background;
presentation.cutsceneCamera = evaluateCutsceneCameraBlend();
presentation.cutsceneImages = evaluateCutsceneImages();
presentation.cutsceneSkippable = activeCutscene->skippable;
const int fadeOutMs = activeCutscene->fadeOutMs;
const int fadeInMs = activeCutscene->fadeInMs;
const int endFadeOutMs = activeCutscene->endFadeOutMs;
const int endFadeInMs = activeCutscene->endFadeInMs;
const int endFadeOutStart = cutsceneContentDurationMs;
const int endFadeInStart = cutsceneContentDurationMs + endFadeOutMs;
if (cutsceneElapsedMs < fadeOutMs) {
// Start phase 1: game world fading to black
presentation.cutsceneGlobalFadeAlpha = 0.0f;
presentation.cutsceneBlackAlpha = std::clamp(
static_cast<float>(cutsceneElapsedMs) / static_cast<float>(fadeOutMs),
0.0f, 1.0f
);
} else if (cutsceneElapsedMs < endFadeOutStart) {
// Content playing (also covers start phase 2 fade-in)
presentation.cutsceneGlobalFadeAlpha = 1.0f;
const int phase2elapsed = cutsceneElapsedMs - fadeOutMs;
presentation.cutsceneBlackAlpha = (fadeInMs > 0)
? std::clamp(1.0f - static_cast<float>(phase2elapsed) / static_cast<float>(fadeInMs), 0.0f, 1.0f)
: 0.0f;
} else if (cutsceneElapsedMs < endFadeInStart) {
// End phase 1: cutscene fading to black
presentation.cutsceneGlobalFadeAlpha = 1.0f;
const int elapsed = cutsceneElapsedMs - endFadeOutStart;
presentation.cutsceneBlackAlpha = (endFadeOutMs > 0)
? std::clamp(static_cast<float>(elapsed) / static_cast<float>(endFadeOutMs), 0.0f, 1.0f)
: 1.0f;
} else {
// End phase 2: game world appearing from black (image hidden, world shows through)
presentation.cutsceneGlobalFadeAlpha = 0.0f;
const int elapsed = cutsceneElapsedMs - endFadeInStart;
presentation.cutsceneBlackAlpha = (endFadeInMs > 0)
? std::clamp(1.0f - static_cast<float>(elapsed) / static_cast<float>(endFadeInMs), 0.0f, 1.0f)
: 0.0f;
}
presentation.choices.clear();
presentation.selectedChoice = -1;
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;
}
const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine];
if (!line.background.empty()) {
currentCutsceneBackground = line.background;
}
presentation.mode = PresentationMode::Cutscene;
presentation.speaker = line.speaker;
presentation.fullText = line.text;
presentation.visibleText = line.text;
presentation.portraitPath = line.portrait;
//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
<< 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) {
const int cps = 17;
const int minDuration = 1500;
const int linger = 450;
const int calculated = static_cast<int>((1000.0 * static_cast<double>(std::max<size_t>(text.size(), 1))) / cps);
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 {
json result;
result["active"] = isActive();
result["dialogueId"] = activeDialogue ? activeDialogue->id : "";
result["currentNodeId"] = currentNodeId;
result["pendingNodeAfterCutscene"] = pendingNodeAfterCutscene;
result["selectedChoice"] = selectedChoice;
result["currentCutsceneLine"] = currentCutsceneLine;
result["cutsceneTimerMs"] = cutsceneTimerMs;
result["flags"] = flags;
result["consumedChoices"] = consumedChoices;
return result;
}
bool DialogueRuntime::restoreSaveState(const json& state) {
if (!database) {
return false;
}
flags.clear();
consumedChoices.clear();
if (state.contains("flags")) {
flags = state["flags"].get<std::unordered_map<std::string, int>>();
}
if (state.contains("consumedChoices")) {
consumedChoices = state["consumedChoices"].get<std::unordered_set<std::string>>();
}
const bool active = state.value("active", false);
if (!active) {
stop();
return true;
}
const std::string dialogueId = state.value("dialogueId", "");
if (!startDialogue(dialogueId)) {
return false;
}
const std::string nodeId = state.value("currentNodeId", "");
pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", "");
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()) {
if (selectedChoice >= 0) {
selectedChoice = std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1);
}
presentation.selectedChoice = selectedChoice;
}
return ok;
}
} // namespace ZL::Dialogue