#include "dialogue/DialogueRuntime.h" #include #include #include 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; currentCutsceneBackground.clear(); presentation = {}; presentation.dialogueId = dialogue->id; return enterNode(dialogue->startNode); } 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; currentCutsceneBackground.clear(); mode = Mode::Inactive; presentation = {}; } void DialogueRuntime::update(int deltaMs) { if (mode == Mode::PresentingLine) { if (!presentation.revealCompleted) { revealCharacters += revealSpeedCharsPerSecond * (static_cast(deltaMs) / 1000.0f); const size_t fullLen = presentation.fullText.size(); const size_t visibleLen = static_cast(std::min(revealCharacters, static_cast(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(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(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(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(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(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(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& 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& 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: 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(); if (presentation.revealCompleted) { presentation.visibleText = node.text; revealCharacters = static_cast(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(); } 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); } cutsceneTotalDurationMs = std::max({ activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene), imageTrackDurationMs }); 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; cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; currentCutsceneBackground.clear(); 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(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(activeCutscene->lines.size())) { refreshCutscenePresentation(); if (cutsceneTotalDurationMs <= 0 || cutsceneElapsedMs >= cutsceneTotalDurationMs) { 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(elapsed) / static_cast(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 DialogueRuntime::evaluateCutsceneImages() const { std::vector result; if (!activeCutscene) { return result; } const std::string& fallbackPath = !currentCutsceneBackground.empty() ? currentCutsceneBackground : activeCutscene->background; if (activeCutscene->images.empty()) { if (!fallbackPath.empty()) { result.push_back({ fallbackPath, 1.0f }); } return result; } const int effectiveTotalDuration = (cutsceneTotalDurationMs > 0) ? cutsceneTotalDurationMs : std::max(activeCutscene->durationMs, 1); const int now = std::max(cutsceneElapsedMs, 0); for (size_t i = 0; i < activeCutscene->images.size(); ++i) { const CutsceneImageCue& cue = activeCutscene->images[i]; if (cue.path.empty()) { continue; } const int startMs = std::max(cue.startMs, 0); int endMs = cue.endMs; if (endMs <= startMs) { if (i + 1 < activeCutscene->images.size()) { endMs = std::max(activeCutscene->images[i + 1].startMs, startMs + 1); } else { endMs = effectiveTotalDuration; } } if (endMs <= startMs) { endMs = startMs + 1; } if (now < startMs || now > endMs) { continue; } float alpha = 1.0f; if (cue.fadeInMs > 0 && now < startMs + cue.fadeInMs) { alpha = std::clamp( static_cast(now - startMs) / static_cast(cue.fadeInMs), 0.0f, 1.0f ); } if (alpha > 0.0f) { result.push_back({ cue.path, alpha }); } } // Safety fallback: never leave the cutscene without an opaque image layer. if (result.empty() && !fallbackPath.empty()) { result.push_back({ fallbackPath, 1.0f }); } // If the first active layer is still fading in, put an opaque fallback/base below it. // This prevents the world from becoming visible behind the cutscene. if (!result.empty() && result.front().alpha < 0.999f && !fallbackPath.empty() && result.front().path != fallbackPath) { result.insert(result.begin(), { fallbackPath, 1.0f }); } return result; } void DialogueRuntime::refreshCutscenePresentation() { if (!activeCutscene) { return; } presentation.mode = PresentationMode::Cutscene; presentation.backgroundPath = activeCutscene->background; presentation.cutsceneCamera = evaluateCutsceneCameraBlend(); presentation.cutsceneImages = evaluateCutsceneImages(); presentation.cutsceneSkippable = activeCutscene->skippable; presentation.choices.clear(); presentation.selectedChoice = -1; presentation.revealCompleted = true; const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(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((1000.0 * static_cast(std::max(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>(); } if (state.contains("consumedChoices")) { consumedChoices = state["consumedChoices"].get>(); } 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(visibleChoices.size()) - 1); } presentation.selectedChoice = selectedChoice; } return ok; } } // namespace ZL::Dialogue