646 lines
17 KiB
C++
646 lines
17 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 = 0;
|
|
revealCharacters = 0.0f;
|
|
currentCutsceneLine = -1;
|
|
cutsceneTimerMs = 0;
|
|
cutsceneElapsedMs = 0;
|
|
cutsceneTotalDurationMs = 0;
|
|
presentation = {};
|
|
presentation.dialogueId = dialogue->id;
|
|
|
|
return enterNode(dialogue->startNode);
|
|
}
|
|
|
|
void DialogueRuntime::stop() {
|
|
activeDialogue = nullptr;
|
|
activeCutscene = nullptr;
|
|
currentNodeId.clear();
|
|
pendingNodeAfterCutscene.clear();
|
|
visibleChoices.clear();
|
|
selectedChoice = 0;
|
|
revealCharacters = 0.0f;
|
|
currentCutsceneLine = -1;
|
|
cutsceneTimerMs = 0;
|
|
cutsceneElapsedMs = 0;
|
|
cutsceneTotalDurationMs = 0;
|
|
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()) {
|
|
return;
|
|
}
|
|
|
|
const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1)];
|
|
if (choice.consumeOnce && !choice.id.empty()) {
|
|
consumedChoices.insert(choice.id);
|
|
}
|
|
applyEffects(choice.effects);
|
|
enterNode(choice.next);
|
|
return;
|
|
}
|
|
|
|
if (mode == Mode::PlayingCutscene) {
|
|
if (!activeCutscene || activeCutscene->lines.empty()) {
|
|
return;
|
|
}
|
|
|
|
if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
|
|
advanceCutsceneLine();
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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:
|
|
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 = 0;
|
|
presentation.revealCompleted = node.text.empty();
|
|
presentation.showCutsceneSubtitle = false;
|
|
presentation.cutsceneCamera = {};
|
|
|
|
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.cutsceneCamera = {};
|
|
}
|
|
|
|
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;
|
|
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;
|
|
cutsceneTimerMs = 0;
|
|
cutsceneElapsedMs = 0;
|
|
cutsceneTotalDurationMs = 0;
|
|
if (!pendingNodeAfterCutscene.empty()) {
|
|
const std::string nextNode = pendingNodeAfterCutscene;
|
|
pendingNodeAfterCutscene.clear();
|
|
enterNode(nextNode);
|
|
}
|
|
else {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
void DialogueRuntime::refreshCutscenePresentation() {
|
|
if (!activeCutscene) {
|
|
return;
|
|
}
|
|
|
|
presentation.mode = PresentationMode::Cutscene;
|
|
presentation.backgroundPath = activeCutscene->background;
|
|
presentation.cutsceneCamera = evaluateCutsceneCameraBlend();
|
|
|
|
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;
|
|
}
|
|
|
|
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
|
|
<< 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;
|
|
}
|
|
//>>>>>>> witcher001-cutscene
|
|
}
|
|
|
|
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", 0);
|
|
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);
|
|
presentation.selectedChoice = selectedChoice;
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
} // namespace ZL::Dialogue
|