diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index f97fb5f..7b481d1 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -120,6 +120,9 @@ set(SOURCES ../src/dialogue/DialogueOverlay.cpp ../src/dialogue/DialogueSystem.h ../src/dialogue/DialogueSystem.cpp + ../src/quest/QuestTypes.h + ../src/quest/QuestJournal.h + ../src/quest/QuestJournal.cpp ) add_executable(bishkek-witcher ${SOURCES}) diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 49313ec..b7cd383 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -73,6 +73,9 @@ add_executable(space-game001 ../src/dialogue/DialogueOverlay.cpp ../src/dialogue/DialogueSystem.h ../src/dialogue/DialogueSystem.cpp + ../src/quest/QuestTypes.h + ../src/quest/QuestJournal.h + ../src/quest/QuestJournal.cpp ) # Установка проекта по умолчанию для Visual Studio diff --git a/resources/config2/ui_inventory.json b/resources/config2/ui_inventory.json index ab996ef..65b4df6 100644 --- a/resources/config2/ui_inventory.json +++ b/resources/config2/ui_inventory.json @@ -28,47 +28,72 @@ "name": "inventory_items_panel", "x": 50.0, "y": 150.0, - "width": 250.0, - "height": 300.0, + "width": 320.0, + "height": 420.0, "children": [ { "type": "StaticImage", "name": "panel_background", - "width": 200, - "height": 400, + "x": 0.0, + "y": 0.0, + "width": 320.0, + "height": 420.0, "texture": "resources/w/red.png" }, + { + "type": "TextView", + "name": "inventory_title_text", + "x": 20.0, + "y": 18.0, + "width": 230.0, + "height": 34.0, + "text": "Inventory", + "fontSize": 24, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 0.0, + "paddingY": 0.0, + "color": [1.0, 1.0, 1.0, 1.0] + }, { "type": "TextView", "name": "inventory_items_text", - "x": -100.0, - "y": -100.0, - "width": 250.0, - "height": 300.0, + "x": 20.0, + "y": 70.0, + "width": 280.0, + "height": 320.0, "text": "Inventory (Empty)", "fontSize": 18, "fontPath": "resources/fonts/DroidSans.ttf", "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 0.0, + "paddingY": 0.0, + "maxLines": 14, "color": [1.0, 1.0, 1.0, 1.0] }, { "type": "TextButton", - "name": "close_inventory_button", - "x": 165.0, - "y": 0.0, - "width": 40.0, - "height": 40.0, - "text": "X", - "fontSize": 20, - "fontPath": "resources/fonts/DroidSans.ttf", - "textCentered": true, - "color": [1.0, 1.0, 1.0, 1.0], - "textures": { - "normal": "resources/w/blue.png" - } + "name": "close_inventory_button", + "x": 266.0, + "y": 16.0, + "width": 40.0, + "height": 40.0, + "text": "X", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/blue.png", + "hover": "resources/w/blue.png", + "pressed": "resources/w/blue.png" + } } ] } ] } -} \ No newline at end of file +} diff --git a/resources/config2/ui_quest_journal.json b/resources/config2/ui_quest_journal.json new file mode 100644 index 0000000..5b15444 --- /dev/null +++ b/resources/config2/ui_quest_journal.json @@ -0,0 +1,353 @@ +{ + "root": { + "type": "FrameLayout", + "name": "quest_journal_root", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "TextButton", + "name": "quest_journal_button", + "x": 220.0, + "y": 50.0, + "width": 170.0, + "height": 60.0, + "text": "Quests", + "fontSize": 24, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/red.png", + "hover": "resources/w/red.png", + "pressed": "resources/w/red.png" + } + }, + { + "type": "FrameLayout", + "name": "quest_journal_panel", + "x": 55.0, + "y": 85.0, + "width": 1170.0, + "height": 590.0, + "children": [ + { + "type": "StaticImage", + "name": "quest_panel_background", + "x": 0.0, + "y": 0.0, + "width": 1170.0, + "height": 590.0, + "texture": "resources/black.png" + }, + { + "type": "TextView", + "name": "quest_journal_title_text", + "x": 0.0, + "y": 18.0, + "width": 1170.0, + "height": 44.0, + "text": "QUESTS", + "fontSize": 32, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": true, + "topAligned": true, + "paddingY": 2.0, + "color": [1.0, 1.0, 1.0, 1.0] + }, + { + "type": "TextButton", + "name": "quest_close_button", + "x": 1110.0, + "y": 20.0, + "width": 38.0, + "height": 38.0, + "text": "X", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/blue.png", + "hover": "resources/w/blue.png", + "pressed": "resources/w/blue.png" + } + }, + { + "type": "StaticImage", + "name": "quest_separator_left", + "x": 390.0, + "y": 88.0, + "width": 2.0, + "height": 470.0, + "texture": "resources/w/red.png" + }, + { + "type": "StaticImage", + "name": "quest_separator_right", + "x": 770.0, + "y": 88.0, + "width": 2.0, + "height": 470.0, + "texture": "resources/w/red.png" + }, + { + "type": "TextView", + "name": "quest_list_header_text", + "x": 35.0, + "y": 90.0, + "width": 330.0, + "height": 32.0, + "text": "ЗАДАНИЯ", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 4.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextButton", + "name": "quest_slot_0", + "x": 35.0, + "y": 130.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_1", + "x": 35.0, + "y": 178.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_2", + "x": 35.0, + "y": 226.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_3", + "x": 35.0, + "y": 274.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_4", + "x": 35.0, + "y": 322.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_5", + "x": 35.0, + "y": 370.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_6", + "x": 35.0, + "y": 418.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_7", + "x": 35.0, + "y": 466.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_8", + "x": 35.0, + "y": 514.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextView", + "name": "quest_middle_title_text", + "x": 415.0, + "y": 94.0, + "width": 330.0, + "height": 72.0, + "text": "Выберите задание", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 4.0, + "maxLines": 2, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_meta_text", + "x": 415.0, + "y": 168.0, + "width": 330.0, + "height": 48.0, + "text": "", + "fontSize": 16, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 2.0, + "maxLines": 2, + "color": [0.72, 0.72, 0.72, 1.0] + }, + { + "type": "TextView", + "name": "quest_objectives_header_text", + "x": 415.0, + "y": 232.0, + "width": 330.0, + "height": 32.0, + "text": "ЦЕЛИ", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 8.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_objectives_text", + "x": 415.0, + "y": 272.0, + "width": 330.0, + "height": 285.0, + "text": "", + "fontSize": 17, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 4.0, + "maxLines": 10, + "color": [0.88, 0.88, 0.88, 1.0] + }, + { + "type": "TextView", + "name": "quest_lore_title_text", + "x": 795.0, + "y": 94.0, + "width": 330.0, + "height": 38.0, + "text": "Описание задания", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 8.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_description_text", + "x": 795.0, + "y": 145.0, + "width": 330.0, + "height": 410.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 6.0, + "maxLines": 15, + "color": [0.92, 0.92, 0.92, 1.0] + } + ] + } + ] + } +} diff --git a/resources/quests/quests.json b/resources/quests/quests.json new file mode 100644 index 0000000..f54a982 --- /dev/null +++ b/resources/quests/quests.json @@ -0,0 +1,90 @@ +{ + "quests": [ + { + "id": "tutorial_open_journal", + "title": "Журнал заданий", + "category": "Tutorial", + "status": "Completed", + "recommendedLevel": 0, + "description": "Теперь у героя есть журнал заданий. В нём можно просматривать текущие поручения, цели и подробные записи о событиях, которые уже произошли в мире игры.", + "objectives": [ + { "id": "open_journal", "text": "Открыть журнал заданий", "completed": true } + ] + }, + { + "id": "side_lost_bag", + "title": "Потерянная сумка", + "category": "Side", + "status": "Completed", + "recommendedLevel": 1, + "description": "Путник потерял сумку недалеко от лесной тропы. Внутри оказались вещи, важные для его дальнейшего пути. Возвращение сумки укрепило доверие местных жителей к герою.", + "objectives": [ + { "id": "find_bag", "text": "Найти сумку у лесной тропы", "completed": true }, + { "id": "bring_bag", "text": "Вернуть сумку путнику", "completed": true } + ] + }, + { + "id": "failed_missing_courier", + "title": "След курьера", + "category": "Side", + "status": "Failed", + "recommendedLevel": 2, + "description": "Курьер исчез до того, как герой смог выйти на его след. Местные говорят, что на дороге остались только следы копыт и обрывок ремня. Возможность узнать, кто забрал посылку, была упущена.", + "objectives": [ + { "id": "ask_innkeeper", "text": "Расспросить трактирщика", "completed": true }, + { "id": "find_courier", "text": "Найти курьера до наступления ночи", "completed": false } + ] + }, + { + "id": "contract_old_well", + "title": "Старый колодец", + "category": "Contract", + "status": "Active", + "recommendedLevel": 2, + "description": "На окраине деревни стоит старый колодец. Ночью из него слышны голоса, а утром вокруг находят мокрые следы. Местные боятся подходить к нему, но староста считает, что причина происходящего связана с давним проклятием.", + "objectives": [ + { "id": "inspect_well", "text": "Осмотреть старый колодец", "completed": false }, + { "id": "search_tracks", "text": "Найти следы возле каменной ограды", "completed": false }, + { "id": "report_elder", "text": "Вернуться к старосте", "completed": false } + ] + }, + { + "id": "main_find_ghost", + "title": "Следы призрака", + "category": "Main", + "status": "Active", + "recommendedLevel": 1, + "description": "В заброшенной части деревни появился странный призрак. Местные жители говорят, что он появляется только ночью и исчезает возле старого колодца. Похоже, дух пытается привести кого-то к месту, где много лет назад случилась трагедия.", + "objectives": [ + { "id": "talk_to_ghost", "text": "Поговорить с призраком возле старого дома", "completed": false }, + { "id": "inspect_well", "text": "Осмотреть старый колодец", "completed": false }, + { "id": "return_to_elder", "text": "Вернуться к старосте", "completed": false } + ] + }, + { + "id": "main_black_letter", + "title": "Письмо с чёрной печатью", + "category": "Main", + "status": "Active", + "recommendedLevel": 3, + "description": "В руки героя попало письмо с чёрной восковой печатью. Имя отправителя стёрто, но на бумаге остался запах дыма и дорогих трав. Письмо упоминает встречу у северных ворот и человека, который знает правду о призраке.", + "objectives": [ + { "id": "read_letter", "text": "Прочитать письмо", "completed": true }, + { "id": "go_north_gate", "text": "Добраться до северных ворот", "completed": false }, + { "id": "find_contact", "text": "Найти человека с серебряным кольцом", "completed": false } + ] + }, + { + "id": "side_herbalist_help", + "title": "Травы для лекаря", + "category": "Side", + "status": "Available", + "recommendedLevel": 1, + "description": "Лекарю нужны редкие болотные травы, чтобы приготовить настой для больных детей. Он предупредил, что растение раскрывается только на рассвете, а рядом часто появляются дикие звери.", + "objectives": [ + { "id": "collect_herbs", "text": "Собрать болотные травы на рассвете", "completed": false }, + { "id": "bring_herbs", "text": "Отнести травы лекарю", "completed": false } + ] + } + ] +} diff --git a/src/Game.cpp b/src/Game.cpp index 90a73ae..3d4a6bf 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -173,6 +173,11 @@ namespace ZL // Load UI with inventory button try { menuManager.uiManager.loadFromFile("resources/config2/ui_inventory.json", renderer, CONST_ZIP_FILE); + menuManager.uiManager.appendFromFile("resources/config2/ui_quest_journal.json", renderer, CONST_ZIP_FILE); + + questJournal.loadFromFile("resources/quests/quests.json", CONST_ZIP_FILE); + setupQuestJournalUi(); + std::cout << "UI loaded successfully" << std::endl; menuManager.uiManager.setNodeVisible("inventory_items_panel", false); @@ -180,6 +185,9 @@ namespace ZL menuManager.uiManager.setTextButtonCallback("inventory_button", [this](const std::string& name) { std::cout << "[UI] Inventory button clicked" << std::endl; + if (this->questJournalOpen) { + this->toggleQuestJournal(); + } this->menuManager.uiManager.setNodeVisible("inventory_items_panel", true); this->menuManager.uiManager.setNodeVisible("close_inventory_button", true); this->inventoryOpen = true; @@ -250,6 +258,149 @@ namespace ZL CheckGlError(__FILE__, __LINE__); } + + + + static int questStatusPriority(Quest::QuestStatus status) { + switch (status) { + case Quest::QuestStatus::Active: return 0; + case Quest::QuestStatus::Available: return 1; + case Quest::QuestStatus::Completed: return 2; + case Quest::QuestStatus::Failed: return 3; + default: return 4; + } + } + + static std::array questStatusColor(Quest::QuestStatus status) { + switch (status) { + case Quest::QuestStatus::Completed: return { 0.25f, 0.95f, 0.35f, 1.0f }; + case Quest::QuestStatus::Failed: return { 1.0f, 0.25f, 0.25f, 1.0f }; + case Quest::QuestStatus::Active: return { 1.0f, 1.0f, 1.0f, 1.0f }; + case Quest::QuestStatus::Available: return { 0.86f, 0.86f, 0.86f, 1.0f }; + default: return { 0.45f, 0.45f, 0.45f, 1.0f }; + } + } + + void Game::setupQuestJournalUi() { + questJournalOpen = false; + selectedQuestIndex = -1; + visibleQuestIds.clear(); + + menuManager.uiManager.setNodeVisible("quest_journal_panel", false); + menuManager.uiManager.setNodeVisible("quest_close_button", false); + + menuManager.uiManager.setTextButtonCallback("quest_journal_button", [this](const std::string&) { + toggleQuestJournal(); + }); + + menuManager.uiManager.setTextButtonCallback("quest_close_button", [this](const std::string&) { + if (questJournalOpen) { + toggleQuestJournal(); + } + }); + + for (int i = 0; i < 9; ++i) { + const std::string slotName = "quest_slot_" + std::to_string(i); + menuManager.uiManager.setTextButtonCallback(slotName, [this, i](const std::string&) { + selectQuestByIndex(i); + }); + menuManager.uiManager.setNodeVisible(slotName, false); + } + } + + void Game::toggleQuestJournal() { + questJournalOpen = !questJournalOpen; + std::cout << "[quest] toggleQuestJournal: " << (questJournalOpen ? "open" : "closed") << std::endl; + + if (questJournalOpen) { + if (inventoryOpen) { + menuManager.uiManager.setNodeVisible("inventory_items_panel", false); + menuManager.uiManager.setNodeVisible("close_inventory_button", false); + inventoryOpen = false; + } + } + + menuManager.uiManager.setNodeVisible("quest_journal_panel", questJournalOpen); + menuManager.uiManager.setNodeVisible("quest_close_button", questJournalOpen); + + if (questJournalOpen) { + refreshQuestJournalUi(); + if (!visibleQuestIds.empty()) { + selectQuestByIndex(0); + } + } + } + + void Game::refreshQuestJournalUi() { + visibleQuestIds.clear(); + auto quests = questJournal.getVisibleQuests(); + + std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) { + const int pa = questStatusPriority(a->status); + const int pb = questStatusPriority(b->status); + if (pa != pb) return pa < pb; + // Newer quests are shown above older quests inside the same status bucket. + return a->orderIndex > b->orderIndex; + }); + + for (int i = 0; i < 9; ++i) { + const std::string slotName = "quest_slot_" + std::to_string(i); + + if (i < static_cast(quests.size())) { + const auto* quest = quests[i]; + visibleQuestIds.push_back(quest->definition.id); + + const bool selected = (i == selectedQuestIndex); + const std::string prefix = selected ? "> " : " "; + menuManager.uiManager.setTextButtonText(slotName, prefix + quest->definition.title); + menuManager.uiManager.setTextButtonColor(slotName, questStatusColor(quest->status)); + menuManager.uiManager.setNodeVisible(slotName, true); + } + else { + menuManager.uiManager.setTextButtonText(slotName, ""); + menuManager.uiManager.setNodeVisible(slotName, false); + } + } + } + + void Game::selectQuestByIndex(int index) { + if (index < 0 || index >= static_cast(visibleQuestIds.size())) { + return; + } + + selectedQuestIndex = index; + Quest::QuestState* quest = questJournal.findQuest(visibleQuestIds[index]); + if (!quest) { + return; + } + + const auto& def = quest->definition; + + menuManager.uiManager.setText("quest_middle_title_text", def.title); + menuManager.uiManager.setTextColor("quest_middle_title_text", questStatusColor(quest->status)); + + const std::string meta = std::string("Category: ") + Quest::toString(def.category) + + " | Status: " + Quest::toString(quest->status) + + " | Level: " + std::to_string(def.recommendedLevel); + menuManager.uiManager.setText("quest_meta_text", meta); + + std::string objectivesText; + for (size_t i = 0; i < def.objectives.size(); ++i) { + const auto& obj = def.objectives[i]; + const bool isActive = static_cast(i) == quest->activeObjectiveIndex; + const std::string mark = obj.completed ? "[x] " : (isActive ? "> [ ] " : "[ ] "); + objectivesText += mark + obj.text; + if (i + 1 < def.objectives.size()) { + objectivesText += "\n"; + } + } + menuManager.uiManager.setText("quest_objectives_text", objectivesText); + + menuManager.uiManager.setText("quest_lore_title_text", "Описание задания"); + menuManager.uiManager.setText("quest_description_text", def.description); + + refreshQuestJournalUi(); + } void Game::drawScene() { glViewport(0, 0, Environment::width, Environment::height); @@ -446,6 +597,14 @@ namespace ZL std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl; handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my); + // Inventory and quest journal are modal UI screens. While either one is + // open, left-clicks must never reach Location/world movement, even if + // the click is on an empty background area of the panel. + if (inventoryOpen || questJournalOpen) { + std::cout << "[CLICK] Menu open, skipping character movement" << std::endl; + continue; + } + if (menuManager.uiManager.isUiInteractionForFinger(ZL::UiManager::MOUSE_FINGER_ID)) { std::cout << "[CLICK] UI handled, skipping character movement" << std::endl; continue; @@ -537,13 +696,17 @@ namespace ZL currentLocation->dialogueSystem.startDialogue("test_cutscene_images_silent_dialogue"); break; case SDLK_f: - currentLocation->dialogueSystem.startDialogue("test_choice_dialogue"); + currentLocation->dialogueSystem.startDialogue("test_line_dialogue"); break; case SDLK_e: currentLocation->dialogueSystem.startDialogue("test_cutscene_pan_dialogue"); break; + case SDLK_j: + toggleQuestJournal(); + break; + case SDLK_p: break; diff --git a/src/Game.h b/src/Game.h index 7beab7b..9ac6ebb 100644 --- a/src/Game.h +++ b/src/Game.h @@ -20,7 +20,8 @@ #include #include #include "Location.h" -#include "AudioPlayerAsync.h" +#include "AudioPlayerAsync.h" +#include "quest/QuestJournal.h" namespace ZL { @@ -51,6 +52,11 @@ namespace ZL { bool inventoryOpen = false; + ZL::Quest::QuestJournal questJournal; + bool questJournalOpen = false; + int selectedQuestIndex = -1; + std::vector visibleQuestIds; + MenuManager menuManager; private: @@ -69,6 +75,11 @@ namespace ZL { void handleDown(int64_t fingerId, int mx, int my); void handleUp(int64_t fingerId, int mx, int my); void handleMotion(int64_t fingerId, int mx, int my); + + void setupQuestJournalUi(); + void toggleQuestJournal(); + void refreshQuestJournalUi(); + void selectQuestByIndex(int index); #ifdef EMSCRIPTEN static Game* s_instance; diff --git a/src/UiManager.cpp b/src/UiManager.cpp index d20487c..c5f4126 100644 --- a/src/UiManager.cpp +++ b/src/UiManager.cpp @@ -4,12 +4,90 @@ #include #include #include +#include #include "GameConstants.h" namespace ZL { using json = nlohmann::json; + + static int countWrappedLines(const std::string& text) { + if (text.empty()) return 0; + int lines = 1; + for (char c : text) { + if (c == '\n') ++lines; + } + return lines; + } + + static std::string limitLines(const std::string& text, int maxLines) { + if (maxLines <= 0) return text; + std::string out; + int lines = 1; + for (char c : text) { + if (c == '\n') { + if (lines >= maxLines) { + out += "..."; + return out; + } + ++lines; + } + out.push_back(c); + } + return out; + } + + static std::string wrapTextByPixels(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale, int maxLines = 0) { + if (input.empty() || maxWidthPx <= 1.0f) return input; + + std::string output; + std::string currentLine; + std::string currentWord; + auto flushLine = [&]() { + if (!currentLine.empty()) { + if (!output.empty()) output.push_back('\n'); + output += currentLine; + currentLine.clear(); + } + }; + auto pushWord = [&](const std::string& word) { + if (word.empty()) return; + if (currentLine.empty()) { + currentLine = word; + return; + } + const std::string candidate = currentLine + " " + word; + if (textRenderer.measureTextWidth(candidate, scale) <= maxWidthPx) { + currentLine = candidate; + } else { + flushLine(); + currentLine = word; + } + }; + + for (size_t i = 0; i < input.size(); ++i) { + const char ch = input[i]; + if (ch == '\n') { + pushWord(currentWord); + currentWord.clear(); + flushLine(); + continue; + } + if (ch == ' ' || ch == '\t' || ch == '\r') { + pushWord(currentWord); + currentWord.clear(); + continue; + } + currentWord.push_back(ch); + } + + pushWord(currentWord); + flushLine(); + + return limitLines(output, maxLines); + } + static float applyEasing(const std::string& easing, float t) { if (easing == "easein") { return t * t; @@ -130,14 +208,60 @@ namespace ZL { // Draw text on top (uses absolute coords, add anim offset manually) + // use left padding, which is required for inventory/quest lists. if (textRenderer && !text.empty()) { - float cx = rect.x + rect.w / 2.0f + animOffsetX; - float cy = rect.y + rect.h / 2.0f + animOffsetY; - textRenderer->drawText(text, cx, cy, 1.0f, textCentered, color); + float tx = rect.x + rect.w / 2.0f + animOffsetX; + if (!textCentered) { + tx = rect.x + textPaddingX + animOffsetX; + } + const float ty = rect.y + rect.h * 0.5f + textPaddingY + animOffsetY; + textRenderer->drawText(text, tx, ty, 1.0f, textCentered, color); } glEnable(GL_DEPTH_TEST); } + void UiTextView::draw(Renderer& renderer) const { + (void)renderer; + if (!textRenderer || text.empty()) { + return; + } + + const float scale = 1.0f; + + // Backward compatibility: + // Old UI files, including the original inventory panel, positioned TextView + // around the rect center. If a TextView does not explicitly request wrapping, + // top alignment, padding or line limiting, keep that old behavior. + const bool usesModernRectText = wrap || topAligned || paddingX != 0.0f || paddingY != 0.0f || maxLines > 0; + if (!usesModernRectText) { + textRenderer->drawText( + text, + rect.x + rect.w * 0.5f, + rect.y + rect.h * 0.5f, + scale, + centered, + color + ); + return; + } + + const float availableWidth = max(1.0f, rect.w - paddingX * 2.0f); + const std::string finalText = wrap + ? wrapTextByPixels(text, *textRenderer, availableWidth, scale, maxLines) + : limitLines(text, maxLines); + + float tx = centered ? rect.x + rect.w * 0.5f : rect.x + paddingX; + float ty = rect.y + rect.h * 0.5f; + + if (topAligned) { + // TextRenderer expects a baseline position. This offset places the first + // visible line close to the top inside the TextView rectangle. + ty = rect.y + rect.h - paddingY - static_cast(fontSize); + } + + textRenderer->drawText(finalText, tx, ty, scale, centered, color); + } + void UiSlider::buildTrackMesh() { trackMesh.data.PositionData.clear(); trackMesh.data.TexCoordData.clear(); @@ -490,6 +614,8 @@ namespace ZL { if (j.contains("fontPath")) tb->fontPath = j["fontPath"].get(); if (j.contains("fontSize")) tb->fontSize = j["fontSize"].get(); if (j.contains("textCentered")) tb->textCentered = j["textCentered"].get(); + if (j.contains("textPaddingX")) tb->textPaddingX = j["textPaddingX"].get(); + if (j.contains("textPaddingY")) tb->textPaddingY = j["textPaddingY"].get(); if (j.contains("color") && j["color"].is_array() && j["color"].size() == 4) { for (int i = 0; i < 4; ++i) tb->color[i] = j["color"][i].get(); } @@ -573,6 +699,11 @@ namespace ZL { } } if (j.contains("centered")) tv->centered = j["centered"].get(); + if (j.contains("wrap")) tv->wrap = j["wrap"].get(); + if (j.contains("topAligned")) tv->topAligned = j["topAligned"].get(); + if (j.contains("paddingX")) tv->paddingX = j["paddingX"].get(); + if (j.contains("paddingY")) tv->paddingY = j["paddingY"].get(); + if (j.contains("maxLines")) tv->maxLines = j["maxLines"].get(); tv->textRenderer = std::make_unique(); if (!tv->textRenderer->init(renderer, tv->fontPath, tv->fontSize, zipFile)) { @@ -675,6 +806,25 @@ namespace ZL { replaceRoot(newRoot); } + void UiManager::appendFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile) { + std::shared_ptr extraRoot = loadUiFromFile(path, renderer, zipFile); + if (!extraRoot) { + std::cerr << "UiManager: appendFromFile failed: " << path << std::endl; + return; + } + + if (!root) { + replaceRoot(extraRoot); + return; + } + + for (auto& child : extraRoot->children) { + root->children.push_back(child); + } + + replaceRoot(root); + } + void UiManager::layoutNode(const std::shared_ptr& node, float parentX, float parentY, float parentW, float parentH, float finalLocalX, float finalLocalY) { @@ -1640,6 +1790,15 @@ namespace ZL { return true; } + bool UiManager::setTextColor(const std::string& name, const std::array& color) { + auto tv = findTextView(name); + if (!tv) { + return false; + } + tv->color = color; + return true; + } + std::shared_ptr UiManager::findTextButton(const std::string& name) { for (auto& tb : textButtons) if (tb->name == name) return tb; return nullptr; @@ -1672,6 +1831,13 @@ namespace ZL { return true; } + bool UiManager::setTextButtonColor(const std::string& name, const std::array& color) { + auto tb = findTextButton(name); + if (!tb) return false; + tb->color = color; + return true; + } + std::shared_ptr UiManager::findNode(const std::string& name) { if (!root) return nullptr; return findNodeByName(root, name); diff --git a/src/UiManager.h b/src/UiManager.h index ce14d3b..1246249 100644 --- a/src/UiManager.h +++ b/src/UiManager.h @@ -145,6 +145,8 @@ namespace ZL { int fontSize = 32; std::array color = { 1.f, 1.f, 1.f, 1.f }; bool textCentered = true; + float textPaddingX = 12.0f; + float textPaddingY = 0.0f; std::unique_ptr textRenderer; @@ -169,14 +171,15 @@ namespace ZL { int fontSize = 32; std::array color = { 1.f, 1.f, 1.f, 1.f }; // rgba bool centered = true; + bool wrap = false; + bool topAligned = true; + float paddingX = 0.0f; + float paddingY = 0.0f; + int maxLines = 0; // 0 = no line limit std::unique_ptr textRenderer; - void draw(Renderer& renderer) const { - if (textRenderer) { - textRenderer->drawText(text, rect.x + rect.w / 2, rect.y + rect.h / 2, 1.0f, centered, color); - } - } + void draw(Renderer& renderer) const; }; struct UiTextField { @@ -273,6 +276,7 @@ namespace ZL { void replaceRoot(std::shared_ptr newRoot); void loadFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile = ""); + void appendFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile = ""); void draw(Renderer& renderer); @@ -329,6 +333,7 @@ namespace ZL { bool setTextButtonCallback(const std::string& name, std::function cb); bool setTextButtonPressCallback(const std::string& name, std::function cb); bool setTextButtonText(const std::string& name, const std::string& newText); + bool setTextButtonColor(const std::string& name, const std::array& color); bool addSlider(const std::string& name, const UiRect& rect, Renderer& renderer, const std::string& zipFile, const std::string& trackPath, const std::string& knobPath, float initialValue = 0.0f, bool vertical = true); @@ -339,6 +344,7 @@ namespace ZL { std::shared_ptr findTextView(const std::string& name); bool setText(const std::string& name, const std::string& newText); + bool setTextColor(const std::string& name, const std::array& color); std::shared_ptr findTextField(const std::string& name); bool setTextFieldCallback(const std::string& name, std::function cb); diff --git a/src/quest/QuestJournal.cpp b/src/quest/QuestJournal.cpp new file mode 100644 index 0000000..5e9ae3a --- /dev/null +++ b/src/quest/QuestJournal.cpp @@ -0,0 +1,211 @@ +#include "quest/QuestJournal.h" +#include "external/nlohmann/json.hpp" +#include "utils/Utils.h" +#include +#include + +namespace ZL::Quest { + +using json = nlohmann::json; + +const char* toString(QuestStatus status) { + switch (status) { + case QuestStatus::Hidden: return "Hidden"; + case QuestStatus::Available: return "Available"; + case QuestStatus::Active: return "Active"; + case QuestStatus::Completed: return "Completed"; + case QuestStatus::Failed: return "Failed"; + default: return "Unknown"; + } +} + +const char* toString(QuestCategory category) { + switch (category) { + case QuestCategory::Main: return "Main"; + case QuestCategory::Side: return "Side"; + case QuestCategory::Contract: return "Contract"; + case QuestCategory::Tutorial: return "Tutorial"; + default: return "Unknown"; + } +} + +static QuestStatus parseQuestStatus(const std::string& value) { + if (value == "Available") return QuestStatus::Available; + if (value == "Active") return QuestStatus::Active; + if (value == "Completed") return QuestStatus::Completed; + if (value == "Failed") return QuestStatus::Failed; + return QuestStatus::Hidden; +} + +static QuestCategory parseQuestCategory(const std::string& value) { + if (value == "Main") return QuestCategory::Main; + if (value == "Contract") return QuestCategory::Contract; + if (value == "Tutorial") return QuestCategory::Tutorial; + return QuestCategory::Side; +} + +bool QuestJournal::loadFromFile(const std::string& path, const std::string& zipFile) { + quests.clear(); + questOrder.clear(); + + std::string content; + try { + if (zipFile.empty()) { + content = ZL::readTextFile(path); + } + else { + auto data = ZL::readFileFromZIP(path, zipFile); + content.assign(data.begin(), data.end()); + } + } + catch (const std::exception& e) { + std::cerr << "[quest] Failed to read " << path << ": " << e.what() << std::endl; + return false; + } + + if (content.empty()) { + std::cerr << "[quest] Empty quest file: " << path << std::endl; + return false; + } + + json root; + try { + root = json::parse(content); + } + catch (const std::exception& e) { + std::cerr << "[quest] JSON parse error in " << path << ": " << e.what() << std::endl; + return false; + } + + if (!root.contains("quests") || !root["quests"].is_array()) { + std::cerr << "[quest] Missing quests array in " << path << std::endl; + return false; + } + + for (const auto& item : root["quests"]) { + QuestDefinition def; + def.id = item.value("id", ""); + def.title = item.value("title", def.id); + def.description = item.value("description", ""); + def.category = parseQuestCategory(item.value("category", "Side")); + def.initialStatus = parseQuestStatus(item.value("status", "Hidden")); + def.recommendedLevel = item.value("recommendedLevel", 0); + + if (item.contains("objectives") && item["objectives"].is_array()) { + for (const auto& obj : item["objectives"]) { + QuestObjective objective; + objective.id = obj.value("id", ""); + objective.text = obj.value("text", ""); + objective.completed = obj.value("completed", false); + if (!objective.id.empty()) { + def.objectives.push_back(objective); + } + } + } + + if (def.id.empty()) { + continue; + } + + QuestState state; + state.definition = def; + state.status = def.initialStatus; + state.activeObjectiveIndex = 0; + state.orderIndex = static_cast(questOrder.size()); + + quests[def.id] = std::move(state); + questOrder.push_back(def.id); + } + + return true; +} + +bool QuestJournal::setStatus(const std::string& questId, QuestStatus status) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + quest->status = status; + return true; +} + +bool QuestJournal::unlockQuest(const std::string& questId) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + if (quest->status == QuestStatus::Hidden) { + quest->status = QuestStatus::Available; + } + return true; +} + +bool QuestJournal::startQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Active); +} + +bool QuestJournal::completeQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Completed); +} + +bool QuestJournal::failQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Failed); +} + +bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + + for (auto& objective : quest->definition.objectives) { + if (objective.id == objectiveId) { + objective.completed = completed; + return true; + } + } + + return false; +} + +bool QuestJournal::setActiveObjective(const std::string& questId, int objectiveIndex) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + + if (objectiveIndex < 0 || objectiveIndex >= static_cast(quest->definition.objectives.size())) { + return false; + } + + quest->activeObjectiveIndex = objectiveIndex; + return true; +} + +QuestState* QuestJournal::findQuest(const std::string& questId) { + auto it = quests.find(questId); + return (it != quests.end()) ? &it->second : nullptr; +} + +const QuestState* QuestJournal::findQuest(const std::string& questId) const { + auto it = quests.find(questId); + return (it != quests.end()) ? &it->second : nullptr; +} + +std::vector QuestJournal::getVisibleQuests() { + std::vector result; + for (const std::string& id : questOrder) { + QuestState* quest = findQuest(id); + if (!quest) continue; + if (quest->status != QuestStatus::Hidden) { + result.push_back(quest); + } + } + return result; +} + +std::vector QuestJournal::getVisibleQuests() const { + std::vector result; + for (const std::string& id : questOrder) { + const QuestState* quest = findQuest(id); + if (!quest) continue; + if (quest->status != QuestStatus::Hidden) { + result.push_back(quest); + } + } + return result; +} + +} // namespace ZL::Quest diff --git a/src/quest/QuestJournal.h b/src/quest/QuestJournal.h new file mode 100644 index 0000000..12eec2d --- /dev/null +++ b/src/quest/QuestJournal.h @@ -0,0 +1,35 @@ +#pragma once + +#include "quest/QuestTypes.h" +#include +#include +#include + +namespace ZL::Quest { + +class QuestJournal { +public: + bool loadFromFile(const std::string& path, const std::string& zipFile = ""); + + bool unlockQuest(const std::string& questId); + bool startQuest(const std::string& questId); + bool completeQuest(const std::string& questId); + bool failQuest(const std::string& questId); + + bool setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true); + bool setActiveObjective(const std::string& questId, int objectiveIndex); + + QuestState* findQuest(const std::string& questId); + const QuestState* findQuest(const std::string& questId) const; + + std::vector getVisibleQuests(); + std::vector getVisibleQuests() const; + +private: + std::unordered_map quests; + std::vector questOrder; + + bool setStatus(const std::string& questId, QuestStatus status); +}; + +} // namespace ZL::Quest diff --git a/src/quest/QuestTypes.h b/src/quest/QuestTypes.h new file mode 100644 index 0000000..a9ca481 --- /dev/null +++ b/src/quest/QuestTypes.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace ZL::Quest { + +enum class QuestStatus { + Hidden, + Available, + Active, + Completed, + Failed +}; + +enum class QuestCategory { + Main, + Side, + Contract, + Tutorial +}; + +struct QuestObjective { + std::string id; + std::string text; + bool completed = false; +}; + +struct QuestDefinition { + std::string id; + std::string title; + std::string description; + QuestCategory category = QuestCategory::Side; + QuestStatus initialStatus = QuestStatus::Hidden; + int recommendedLevel = 0; + std::vector objectives; +}; + +struct QuestState { + QuestDefinition definition; + QuestStatus status = QuestStatus::Hidden; + int activeObjectiveIndex = 0; + int orderIndex = 0; // acquisition/load order; larger value means newer quest +}; + +const char* toString(QuestStatus status); +const char* toString(QuestCategory category); + +} // namespace ZL::Quest diff --git a/src/render/TextRenderer.h b/src/render/TextRenderer.h index f70f632..0dd3075 100644 --- a/src/render/TextRenderer.h +++ b/src/render/TextRenderer.h @@ -30,6 +30,7 @@ public: void drawText(const std::string& text, float x, float y, float scale, bool centered, std::array color = { 1.f,1.f,1.f,1.f }); float measureTextWidth(const std::string& text, float scale = 1.0f) const; + float getLineHeight(float scale = 1.0f) const { return lineHeight * scale; } // Clear cached meshes (call on window resize / DPI change) void ClearCache();