Compare commits

...

2 Commits

Author SHA1 Message Date
6465549c3b fix: dialogue text now displays correctly 2026-04-13 02:16:25 +06:00
7125674308 add dialogue and cutscene system 2026-04-12 01:46:33 +06:00
25 changed files with 1689 additions and 2 deletions

View File

@ -73,6 +73,15 @@ add_executable(space-game001
../src/GameConstants.cpp ../src/GameConstants.cpp
../src/ScriptEngine.h ../src/ScriptEngine.h
../src/ScriptEngine.cpp ../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 # Установка проекта по умолчанию для Visual Studio

BIN
resources/dialogue/choice_main.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/dialogue/choice_optional.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/dialogue/choice_selected.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/dialogue/cutscene_subtitle_bg.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/dialogue/portrait_frame.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,168 @@
{
"dialogues": [
{
"id": "test_line_dialogue",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "You finally came here.",
"next": "line_2"
},
{
"id": "line_2",
"type": "Line",
"speaker": "Hero",
"portrait": "",
"text": "I need answers.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_choice_dialogue",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Merchant",
"portrait": "resources/ghost_avatar.png",
"text": "What do you want?",
"next": "choice_1"
},
{
"id": "choice_1",
"type": "Choice",
"speaker": "Hero",
"portrait": "",
"text": "Choose your answer.",
"choices": [
{
"id": "main_1",
"kind": "Main",
"text": "Show me your goods.",
"next": "line_goods"
},
{
"id": "optional_1",
"kind": "Optional",
"text": "Who are you?",
"next": "line_who"
}
]
},
{
"id": "line_goods",
"type": "Line",
"speaker": "Merchant",
"portrait": "resources/ghost_avatar.png",
"text": "Take a look.",
"next": "end_1"
},
{
"id": "line_who",
"type": "Line",
"speaker": "Merchant",
"portrait": "resources/ghost_avatar.png",
"text": "Just a trader passing through.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_condition_dialogue",
"start": "set_flag_1",
"nodes": [
{
"id": "set_flag_1",
"type": "SetFlag",
"effects": [
{ "flag": "met_ghost", "value": 1 }
],
"next": "condition_1"
},
{
"id": "condition_1",
"type": "Condition",
"conditions": [
{ "flag": "met_ghost", "op": "Equals", "value": 1 }
],
"trueNext": "line_true",
"falseNext": "line_false"
},
{
"id": "line_true",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "Now you know who I am.",
"next": "end_1"
},
{
"id": "line_false",
"type": "Line",
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "You should not hear this line.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "test_cutscene_dialogue",
"start": "cutscene_start",
"nodes": [
{
"id": "cutscene_start",
"type": "CutsceneStart",
"cutsceneId": "test_cutscene_01",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
}
],
"cutscenes": [
{
"id": "test_cutscene_01",
"background": "resources/first_cutscene.png",
"lines": [
{
"speaker": "Narrator",
"portrait": "",
"text": "The air in the room turned cold.",
"durationMs": 2200
},
{
"speaker": "Ghost",
"portrait": "resources/ghost_avatar.png",
"text": "Some memories never fade.",
"durationMs": 2600
}
]
}
]
}

BIN
resources/dialogue/textbox_bg.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/first_cutscene.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/ghost_avatar.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
resources/portraits/elder_bor_neutral.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -227,6 +227,17 @@ namespace ZL
loadingCompleted = true; loadingCompleted = true;
dialogueSystem.init(renderer, CONST_ZIP_FILE);
dialogueSystem.loadDatabase("resources/dialogue/sample_dialogues.json");
dialogueSystem.addTriggerZone({
"ghost_room_trigger",
"test_line_dialogue",
Eigen::Vector3f(0.0f, 0.0f, -8.5f),
2.0f,
true,
false
});
scriptEngine.init(this); scriptEngine.init(this);
std::cout << "Load resurces step 13" << std::endl; std::cout << "Load resurces step 13" << std::endl;
@ -237,12 +248,22 @@ namespace ZL
{ {
glClear(GL_DEPTH_BUFFER_BIT); glClear(GL_DEPTH_BUFFER_BIT);
glDisable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE);
renderer.shaderManager.PushShader(defaultShaderName); renderer.shaderManager.PushShader(defaultShaderName);
renderer.RenderUniform1i(textureUniformName, 0); renderer.RenderUniform1i(textureUniformName, 0);
glEnable(GL_BLEND); glEnable(GL_BLEND);
menuManager.uiManager.draw(renderer); menuManager.uiManager.draw(renderer);
dialogueSystem.draw(renderer);
glDisable(GL_BLEND); glDisable(GL_BLEND);
renderer.shaderManager.PopShader(); renderer.shaderManager.PopShader();
glDepthMask(GL_TRUE);
glEnable(GL_DEPTH_TEST);
CheckGlError(); CheckGlError();
} }
@ -361,7 +382,10 @@ namespace ZL
lastTickCount = newTickCount; lastTickCount = newTickCount;
if (player) player->update(delta); 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<int>(delta), player->position);
}
} }
@ -450,6 +474,11 @@ namespace ZL
if (event.type == SDL_MOUSEBUTTONDOWN) { if (event.type == SDL_MOUSEBUTTONDOWN) {
handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my); handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
if (dialogueSystem.blocksGameplayInput()) {
dialogueSystem.handlePointerReleased(static_cast<float>(mx), Environment::projectionHeight - static_cast<float>(my));
continue;
}
// Unproject click to ground plane (y=0) for Viola's walk target // Unproject click to ground plane (y=0) for Viola's walk target
float ndcX = 2.0f * event.button.x / Environment::width - 1.0f; float ndcX = 2.0f * event.button.x / Environment::width - 1.0f;
float ndcY = 1.0f - 2.0f * event.button.y / Environment::height; float ndcY = 1.0f - 2.0f * event.button.y / Environment::height;
@ -521,6 +550,27 @@ namespace ZL
} }
} }
if (event.type == SDL_KEYDOWN && dialogueSystem.handleKeyDown(event.key.keysym.sym)) {
continue;
}
if (event.type == SDL_KEYDOWN && event.key.repeat == 0) {
switch (event.key.keysym.sym) {
case SDLK_f:
dialogueSystem.startDialogue("test_line_dialogue");
break;
case SDLK_e:
dialogueSystem.startDialogue("test_cutscene_dialogue");
break;
case SDLK_RETURN:
default:
break;
}
}
// Обработка ввода текста // Обработка ввода текста
if (event.type == SDL_KEYDOWN) { if (event.type == SDL_KEYDOWN) {
if (event.key.keysym.sym == SDLK_BACKSPACE) { if (event.key.keysym.sym == SDLK_BACKSPACE) {
@ -601,5 +651,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 } // namespace ZL

View File

@ -17,6 +17,7 @@
#include <render/TextRenderer.h> #include <render/TextRenderer.h>
#include "MenuManager.h" #include "MenuManager.h"
#include "ScriptEngine.h" #include "ScriptEngine.h"
#include "dialogue/DialogueSystem.h"
#include <unordered_set> #include <unordered_set>
@ -33,6 +34,9 @@ namespace ZL {
void render(); void render();
bool shouldExit() const { return Environment::exitGameLoop; } 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; Renderer renderer;
TaskManager taskManager; TaskManager taskManager;
@ -94,6 +98,7 @@ namespace ZL {
static const size_t CONST_MAX_TIME_INTERVAL = 1000; static const size_t CONST_MAX_TIME_INTERVAL = 1000;
MenuManager menuManager; MenuManager menuManager;
Dialogue::DialogueSystem dialogueSystem;
ScriptEngine scriptEngine; ScriptEngine scriptEngine;
}; };

View File

@ -47,6 +47,23 @@ void ScriptEngine::init(Game* game) {
npcs[index]->setTarget(Eigen::Vector3f(x, y, z), std::move(cb)); 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"); runScript("resources/start.lua");
} }

View File

@ -0,0 +1,195 @@
#include "dialogue/DialogueDatabase.h"
#include "utils/Utils.h"
#include <iostream>
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

View File

@ -0,0 +1,38 @@
#pragma once
#include "dialogue/DialogueTypes.h"
#include "external/nlohmann/json.hpp"
#include <string>
#include <unordered_map>
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<std::string, DialogueDefinition> dialogues;
std::unordered_map<std::string, StaticCutsceneDefinition> 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

View File

@ -0,0 +1,270 @@
#include "dialogue/DialogueOverlay.h"
#include "dialogue/DialogueTypes.h"
#include "GameConstants.h"
#include "Environment.h"
#include <algorithm>
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<Texture>(CreateTextureDataFromPng("resources/dialogue/textbox_bg.png", zipFile));
portraitFrameTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/portrait_frame.png", zipFile));
choiceMainTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_main.png", zipFile));
choiceOptionalTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_optional.png", zipFile));
choiceSelectedTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/choice_selected.png", zipFile));
cutsceneSubtitleTexture = std::make_shared<Texture>(CreateTextureDataFromPng("resources/dialogue/cutscene_subtitle_bg.png", zipFile));
nameRenderer = std::make_unique<TextRenderer>();
bodyRenderer = std::make_unique<TextRenderer>();
choiceRenderer = std::make_unique<TextRenderer>();
cutsceneRenderer = std::make_unique<TextRenderer>();
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<float>(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<int>(i) == model.selectedChoice;
std::shared_ptr<Texture> 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<int>(i) == model.selectedChoice;
const std::array<float, 4> color = (model.choices[i].kind == ChoiceKind::Optional)
? std::array<float, 4>{0.82f, 0.82f, 0.82f, 1.0f}
: std::array<float, 4>{ 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<float, 4>{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<int>(i);
return true;
}
}
return false;
}
std::shared_ptr<Texture> 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<Texture>(CreateTextureDataFromPng(path, zipFilename));
textureCache[path] = texture;
return texture;
}
void DialogueOverlay::drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& 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

View File

@ -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 <memory>
#include <string>
#include <unordered_map>
#include <vector>
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<Texture> textboxTexture;
std::shared_ptr<Texture> portraitFrameTexture;
std::shared_ptr<Texture> choiceMainTexture;
std::shared_ptr<Texture> choiceOptionalTexture;
std::shared_ptr<Texture> choiceSelectedTexture;
std::shared_ptr<Texture> cutsceneSubtitleTexture;
mutable std::vector<UiRect> lastChoiceRects;
std::unique_ptr<TextRenderer> nameRenderer;
std::unique_ptr<TextRenderer> bodyRenderer;
std::unique_ptr<TextRenderer> choiceRenderer;
std::unique_ptr<TextRenderer> cutsceneRenderer;
TexturedQuad portraitQuad;
TexturedQuad textboxQuad;
TexturedQuad subtitleQuad;
TexturedQuad backgroundQuad;
mutable std::vector<TexturedQuad> choiceQuads;
std::unordered_map<std::string, std::shared_ptr<Texture>> textureCache;
void drawDialogue(Renderer& renderer, const PresentationModel& model);
void drawCutscene(Renderer& renderer, const PresentationModel& model);
std::shared_ptr<Texture> loadTextureCached(const std::string& path);
void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& texture) const;
static std::string wrapText(const std::string& input, size_t maxLineLength);
};
} // namespace ZL::Dialogue

View File

@ -0,0 +1,439 @@
#include "dialogue/DialogueRuntime.h"
#include <algorithm>
#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;
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<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) {
if (currentCutsceneLine < 0 || currentCutsceneLine >= static_cast<int>(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<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) {
advanceCutsceneLine();
}
}
void DialogueRuntime::moveSelection(int delta) {
if (mode != Mode::WaitingForChoice || visibleChoices.empty()) {
return;
}
const int count = static_cast<int>(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<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();
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 = 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<int>(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<int>(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<int>((1000.0 * static_cast<double>(std::max<size_t>(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<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

View File

@ -0,0 +1,81 @@
#pragma once
#include "dialogue/DialogueDatabase.h"
#include "external/nlohmann/json.hpp"
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<std::string, int> flags;
std::unordered_set<std::string> consumedChoices;
std::string currentNodeId;
std::string pendingNodeAfterCutscene;
std::vector<Choice> 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<Condition>& conditions) const;
void applyEffects(const std::vector<Effect>& 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

View File

@ -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

View File

@ -0,0 +1,51 @@
#pragma once
#include "dialogue/DialogueOverlay.h"
#include "dialogue/DialogueRuntime.h"
#include <Eigen/Dense>
#include <SDL.h>
#include <string>
#include <vector>
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<TriggerZone> triggerZones;
};
} // namespace ZL::Dialogue

View File

@ -0,0 +1,140 @@
#pragma once
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<Condition> conditions;
std::vector<Effect> 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<Condition> conditions;
// For Choice / SetFlag
std::vector<Choice> choices;
std::vector<Effect> effects;
// For CutsceneStart
std::string cutsceneId;
};
struct DialogueDefinition {
std::string id;
std::string displayName;
std::string startNode;
std::unordered_map<std::string, Node> 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<CutsceneLine> 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<PresentedChoice> choices;
int selectedChoice = 0;
bool revealCompleted = true;
};
struct SaveState {
std::string dialogueId;
std::string currentNodeId;
std::string pendingNodeAfterCutscene;
std::unordered_set<std::string, int> flags;
std::unordered_set<std::string> consumedChoices;
int selectedChoice = 0;
int currentCutsceneLine = -1;
int cutsceneTimerMs = 0;
bool active = false;
};
} // namespace ZL::Dialogue

View File

@ -133,6 +133,8 @@ bool TextRenderer::loadGlyphs(const std::string& ttfPath, int pixelSize, const s
glyphList.emplace_back((char)c, std::move(gb)); glyphList.emplace_back((char)c, std::move(gb));
} }
lineHeight = std::max<float>(static_cast<float>(pixelSize) * 1.3f, static_cast<float>(maxGlyphHeight) * 1.25f);
// Пакуем глифы в один атлас (упрощённый алгоритм строковой укладки) // Пакуем глифы в один атлас (упрощённый алгоритм строковой укладки)
const int padding = 1; const int padding = 1;
const int maxAtlasWidth = 1024; // безопасное значение для большинства устройств const int maxAtlasWidth = 1024; // безопасное значение для большинства устройств
@ -297,7 +299,15 @@ void TextRenderer::drawText(const std::string& text, float x, float y, float sca
float totalW = 0.0f; float totalW = 0.0f;
float maxH = 0.0f; float maxH = 0.0f;
float maxLineWidth = 0.0f;
for (char ch : text) { for (char ch : text) {
if (ch == '\n') {
maxLineWidth = max(maxLineWidth, penX);
penX = 0.0f;
penY -= lineHeight * scale;
continue;
}
auto git = glyphs.find(ch); auto git = glyphs.find(ch);
if (git == glyphs.end()) continue; if (git == glyphs.end()) continue;
const GlyphInfo& g = git->second; const GlyphInfo& g = git->second;
@ -332,6 +342,7 @@ void TextRenderer::drawText(const std::string& text, float x, float y, float sca
totalW = penX; totalW = penX;
maxH = max(maxH, h); maxH = max(maxH, h);
} }
totalW = max(maxLineWidth, penX);
// Сохраняем в кеш // Сохраняем в кеш
CachedText ct; CachedText ct;

View File

@ -47,6 +47,7 @@ private:
std::shared_ptr<Texture> atlasTexture; std::shared_ptr<Texture> atlasTexture;
size_t atlasWidth = 0; size_t atlasWidth = 0;
size_t atlasHeight = 0; size_t atlasHeight = 0;
float lineHeight = 32.0f;
VertexRenderStruct textMesh; VertexRenderStruct textMesh;