add dialogue and cutscene system
This commit is contained in:
parent
178f69967b
commit
7125674308
@ -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
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
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
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
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
BIN
resources/dialogue/portrait_frame.png
(Stored with Git LFS)
Normal file
Binary file not shown.
130
resources/dialogue/sample_dialogues.json
Normal file
130
resources/dialogue/sample_dialogues.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
resources/dialogue/textbox_bg.png
(Stored with Git LFS)
Normal file
BIN
resources/dialogue/textbox_bg.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
resources/portraits/elder_bor_neutral.png
(Stored with Git LFS)
Normal file
BIN
resources/portraits/elder_bor_neutral.png
(Stored with Git LFS)
Normal file
Binary file not shown.
41
src/Game.cpp
41
src/Game.cpp
@ -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",
|
||||||
|
"npc_viola_intro",
|
||||||
|
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;
|
||||||
@ -241,6 +252,7 @@ namespace ZL
|
|||||||
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();
|
||||||
CheckGlError();
|
CheckGlError();
|
||||||
@ -361,7 +373,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 +465,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 +541,10 @@ namespace ZL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type == SDL_KEYDOWN && dialogueSystem.handleKeyDown(event.key.keysym.sym)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Обработка ввода текста
|
// Обработка ввода текста
|
||||||
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 +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
|
} // namespace ZL
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
195
src/dialogue/DialogueDatabase.cpp
Normal file
195
src/dialogue/DialogueDatabase.cpp
Normal 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
|
||||||
38
src/dialogue/DialogueDatabase.h
Normal file
38
src/dialogue/DialogueDatabase.h
Normal 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
|
||||||
270
src/dialogue/DialogueOverlay.cpp
Normal file
270
src/dialogue/DialogueOverlay.cpp
Normal 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
|
||||||
66
src/dialogue/DialogueOverlay.h
Normal file
66
src/dialogue/DialogueOverlay.h
Normal 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
|
||||||
439
src/dialogue/DialogueRuntime.cpp
Normal file
439
src/dialogue/DialogueRuntime.cpp
Normal 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
|
||||||
81
src/dialogue/DialogueRuntime.h
Normal file
81
src/dialogue/DialogueRuntime.h
Normal 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
|
||||||
104
src/dialogue/DialogueSystem.cpp
Normal file
104
src/dialogue/DialogueSystem.cpp
Normal 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
|
||||||
51
src/dialogue/DialogueSystem.h
Normal file
51
src/dialogue/DialogueSystem.h
Normal 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
|
||||||
140
src/dialogue/DialogueTypes.h
Normal file
140
src/dialogue/DialogueTypes.h
Normal 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
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user