diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 4d361bd..bd05cb8 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -73,6 +73,15 @@ add_executable(space-game001 ../src/GameConstants.cpp ../src/ScriptEngine.h ../src/ScriptEngine.cpp + ../src/dialogue/DialogueTypes.h + ../src/dialogue/DialogueDatabase.h + ../src/dialogue/DialogueDatabase.cpp + ../src/dialogue/DialogueRuntime.h + ../src/dialogue/DialogueRuntime.cpp + ../src/dialogue/DialogueOverlay.h + ../src/dialogue/DialogueOverlay.cpp + ../src/dialogue/DialogueSystem.h + ../src/dialogue/DialogueSystem.cpp ) # Установка проекта по умолчанию для Visual Studio diff --git a/resources/dialogue/choice_main.png b/resources/dialogue/choice_main.png new file mode 100644 index 0000000..77b51be --- /dev/null +++ b/resources/dialogue/choice_main.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:426debd008e3787830cf904aa50f2cb82cc4ddb322d6e316125ecbd40932cbcd +size 518 diff --git a/resources/dialogue/choice_optional.png b/resources/dialogue/choice_optional.png new file mode 100644 index 0000000..4651f37 --- /dev/null +++ b/resources/dialogue/choice_optional.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:167c1b2c066ad417bd6fd2aaa37e8d058df3f05d2d1d11d5124743b384b92097 +size 524 diff --git a/resources/dialogue/choice_selected.png b/resources/dialogue/choice_selected.png new file mode 100644 index 0000000..714ae39 --- /dev/null +++ b/resources/dialogue/choice_selected.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4b5c30574b544cc07569526e4562c6f6c7db22903b7c901f277ddf966e8d6d6 +size 519 diff --git a/resources/dialogue/cutscene_subtitle_bg.png b/resources/dialogue/cutscene_subtitle_bg.png new file mode 100644 index 0000000..9984263 --- /dev/null +++ b/resources/dialogue/cutscene_subtitle_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5aff657e1efd81b8a4806cee19928c4649506ce2906aa2cd03c9569139c6f5c +size 1128 diff --git a/resources/dialogue/portrait_frame.png b/resources/dialogue/portrait_frame.png new file mode 100644 index 0000000..39f4138 --- /dev/null +++ b/resources/dialogue/portrait_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baf860b51ef2e8e610cbb84e3fb8c468b1ed3d92633c028e2b13013e14bcb216 +size 1286 diff --git a/resources/dialogue/sample_dialogues.json b/resources/dialogue/sample_dialogues.json new file mode 100644 index 0000000..e8a56e7 --- /dev/null +++ b/resources/dialogue/sample_dialogues.json @@ -0,0 +1,130 @@ +{ + "dialogues": [ + { + "id": "npc_viola_intro", + "displayName": "Ghost introduction", + "start": "start", + "nodes": [ + { + "id": "start", + "type": "Line", + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "So, you finally reached this room.", + "next": "intro_choice" + }, + { + "id": "intro_choice", + "type": "Choice", + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "Choose what Geralt says next.", + "choices": [ + { + "id": "main_job", + "kind": "Main", + "text": "I am looking for answers.", + "next": "job_line" + }, + { + "id": "optional_who", + "kind": "Optional", + "text": "Who are you?", + "next": "who_line", + "consumeOnce": true + }, + { + "id": "exit_now", + "kind": "Exit", + "text": "Enough. Goodbye.", + "next": "end" + } + ] + }, + { + "id": "who_line", + "type": "Line", + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "Only a memory. But memories can still be dangerous.", + "next": "intro_choice" + }, + { + "id": "job_line", + "type": "Line", + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "Then watch carefully. What follows is not a story, but a wound.", + "next": "start_cutscene" + }, + { + "id": "start_cutscene", + "type": "CutsceneStart", + "cutsceneId": "ghost_memory_01", + "next": "set_memory_seen" + }, + { + "id": "set_memory_seen", + "type": "SetFlag", + "effects": [ + { + "flag": "memory_seen", + "value": 1 + } + ], + "next": "after_cutscene_condition" + }, + { + "id": "after_cutscene_condition", + "type": "Condition", + "conditions": [ + { + "flag": "memory_seen", + "op": ">=", + "value": 1 + } + ], + "trueNext": "after_cutscene_line", + "falseNext": "end" + }, + { + "id": "after_cutscene_line", + "type": "Line", + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "Now you know enough for the first quest hook.", + "next": "end" + }, + { + "id": "end", + "type": "End" + } + ] + } + ], + "cutscenes": [ + { + "id": "ghost_memory_01", + "background": "resources/w/room005.png", + "skippable": true, + "lines": [ + { + "speaker": "Narrator", + "text": "The room went silent, as if the walls remembered everything.", + "durationMs": 2800 + }, + { + "speaker": "Ghost", + "portrait": "resources/w/ghost_skin001.png", + "text": "There were voices here once. Oaths. Smoke. Firelight.", + "durationMs": 2600 + }, + { + "speaker": "Narrator", + "text": "Now there was only ash and a story that refused to die.", + "durationMs": 2700 + } + ] + } + ] +} diff --git a/resources/dialogue/textbox_bg.png b/resources/dialogue/textbox_bg.png new file mode 100644 index 0000000..e02bef2 --- /dev/null +++ b/resources/dialogue/textbox_bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36fc69bd2e67933be9b040ad4b5d5390a516ae1859ef1c865b1031801f7a22e1 +size 1880 diff --git a/resources/portraits/elder_bor_neutral.png b/resources/portraits/elder_bor_neutral.png new file mode 100644 index 0000000..130ff37 --- /dev/null +++ b/resources/portraits/elder_bor_neutral.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6e2b21c0d776d382a6194fe1a7e53455183a5a02e1e5b18e438fb971e7602df +size 2432469 diff --git a/src/Game.cpp b/src/Game.cpp index 55a1bb9..d4c3690 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -227,6 +227,17 @@ namespace ZL loadingCompleted = true; + dialogueSystem.init(renderer, CONST_ZIP_FILE); + dialogueSystem.loadDatabase("resources/dialogue/sample_dialogues.json"); + dialogueSystem.addTriggerZone({ + "ghost_room_trigger", + "npc_viola_intro", + Eigen::Vector3f(0.0f, 0.0f, -8.5f), + 2.0f, + true, + false + }); + scriptEngine.init(this); std::cout << "Load resurces step 13" << std::endl; @@ -241,6 +252,7 @@ namespace ZL renderer.RenderUniform1i(textureUniformName, 0); glEnable(GL_BLEND); menuManager.uiManager.draw(renderer); + dialogueSystem.draw(renderer); glDisable(GL_BLEND); renderer.shaderManager.PopShader(); CheckGlError(); @@ -361,7 +373,10 @@ namespace ZL lastTickCount = newTickCount; if (player) player->update(delta); - for (auto& npc : npcs) npc->update(delta); + //for (auto& npc : npcs) npc->update(delta); + if (player) { + dialogueSystem.update(static_cast(delta), player->position); + } } @@ -450,6 +465,11 @@ namespace ZL if (event.type == SDL_MOUSEBUTTONDOWN) { handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my); + if (dialogueSystem.blocksGameplayInput()) { + dialogueSystem.handlePointerReleased(static_cast(mx), Environment::projectionHeight - static_cast(my)); + continue; + } + // Unproject click to ground plane (y=0) for Viola's walk target float ndcX = 2.0f * event.button.x / Environment::width - 1.0f; float ndcY = 1.0f - 2.0f * event.button.y / Environment::height; @@ -521,6 +541,10 @@ namespace ZL } } + if (event.type == SDL_KEYDOWN && dialogueSystem.handleKeyDown(event.key.keysym.sym)) { + continue; + } + // Обработка ввода текста if (event.type == SDL_KEYDOWN) { if (event.key.keysym.sym == SDLK_BACKSPACE) { @@ -601,5 +625,20 @@ namespace ZL } + bool Game::requestDialogueStart(const std::string& dialogueId) + { + return dialogueSystem.startDialogue(dialogueId); + } + + void Game::setDialogueFlag(const std::string& flag, int value) + { + dialogueSystem.setFlag(flag, value); + } + + int Game::getDialogueFlag(const std::string& flag) const + { + return dialogueSystem.getFlag(flag); + } + } // namespace ZL diff --git a/src/Game.h b/src/Game.h index 30c42e5..3740313 100644 --- a/src/Game.h +++ b/src/Game.h @@ -17,6 +17,7 @@ #include #include "MenuManager.h" #include "ScriptEngine.h" +#include "dialogue/DialogueSystem.h" #include @@ -33,6 +34,9 @@ namespace ZL { void render(); bool shouldExit() const { return Environment::exitGameLoop; } + bool requestDialogueStart(const std::string& dialogueId); + void setDialogueFlag(const std::string& flag, int value); + int getDialogueFlag(const std::string& flag) const; Renderer renderer; TaskManager taskManager; @@ -94,6 +98,7 @@ namespace ZL { static const size_t CONST_MAX_TIME_INTERVAL = 1000; MenuManager menuManager; + Dialogue::DialogueSystem dialogueSystem; ScriptEngine scriptEngine; }; diff --git a/src/ScriptEngine.cpp b/src/ScriptEngine.cpp index 0951a18..45fde3f 100644 --- a/src/ScriptEngine.cpp +++ b/src/ScriptEngine.cpp @@ -47,6 +47,23 @@ void ScriptEngine::init(Game* game) { npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb)); }); + api.set_function("start_dialogue", + [game](const std::string& dialogueId) { + if (!game->requestDialogueStart(dialogueId)) { + std::cerr << "[script] start_dialogue failed for id: " << dialogueId << "\n"; + } + }); + + api.set_function("set_dialogue_flag", + [game](const std::string& flag, int value) { + game->setDialogueFlag(flag, value); + }); + + api.set_function("get_dialogue_flag", + [game](const std::string& flag) { + return game->getDialogueFlag(flag); + }); + runScript("resources/start.lua"); } diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp new file mode 100644 index 0000000..77177a3 --- /dev/null +++ b/src/dialogue/DialogueDatabase.cpp @@ -0,0 +1,195 @@ +#include "dialogue/DialogueDatabase.h" + +#include "utils/Utils.h" +#include + +namespace ZL::Dialogue { + +NodeType DialogueDatabase::parseNodeType(const std::string& value) { + if (value == "Choice") return NodeType::Choice; + if (value == "Condition") return NodeType::Condition; + if (value == "SetFlag") return NodeType::SetFlag; + if (value == "Jump") return NodeType::Jump; + if (value == "End") return NodeType::End; + if (value == "CutsceneStart") return NodeType::CutsceneStart; + return NodeType::Line; +} + +ChoiceKind DialogueDatabase::parseChoiceKind(const std::string& value) { + if (value == "Optional") return ChoiceKind::Optional; + if (value == "Exit") return ChoiceKind::Exit; + return ChoiceKind::Main; +} + +ComparisonOp DialogueDatabase::parseComparisonOp(const std::string& value) { + if (value == "==" || value == "Equals") return ComparisonOp::Equals; + if (value == "!=" || value == "NotEquals") return ComparisonOp::NotEquals; + if (value == ">=" || value == "GreaterOrEqual") return ComparisonOp::GreaterOrEqual; + if (value == "<=" || value == "LessOrEqual") return ComparisonOp::LessOrEqual; + return ComparisonOp::Exists; +} + +Condition DialogueDatabase::parseCondition(const json& j) { + Condition c; + c.flag = j.value("flag", ""); + c.op = parseComparisonOp(j.value("op", "Exists")); + c.value = j.value("value", 1); + return c; +} + +Effect DialogueDatabase::parseEffect(const json& j) { + Effect e; + e.flag = j.value("flag", ""); + e.value = j.value("value", 1); + e.relative = j.value("relative", false); + return e; +} + +Choice DialogueDatabase::parseChoice(const json& j) { + Choice c; + c.id = j.value("id", ""); + c.text = j.value("text", ""); + c.next = j.value("next", ""); + c.kind = parseChoiceKind(j.value("kind", "Main")); + c.consumeOnce = j.value("consumeOnce", false); + + if (j.contains("conditions") && j["conditions"].is_array()) { + for (const auto& item : j["conditions"]) { + c.conditions.push_back(parseCondition(item)); + } + } + if (j.contains("effects") && j["effects"].is_array()) { + for (const auto& item : j["effects"]) { + c.effects.push_back(parseEffect(item)); + } + } + return c; +} + +Node DialogueDatabase::parseNode(const json& j) { + Node node; + node.id = j.value("id", ""); + node.type = parseNodeType(j.value("type", "Line")); + node.speaker = j.value("speaker", ""); + node.text = j.value("text", ""); + node.portrait = j.value("portrait", ""); + node.next = j.value("next", ""); + node.trueNext = j.value("trueNext", ""); + node.falseNext = j.value("falseNext", ""); + node.cutsceneId = j.value("cutsceneId", ""); + + if (j.contains("conditions") && j["conditions"].is_array()) { + for (const auto& item : j["conditions"]) { + node.conditions.push_back(parseCondition(item)); + } + } + if (j.contains("effects") && j["effects"].is_array()) { + for (const auto& item : j["effects"]) { + node.effects.push_back(parseEffect(item)); + } + } + if (j.contains("choices") && j["choices"].is_array()) { + for (const auto& item : j["choices"]) { + node.choices.push_back(parseChoice(item)); + } + } + + return node; +} + +DialogueDefinition DialogueDatabase::parseDialogue(const json& j) { + DialogueDefinition result; + result.id = j.value("id", ""); + result.displayName = j.value("displayName", result.id); + result.startNode = j.value("start", ""); + + if (j.contains("nodes") && j["nodes"].is_array()) { + for (const auto& item : j["nodes"]) { + Node node = parseNode(item); + if (!node.id.empty()) { + result.nodes[node.id] = std::move(node); + } + } + } + + return result; +} + +CutsceneLine DialogueDatabase::parseCutsceneLine(const json& j) { + CutsceneLine line; + line.speaker = j.value("speaker", ""); + line.text = j.value("text", ""); + line.portrait = j.value("portrait", ""); + line.sfx = j.value("sfx", ""); + line.durationMs = j.value("durationMs", 0); + line.waitForConfirm = j.value("waitForConfirm", false); + return line; +} + +StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { + StaticCutsceneDefinition cutscene; + cutscene.id = j.value("id", ""); + cutscene.background = j.value("background", ""); + cutscene.music = j.value("music", ""); + cutscene.skippable = j.value("skippable", true); + + if (j.contains("lines") && j["lines"].is_array()) { + for (const auto& item : j["lines"]) { + cutscene.lines.push_back(parseCutsceneLine(item)); + } + } + + return cutscene; +} + +bool DialogueDatabase::loadFromFile(const std::string& path) { + dialogues.clear(); + cutscenes.clear(); + + const std::string raw = ZL::readTextFile(path); + if (raw.empty()) { + std::cerr << "[dialogue] Failed to read file: " << path << "\n"; + return false; + } + + json root; + try { + root = json::parse(raw); + } + catch (const std::exception& e) { + std::cerr << "[dialogue] JSON parse error in " << path << ": " << e.what() << "\n"; + return false; + } + + if (root.contains("dialogues") && root["dialogues"].is_array()) { + for (const auto& item : root["dialogues"]) { + DialogueDefinition dialogue = parseDialogue(item); + if (!dialogue.id.empty()) { + dialogues[dialogue.id] = std::move(dialogue); + } + } + } + + if (root.contains("cutscenes") && root["cutscenes"].is_array()) { + for (const auto& item : root["cutscenes"]) { + StaticCutsceneDefinition cutscene = parseCutscene(item); + if (!cutscene.id.empty()) { + cutscenes[cutscene.id] = std::move(cutscene); + } + } + } + + return !dialogues.empty(); +} + +const DialogueDefinition* DialogueDatabase::findDialogue(const std::string& id) const { + auto it = dialogues.find(id); + return (it != dialogues.end()) ? &it->second : nullptr; +} + +const StaticCutsceneDefinition* DialogueDatabase::findCutscene(const std::string& id) const { + auto it = cutscenes.find(id); + return (it != cutscenes.end()) ? &it->second : nullptr; +} + +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h new file mode 100644 index 0000000..e2e3c09 --- /dev/null +++ b/src/dialogue/DialogueDatabase.h @@ -0,0 +1,38 @@ +#pragma once + +#include "dialogue/DialogueTypes.h" +#include "external/nlohmann/json.hpp" +#include +#include + +namespace ZL::Dialogue { + +class DialogueDatabase { +public: + using json = nlohmann::json; + + bool loadFromFile(const std::string& path); + + const DialogueDefinition* findDialogue(const std::string& id) const; + const StaticCutsceneDefinition* findCutscene(const std::string& id) const; + +private: + std::unordered_map dialogues; + std::unordered_map cutscenes; + + static NodeType parseNodeType(const std::string& value); + static ChoiceKind parseChoiceKind(const std::string& value); + static ComparisonOp parseComparisonOp(const std::string& value); + + static Condition parseCondition(const json& j); + static Effect parseEffect(const json& j); + static Choice parseChoice(const json& j); + static Node parseNode(const json& j); + static DialogueDefinition parseDialogue(const json& j); + static CutsceneLine parseCutsceneLine(const json& j); + static StaticCutsceneDefinition parseCutscene(const json& j); + + +}; + +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp new file mode 100644 index 0000000..5718e02 --- /dev/null +++ b/src/dialogue/DialogueOverlay.cpp @@ -0,0 +1,270 @@ +#include "dialogue/DialogueOverlay.h" +#include "dialogue/DialogueTypes.h" + +#include "GameConstants.h" +#include "Environment.h" +#include + +namespace ZL::Dialogue { + +void DialogueOverlay::TexturedQuad::rebuild(const UiRect& newRect) { + rect = newRect; + mesh.data = CreateRect2D( + { rect.x + rect.w * 0.5f, rect.y + rect.h * 0.5f }, + { rect.w * 0.5f, rect.h * 0.5f }, + 0.0f + ); + mesh.RefreshVBO(); + initialized = true; +} + +bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) { + rendererRef = &renderer; + zipFilename = zipFile; + + textboxTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/textbox_bg.png", zipFile)); + portraitFrameTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/portrait_frame.png", zipFile)); + choiceMainTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_main.png", zipFile)); + choiceOptionalTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_optional.png", zipFile)); + choiceSelectedTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/choice_selected.png", zipFile)); + cutsceneSubtitleTexture = std::make_shared(CreateTextureDataFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile)); + + nameRenderer = std::make_unique(); + bodyRenderer = std::make_unique(); + choiceRenderer = std::make_unique(); + cutsceneRenderer = std::make_unique(); + + const bool ok = + nameRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 28, zipFile) && + bodyRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile) && + choiceRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 22, zipFile) && + cutsceneRenderer->init(renderer, "resources/fonts/DroidSans.ttf", 24, zipFile); + + return ok; +} + +void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { + if (model.mode == PresentationMode::Hidden) { + lastChoiceRects.clear(); + return; + } + + if (model.mode == PresentationMode::Cutscene) { + drawCutscene(renderer, model); + } + else { + drawDialogue(renderer, model); + } +} + +void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& model) { + const float W = Environment::projectionWidth; + const float H = Environment::projectionHeight; + + 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 }; + + if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || + portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { + portraitQuad.rebuild(portraitRect); + } + if (!textboxQuad.initialized || textboxQuad.rect.w != textboxRect.w || textboxQuad.rect.h != textboxRect.h || + textboxQuad.rect.x != textboxRect.x || textboxQuad.rect.y != textboxRect.y) { + textboxQuad.rebuild(textboxRect); + } + + glEnable(GL_BLEND); + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + + drawQuad(renderer, textboxQuad, textboxTexture); + drawQuad(renderer, portraitQuad, model.portraitPath.empty() ? portraitFrameTexture : loadTextureCached(model.portraitPath)); + drawQuad(renderer, portraitQuad, portraitFrameTexture); + + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + const float nameX = textboxRect.x + 24.0f; + const float nameY = textboxRect.y + textboxRect.h - 38.0f; + const float bodyX = textboxRect.x + 24.0f; + const float bodyY = textboxRect.y + textboxRect.h - 78.0f; + + if (!model.speaker.empty()) { + nameRenderer->drawText(model.speaker, nameX, nameY, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }); + } + + const std::string wrappedBody = wrapText(model.visibleText, 56); + bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); + + lastChoiceRects.clear(); + if (model.mode == PresentationMode::Choice) { + const float choiceStartY = textboxRect.y + 56.0f; + const float choiceHeight = 30.0f; + const float choiceSpacing = 8.0f; + const float choiceWidth = textboxRect.w - 48.0f; + + if (choiceQuads.size() < model.choices.size()) { + choiceQuads.resize(model.choices.size()); + } + + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + + for (size_t i = 0; i < model.choices.size(); ++i) { + const float y = choiceStartY + (choiceHeight + choiceSpacing) * static_cast(model.choices.size() - 1 - i); + UiRect rect{ textboxRect.x + 20.0f, y, choiceWidth, choiceHeight }; + lastChoiceRects.push_back(rect); + choiceQuads[i].rebuild(rect); + + const bool isSelected = static_cast(i) == model.selectedChoice; + std::shared_ptr choiceTexture = choiceMainTexture; + if (model.choices[i].kind == ChoiceKind::Optional) { + choiceTexture = choiceOptionalTexture; + } + if (isSelected) { + choiceTexture = choiceSelectedTexture; + } + drawQuad(renderer, choiceQuads[i], choiceTexture); + } + + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + for (size_t i = 0; i < model.choices.size(); ++i) { + const UiRect& rect = lastChoiceRects[i]; + const bool isSelected = static_cast(i) == model.selectedChoice; + const std::array color = (model.choices[i].kind == ChoiceKind::Optional) + ? std::array{0.82f, 0.82f, 0.82f, 1.0f} + : std::array{ 1.0f, 0.93f, 0.65f, 1.0f }; + + choiceRenderer->drawText( + wrapText(model.choices[i].text, 52), + rect.x + 14.0f, + rect.y + 9.0f, + 1.0f, + false, + isSelected ? std::array{1.0f, 1.0f, 1.0f, 1.0f} : color + ); + } + } + + glDisable(GL_BLEND); +} + +void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& model) { + const float W = Environment::projectionWidth; + 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 }; + + backgroundQuad.rebuild(fullscreenRect); + subtitleQuad.rebuild(subtitleRect); + + glEnable(GL_BLEND); + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + + if (!model.backgroundPath.empty()) { + drawQuad(renderer, backgroundQuad, loadTextureCached(model.backgroundPath)); + } + drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); + + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + 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 }); + } + cutsceneRenderer->drawText( + wrapText(model.visibleText, 62), + subtitleRect.x + 24.0f, + subtitleRect.y + 30.0f, + 1.0f, + false, + { 1.0f, 1.0f, 1.0f, 1.0f } + ); + + glDisable(GL_BLEND); +} + +bool DialogueOverlay::handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const { + outChoiceIndex = -1; + if (model.mode != PresentationMode::Choice) { + return false; + } + + for (size_t i = 0; i < lastChoiceRects.size(); ++i) { + if (lastChoiceRects[i].contains(x, y)) { + outChoiceIndex = static_cast(i); + return true; + } + } + return false; +} + +std::shared_ptr DialogueOverlay::loadTextureCached(const std::string& path) { + if (path.empty()) { + return nullptr; + } + + auto it = textureCache.find(path); + if (it != textureCache.end()) { + return it->second; + } + + auto texture = std::make_shared(CreateTextureDataFromPng(path, zipFilename)); + textureCache[path] = texture; + return texture; +} + +void DialogueOverlay::drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& texture) const { + if (!texture) { + return; + } + + glBindTexture(GL_TEXTURE_2D, texture->getTexID()); + renderer.DrawVertexRenderStruct(quad.mesh); +} + +std::string DialogueOverlay::wrapText(const std::string& input, size_t maxLineLength) { + if (input.size() <= maxLineLength) { + return input; + } + + std::string output; + size_t lineStart = 0; + while (lineStart < input.size()) { + size_t lineEnd = min(lineStart + maxLineLength, input.size()); + if (lineEnd < input.size()) { + const size_t split = input.rfind(' ', lineEnd); + if (split != std::string::npos && split > lineStart) { + lineEnd = split; + } + } + + output.append(input.substr(lineStart, lineEnd - lineStart)); + if (lineEnd < input.size()) { + output.push_back('\n'); + lineStart = lineEnd + (input[lineEnd] == ' ' ? 1 : 0); + } + else { + lineStart = lineEnd; + } + } + + return output; +} + +} // namespace ZL::Dialogue \ No newline at end of file diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h new file mode 100644 index 0000000..34cc273 --- /dev/null +++ b/src/dialogue/DialogueOverlay.h @@ -0,0 +1,66 @@ +#pragma once + +#include "dialogue/DialogueRuntime.h" +#include "render/Renderer.h" +#include "render/TextRenderer.h" +#include "render/TextureManager.h" +#include "UiManager.h" +#include +#include +#include +#include + +namespace ZL::Dialogue { + +class DialogueOverlay { +public: + bool init(Renderer& renderer, const std::string& zipFile = ""); + void draw(Renderer& renderer, const PresentationModel& model); + + // Coordinates are expected in the game's UI projection space + bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex) const; + +private: + struct TexturedQuad { + UiRect rect; + VertexRenderStruct mesh; + bool initialized = false; + + void rebuild(const UiRect& newRect); + }; + + Renderer* rendererRef = nullptr; + std::string zipFilename; + + std::shared_ptr textboxTexture; + std::shared_ptr portraitFrameTexture; + std::shared_ptr choiceMainTexture; + std::shared_ptr choiceOptionalTexture; + std::shared_ptr choiceSelectedTexture; + std::shared_ptr cutsceneSubtitleTexture; + + mutable std::vector lastChoiceRects; + + std::unique_ptr nameRenderer; + std::unique_ptr bodyRenderer; + std::unique_ptr choiceRenderer; + std::unique_ptr cutsceneRenderer; + + TexturedQuad portraitQuad; + TexturedQuad textboxQuad; + TexturedQuad subtitleQuad; + TexturedQuad backgroundQuad; + mutable std::vector choiceQuads; + + std::unordered_map> textureCache; + + void drawDialogue(Renderer& renderer, const PresentationModel& model); + void drawCutscene(Renderer& renderer, const PresentationModel& model); + + std::shared_ptr loadTextureCached(const std::string& path); + void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& texture) const; + + static std::string wrapText(const std::string& input, size_t maxLineLength); +}; + +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp new file mode 100644 index 0000000..e7307a2 --- /dev/null +++ b/src/dialogue/DialogueRuntime.cpp @@ -0,0 +1,439 @@ +#include "dialogue/DialogueRuntime.h" + +#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 = 0; + revealCharacters = 0.0f; + currentCutsceneLine = -1; + cutsceneTimerMs = 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; + 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) { + if (currentCutsceneLine < 0 || currentCutsceneLine >= static_cast(activeCutscene->lines.size())) { + advanceCutsceneLine(); + return; + } + + const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; + if (line.waitForConfirm) { + return; + } + + cutsceneTimerMs += deltaMs; + const int durationMs = (line.durationMs > 0) ? line.durationMs : computeFallbackCutsceneDurationMs(line.text); + if (cutsceneTimerMs >= durationMs) { + advanceCutsceneLine(); + } + } +} + +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()) { + return; + } + + const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1)]; + if (choice.consumeOnce && !choice.id.empty()) { + consumedChoices.insert(choice.id); + } + applyEffects(choice.effects); + enterNode(choice.next); + return; + } + + if (mode == Mode::PlayingCutscene) { + advanceCutsceneLine(); + } +} + +void DialogueRuntime::moveSelection(int delta) { + if (mode != Mode::WaitingForChoice || visibleChoices.empty()) { + return; + } + + const int count = static_cast(visibleChoices.size()); + selectedChoice = (selectedChoice + delta) % count; + if (selectedChoice < 0) { + selectedChoice += count; + } + 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& 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 = 0; + presentation.revealCompleted = node.text.empty(); + + 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 = 0; + 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 = 0; + presentation.revealCompleted = true; +} + +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; + mode = Mode::PlayingCutscene; + currentCutsceneLine = -1; + cutsceneTimerMs = 0; + advanceCutsceneLine(); +} + +void DialogueRuntime::advanceCutsceneLine() { + if (!activeCutscene) { + stop(); + return; + } + + ++currentCutsceneLine; + cutsceneTimerMs = 0; + + if (currentCutsceneLine >= static_cast(activeCutscene->lines.size())) { + activeCutscene = nullptr; + if (!pendingNodeAfterCutscene.empty()) { + const std::string nextNode = pendingNodeAfterCutscene; + pendingNodeAfterCutscene.clear(); + enterNode(nextNode); + } + else { + stop(); + } + return; + } + + refreshCutscenePresentation(); +} + +void DialogueRuntime::refreshCutscenePresentation() { + if (!activeCutscene || currentCutsceneLine < 0 || + currentCutsceneLine >= static_cast(activeCutscene->lines.size())) { + return; + } + + const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; + 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.choices.clear(); + presentation.selectedChoice = 0; + presentation.revealCompleted = true; +} + +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); +} + +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", 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(visibleChoices.size()) - 1); + presentation.selectedChoice = selectedChoice; + } + return ok; +} + +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h new file mode 100644 index 0000000..f810834 --- /dev/null +++ b/src/dialogue/DialogueRuntime.h @@ -0,0 +1,81 @@ +#pragma once + +#include "dialogue/DialogueDatabase.h" +#include "external/nlohmann/json.hpp" +#include +#include +#include +#include + +namespace ZL::Dialogue { + +class DialogueRuntime { +public: + using json = nlohmann::json; + + void setDatabase(const DialogueDatabase* value); + + bool startDialogue(const std::string& dialogueId); + void stop(); + + void update(int deltaMs); + + bool isActive() const { return mode != Mode::Inactive; } + bool isInChoice() const { return mode == Mode::WaitingForChoice; } + bool isPlayingCutscene() const { return mode == Mode::PlayingCutscene; } + + void confirmAdvance(); + void moveSelection(int delta); + + const PresentationModel& getPresentation() const { return presentation; } + + void setFlag(const std::string& name, int value); + int getFlag(const std::string& name) const; + + json buildSaveState() const; + bool restoreSaveState(const json& state); + +private: + enum class Mode { + Inactive, + PresentingLine, + WaitingForChoice, + PlayingCutscene + }; + + const DialogueDatabase* database = nullptr; + const DialogueDefinition* activeDialogue = nullptr; + const StaticCutsceneDefinition* activeCutscene = nullptr; + + std::unordered_map flags; + std::unordered_set consumedChoices; + + std::string currentNodeId; + std::string pendingNodeAfterCutscene; + + std::vector visibleChoices; + PresentationModel presentation; + Mode mode = Mode::Inactive; + + int selectedChoice = 0; + float revealCharacters = 0.0f; + float revealSpeedCharsPerSecond = 52.0f; + + int currentCutsceneLine = -1; + int cutsceneTimerMs = 0; + + bool evaluateConditions(const std::vector& conditions) const; + void applyEffects(const std::vector& effects); + + bool enterNode(const std::string& nodeId); + void presentLine(const Node& node); + void presentChoices(const Node& node); + void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); + + void advanceCutsceneLine(); + void refreshCutscenePresentation(); + + static int computeFallbackCutsceneDurationMs(const std::string& text); +}; + +} // namespace ZL::Dialogue \ No newline at end of file diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp new file mode 100644 index 0000000..319f416 --- /dev/null +++ b/src/dialogue/DialogueSystem.cpp @@ -0,0 +1,104 @@ +#include "dialogue/DialogueSystem.h" + +namespace ZL::Dialogue { + +bool DialogueSystem::init(Renderer& renderer, const std::string& zipFile) { + runtime.setDatabase(&database); + return overlay.init(renderer, zipFile); +} + +bool DialogueSystem::loadDatabase(const std::string& path) { + return database.loadFromFile(path); +} + +void DialogueSystem::update(int deltaMs, const Eigen::Vector3f& playerPosition) { + if (!runtime.isActive()) { + for (TriggerZone& zone : triggerZones) { + if (zone.once && zone.triggered) { + continue; + } + + const Eigen::Vector3f diff = playerPosition - zone.center; + if (diff.norm() <= zone.radius) { + if (startDialogue(zone.dialogueId)) { + zone.triggered = true; + break; + } + } + } + } + + runtime.update(deltaMs); +} + +void DialogueSystem::draw(Renderer& renderer) { + overlay.draw(renderer, runtime.getPresentation()); +} + +bool DialogueSystem::handleKeyDown(SDL_Keycode key) { + if (!runtime.isActive()) { + return false; + } + + switch (key) { + case SDLK_RETURN: + case SDLK_SPACE: + case SDLK_e: + runtime.confirmAdvance(); + return true; + + case SDLK_UP: + case SDLK_w: + runtime.moveSelection(-1); + return true; + + case SDLK_DOWN: + case SDLK_s: + runtime.moveSelection(1); + return true; + + case SDLK_ESCAPE: + stopDialogue(); + return true; + + default: + return false; + } +} + +bool DialogueSystem::handlePointerReleased(float x, float y) { + if (!runtime.isActive()) { + return false; + } + + int choiceIndex = -1; + const PresentationModel& model = runtime.getPresentation(); + if (overlay.handlePointerReleased(x, y, model, choiceIndex)) { + while (model.selectedChoice != choiceIndex) { + runtime.moveSelection(1); + } + runtime.confirmAdvance(); + return true; + } + + runtime.confirmAdvance(); + return true; +} + +bool DialogueSystem::startDialogue(const std::string& dialogueId) { + return runtime.startDialogue(dialogueId); +} + +void DialogueSystem::stopDialogue() { + runtime.stop(); +} + +void DialogueSystem::addTriggerZone(const TriggerZone& zone) { + triggerZones.push_back(zone); +} + +void DialogueSystem::clearTriggerZones() { + triggerZones.clear(); +} + +} // namespace ZL::Dialogue \ No newline at end of file diff --git a/src/dialogue/DialogueSystem.h b/src/dialogue/DialogueSystem.h new file mode 100644 index 0000000..3cd464b --- /dev/null +++ b/src/dialogue/DialogueSystem.h @@ -0,0 +1,51 @@ +#pragma once + +#include "dialogue/DialogueOverlay.h" +#include "dialogue/DialogueRuntime.h" +#include +#include +#include +#include + +namespace ZL::Dialogue { + +struct TriggerZone { + std::string id; + std::string dialogueId; + Eigen::Vector3f center = Eigen::Vector3f::Zero(); + float radius = 1.5f; + bool once = true; + bool triggered = false; +}; + +class DialogueSystem { +public: + bool init(Renderer& renderer, const std::string& zipFile = ""); + bool loadDatabase(const std::string& path); + + void update(int deltaMs, const Eigen::Vector3f& playerPosition); + void draw(Renderer& renderer); + + bool handleKeyDown(SDL_Keycode key); + bool handlePointerReleased(float x, float y); + + bool startDialogue(const std::string& dialogueId); + void stopDialogue(); + + bool isActive() const { return runtime.isActive(); } + bool blocksGameplayInput() const { return runtime.isActive(); } + + void setFlag(const std::string& name, int value) { runtime.setFlag(name, value); } + int getFlag(const std::string& name) const { return runtime.getFlag(name); } + + void addTriggerZone(const TriggerZone& zone); + void clearTriggerZones(); + +private: + DialogueDatabase database; + DialogueRuntime runtime; + DialogueOverlay overlay; + std::vector triggerZones; +}; + +} // namespace ZL::Dialogue diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h new file mode 100644 index 0000000..76cd925 --- /dev/null +++ b/src/dialogue/DialogueTypes.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZL::Dialogue { + +enum class NodeType { + Line, + Choice, + Condition, + SetFlag, + Jump, + End, + CutsceneStart +}; + +enum class ChoiceKind { + Main, + Optional, + Exit +}; + +enum class ComparisonOp { + Exists, + Equals, + NotEquals, + GreaterOrEqual, + LessOrEqual +}; + +struct Condition { + std::string flag; + ComparisonOp op = ComparisonOp::Exists; + int value = 1; +}; + +struct Effect { + std::string flag; + int value = 1; + bool relative = false; +}; + +struct Choice { + std::string id; + std::string text; + std::string next; + ChoiceKind kind = ChoiceKind::Main; + std::vector conditions; + std::vector effects; + bool consumeOnce = false; +}; + +struct Node { + std::string id; + NodeType type = NodeType::Line; + + std::string speaker; + std::string text; + std::string portrait; + std::string next; + + // For Condition nodes + std::string trueNext; + std::string falseNext; + std::vector conditions; + + // For Choice / SetFlag + std::vector choices; + std::vector effects; + + // For CutsceneStart + std::string cutsceneId; +}; + +struct DialogueDefinition { + std::string id; + std::string displayName; + std::string startNode; + std::unordered_map nodes; +}; + +struct CutsceneLine { + std::string speaker; + std::string text; + std::string portrait; + std::string sfx; + int durationMs = 0; + bool waitForConfirm = false; +}; + +struct StaticCutsceneDefinition { + std::string id; + std::string background; + std::string music; + bool skippable = true; + std::vector lines; +}; + +struct PresentedChoice { + std::string id; + std::string text; + ChoiceKind kind = ChoiceKind::Main; +}; + +enum class PresentationMode { + Hidden, + Dialogue, + Choice, + Cutscene +}; + +struct PresentationModel { + PresentationMode mode = PresentationMode::Hidden; + std::string dialogueId; + std::string speaker; + std::string fullText; + std::string visibleText; + std::string portraitPath; + std::string backgroundPath; + std::vector choices; + int selectedChoice = 0; + bool revealCompleted = true; +}; + +struct SaveState { + std::string dialogueId; + std::string currentNodeId; + std::string pendingNodeAfterCutscene; + std::unordered_set flags; + std::unordered_set consumedChoices; + int selectedChoice = 0; + int currentCutsceneLine = -1; + int cutsceneTimerMs = 0; + bool active = false; +}; + +} // namespace ZL::Dialogue \ No newline at end of file diff --git a/src/render/TextRenderer.cpp b/src/render/TextRenderer.cpp index 534e866..532a995 100644 --- a/src/render/TextRenderer.cpp +++ b/src/render/TextRenderer.cpp @@ -133,6 +133,8 @@ bool TextRenderer::loadGlyphs(const std::string& ttfPath, int pixelSize, const s glyphList.emplace_back((char)c, std::move(gb)); } + lineHeight = std::max(static_cast(pixelSize) * 1.3f, static_cast(maxGlyphHeight) * 1.25f); + // Пакуем глифы в один атлас (упрощённый алгоритм строковой укладки) const int padding = 1; const int maxAtlasWidth = 1024; // безопасное значение для большинства устройств @@ -296,8 +298,16 @@ void TextRenderer::drawText(const std::string& text, float x, float y, float sca float totalW = 0.0f; float maxH = 0.0f; - + + float maxLineWidth = 0.0f; for (char ch : text) { + if (ch == '\n') { + maxLineWidth = max(maxLineWidth, penX); + penX = 0.0f; + penY -= lineHeight * scale; + continue; + } + auto git = glyphs.find(ch); if (git == glyphs.end()) continue; const GlyphInfo& g = git->second; @@ -332,6 +342,7 @@ void TextRenderer::drawText(const std::string& text, float x, float y, float sca totalW = penX; maxH = max(maxH, h); } + totalW = max(maxLineWidth, penX); // Сохраняем в кеш CachedText ct; diff --git a/src/render/TextRenderer.h b/src/render/TextRenderer.h index d7a138a..21fc5f8 100644 --- a/src/render/TextRenderer.h +++ b/src/render/TextRenderer.h @@ -47,6 +47,7 @@ private: std::shared_ptr atlasTexture; size_t atlasWidth = 0; size_t atlasHeight = 0; + float lineHeight = 32.0f; VertexRenderStruct textMesh;