#include "MenuManager.h" #include "render/TextRenderer.h" #include #include #include namespace ZL { static int questStatusPriority(Quest::QuestStatus status) { switch (status) { case Quest::QuestStatus::Available: return 0; case Quest::QuestStatus::Completed: return 1; case Quest::QuestStatus::Failed: return 2; default: return 3; } } static int countWrappedLines(const std::string& text, const TextRenderer& tr, float maxWidth) { if (text.empty()) return 1; int lines = 0; std::string currentLine; auto flushLine = [&]() { ++lines; currentLine.clear(); }; auto pushWord = [&](const std::string& word) { if (word.empty()) return; if (currentLine.empty()) { currentLine = word; } else { const std::string candidate = currentLine + " " + word; if (tr.measureTextWidth(candidate) <= maxWidth) { currentLine = candidate; } else { flushLine(); currentLine = word; } } }; std::string currentWord; for (char ch : text) { if (ch == '\n') { pushWord(currentWord); currentWord.clear(); flushLine(); } else if (ch == ' ' || ch == '\t' || ch == '\r') { pushWord(currentWord); currentWord.clear(); } else { currentWord.push_back(ch); } } pushWord(currentWord); if (!currentLine.empty()) flushLine(); return max(1, lines); } static std::string formatMoney(int amount) { bool negative = amount < 0; std::string digits = std::to_string(negative ? -amount : amount); std::string result; const int len = static_cast(digits.size()); for (int i = 0; i < len; ++i) { if (i > 0 && (len - i) % 3 == 0) result += ' '; result += digits[i]; } if (negative) result = "-" + result; return result + " сом"; } 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::Available: return { 1.0f, 1.0f, 1.0f, 1.0f }; default: return { 0.45f, 0.45f, 0.45f, 1.0f }; } } MenuManager::MenuManager(Renderer& iRenderer) : renderer(iRenderer) { } void MenuManager::setup(Inventory& inv, const std::string& zipFile) { inventory = &inv; //hudRoot = loadUiFromFile("resources/config2/hud.json", renderer, zipFile); hudRoot = loadUiFromFile("resources/w/ui/hud_step0.json", renderer, zipFile); hudStep1Root = loadUiFromFile("resources/w/ui/hud_step1.json", renderer, zipFile); hudStep2Root = loadUiFromFile("resources/w/ui/hud_step2.json", renderer, zipFile); hudStep3Root = loadUiFromFile("resources/w/ui/hud_step3.json", renderer, zipFile); hudStep4Root = loadUiFromFile("resources/w/ui/hud_step4.json", renderer, zipFile); hudStep5aRoot = loadUiFromFile("resources/w/ui/hud_step5a.json", renderer, zipFile); hudStep5bRoot = loadUiFromFile("resources/w/ui/hud_step5b.json", renderer, zipFile); hudStep5abRoot = loadUiFromFile("resources/w/ui/hud_step5ab.json", renderer, zipFile); hudUniExtRoot = loadUiFromFile("resources/w/ui/hud_uni_ext.json", renderer, zipFile); hudUniIntStep10Root = loadUiFromFile("resources/w/ui/hud_uni_int_step10.json", renderer, zipFile); hudUniIntStep11Root = loadUiFromFile("resources/w/ui/hud_uni_int_step11.json", renderer, zipFile); hudUniIntStep12Root = loadUiFromFile("resources/w/ui/hud_uni_int_step12.json", renderer, zipFile); hudUniIntStep13Root = loadUiFromFile("resources/w/ui/hud_uni_int_step13.json", renderer, zipFile); hudUniIntFullRoot = loadUiFromFile("resources/w/ui/hud_uni_int_full.json", renderer, zipFile); hudUniIntDarkFullRoot = loadUiFromFile("resources/w/ui/hud_uni_int_dark_full.json", renderer, zipFile); hudUniExtDarkRoot = loadUiFromFile("resources/w/ui/hud_uni_ext_dark.json", renderer, zipFile); hudCutsceneRoot_ = loadUiFromFile("resources/w/ui/hud_cutscene.json", renderer, zipFile); hudTopHintRoot_ = loadUiFromFile("resources/w/ui/hud_top_hint01.json", renderer, zipFile); phoneMainRoot = loadUiFromFile("resources/w/ui/screen_phone.json", renderer, zipFile); phoneMainHintARoot = loadUiFromFile("resources/w/ui/screen_phone_hint001.json", renderer, zipFile); phoneMainHintBRoot = loadUiFromFile("resources/w/ui/screen_phone_hint002.json", renderer, zipFile); phoneMainHintABRoot = loadUiFromFile("resources/w/ui/screen_phone_hint001_002.json", renderer, zipFile); phoneBankRoot = loadUiFromFile("resources/w/ui/screen_phone_bank.json", renderer, zipFile); phoneVideoRoot = loadUiFromFile("resources/w/ui/screen_phone_video.json", renderer, zipFile); phoneMapDormRoot = loadUiFromFile("resources/w/ui/screen_phone_map_dorm.json", renderer, zipFile); phoneMapUniRoot = loadUiFromFile("resources/w/ui/screen_phone_map_uni.json", renderer, zipFile); phoneChatListRoot = loadUiFromFile("resources/w/ui/screen_phone_chat_list.json", renderer, zipFile); phoneChatListHintRoot = loadUiFromFile("resources/w/ui/screen_phone_chat_list_hint001.json", renderer, zipFile); phoneChat1Root = loadUiFromFile("resources/w/ui/screen_phone_chat1.json", renderer, zipFile); phoneChat2Root = loadUiFromFile("resources/w/ui/screen_phone_chat2.json", renderer, zipFile); phoneChat3Root = loadUiFromFile("resources/w/ui/screen_phone_chat3.json", renderer, zipFile); newInventoryRoot = loadUiFromFile("resources/w/ui/screen_inventory.json", renderer, zipFile); questJournalRoot = loadUiFromFile("resources/w/ui/screen_journal.json", renderer, zipFile); mainMenuRoot = loadUiFromFile("resources/w/ui/screen_main_menu.json", renderer, zipFile); aboutScreenRoot = loadUiFromFile("resources/w/ui/screen_about.json", renderer, zipFile); texObjectiveCompleted_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_completed.png", zipFile, true); texObjectiveBlank_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/quest_objective_blank.png", zipFile, true); texItemSelected_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/ButtonBkg001.png", zipFile, true); texItemTransparent_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/ButtonBkgTransparent001.png", zipFile, true); questJournal.loadFromFile("resources/quests/quests.json", zipFile); renderer.textureManager.LoadFromPng("resources/w/ui/img/toast/quest_new001.png", zipFile, true); renderer.textureManager.LoadFromPng("resources/w/ui/img/toast/quest_completed001.png", zipFile, true); renderer.textureManager.LoadFromPng("resources/w/ui/img/toast/quest_failed001.png", zipFile, true); questJournal.onQuestUnlocked = [this](const std::string& id) { const Quest::QuestState* q = questJournal.findQuest(id); if (q) showToast("resources/w/ui/img/toast/quest_new001.png", q->definition.title); }; questJournal.onQuestCompleted = [this](const std::string& id) { const Quest::QuestState* q = questJournal.findQuest(id); if (q) showToast("resources/w/ui/img/toast/quest_completed001.png", q->definition.title); }; questJournal.onQuestFailed = [this](const std::string& id) { const Quest::QuestState* q = questJournal.findQuest(id); if (q) showToast("resources/w/ui/img/toast/quest_failed001.png", q->definition.title); }; /* questJournal.onObjectiveCompleted = [this](const std::string& id, const std::string&) { const Quest::QuestState* q = questJournal.findQuest(id); if (q) showToast("resources/w/ui/img/toast/objective_completed001.png", q->definition.title); };*/ const std::string imgDir = "resources/w/ui/img/phone/"; texBubbleInCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_center.png", zipFile, true); texBubbleInLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_top.png", zipFile, true); texBubbleInLB_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_left_bottom.png", zipFile, true); texBubbleInRT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_right_top.png", zipFile, true); texBubbleInRB_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_in_corner_right_bottom.png",zipFile, true); texBubbleOutCenter_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_out_center.png", zipFile, true); texBubbleOutLT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_out_corner_left_top.png", zipFile, true); texBubbleOutLB_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_out_corner_left_bottom.png",zipFile, true); texBubbleOutRT_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_out_corner_right_top.png", zipFile, true); texBubbleOutRB_ = renderer.textureManager.LoadFromPng(imgDir + "bubble_out_corner_right_bottom.png",zipFile, true); showMainMenu(); } void MenuManager::enterGameplay() { if (state == GameState::MainMenu && startGameFunc) startGameFunc(); state = GameState::Gameplay; uiManager.replaceRoot(hudRoot); topUiManager.replaceRoot(hudTopHintRoot_); applyCurrentHealthBar(); /* uiManager.setTextButtonCallback("inventory_button", [this](const std::string&) { openInventory(); }); uiManager.setTextButtonCallback("quest_journal_button", [this](const std::string&) { openQuestJournal(); });*/ uiManager.setButtonCallback("inventoryButton", [this](const std::string&) { openInventory(); }); //openInventory() } void MenuManager::showMainMenu() { state = GameState::MainMenu; uiManager.replaceRoot(mainMenuRoot); uiManager.setTextButtonCallback("menuStartButton", [this](const std::string&) { enterGameplay(); }); uiManager.setTextButtonCallback("menuAboutButton", [this](const std::string&) { showAboutScreen(); }); #ifndef EMSCRIPTEN uiManager.setTextButtonCallback("menuExitButton", [this](const std::string&) { Environment::exitGameLoop = true; }); #else uiManager.setNodeVisible("menuExitButton", false); #endif } void MenuManager::showAboutScreen() { uiManager.pushMenuFromSavedRoot(aboutScreenRoot); uiManager.setTextButtonCallback("aboutBackButton", [this](const std::string&) { uiManager.popMenu(); }); } void MenuManager::openInventory() { state = GameState::Inventory; uiManager.pushMenuFromSavedRoot(newInventoryRoot); uiManager.setButtonCallback("inventoryExitButton", [this](const std::string&) { closeInventory(); }); const auto& items = inventory->getItems(); std::string itemText; if (items.empty()) { itemText = "Inventory (Empty)"; } else { itemText = "Inventory (" + std::to_string(items.size()) + " items)\n\n"; for (size_t i = 0; i < items.size(); ++i) { itemText += std::to_string(i + 1) + ". " + items[i].name + "\n"; const int maxSlots = 9; for (int i = 0; i < maxSlots; ++i) { const std::string btnName = "item" + std::to_string(i + 1) + "Button"; if (i < static_cast(items.size())) { uiManager.setNodeVisible(btnName, true); auto btn = uiManager.findButton(btnName); if (btn) { auto tex = renderer.textureManager.LoadFromPng(items[i].icon, CONST_ZIP_FILE, true); btn->texNormal = btn->texHover = btn->texPressed = tex; } uiManager.setButtonCallback(btnName, [this, i](const std::string&) { selectInventoryItem(i); }); } else { uiManager.setNodeVisible(btnName, false); } } inventorySelectedIndex_ = -1; if (!items.empty()) { selectInventoryItem(0); } } } } void MenuManager::selectInventoryItem(int index) { const auto& items = inventory->getItems(); if (index < 0 || index >= static_cast(items.size())) return; // Revert previously selected button to its regular icon if (inventorySelectedIndex_ >= 0 && inventorySelectedIndex_ < static_cast(items.size())) { const std::string prevBtnName = "item" + std::to_string(inventorySelectedIndex_ + 1) + "Button"; auto prevBtn = uiManager.findButton(prevBtnName); if (prevBtn) { auto tex = renderer.textureManager.LoadFromPng(items[inventorySelectedIndex_].icon, CONST_ZIP_FILE, true); prevBtn->texNormal = prevBtn->texHover = prevBtn->texPressed = tex; } } inventorySelectedIndex_ = index; const auto& item = items[index]; // Highlight newly selected button with its selected icon const std::string btnName = "item" + std::to_string(index + 1) + "Button"; auto btn = uiManager.findButton(btnName); if (btn) { const std::string& selPath = item.selectedIcon.empty() ? item.icon : item.selectedIcon; auto tex = renderer.textureManager.LoadFromPng(selPath, CONST_ZIP_FILE, true); btn->texNormal = btn->texHover = btn->texPressed = tex; } // Update the large selected picture on the right panel auto img = uiManager.findStaticImage("selectedItemPic"); if (img) { const std::string& path = item.selectedIcon.empty() ? item.icon : item.selectedIcon; img->texture = renderer.textureManager.LoadFromPng(path, CONST_ZIP_FILE, true); } uiManager.setText("selectedText", item.name); uiManager.setText("selectedDescription", item.description); } void MenuManager::closeInventory() { state = GameState::Gameplay; uiManager.popMenu(); } void MenuManager::openQuestJournal() { state = GameState::QuestJournal; tutorialJournalScreenOpened = true; uiManager.setNodeVisible("hint6b", false); uiManager.setNodeVisible("hint6barrow", false); uiManager.pushMenuFromSavedRoot(questJournalRoot); uiManager.setButtonCallback("journalExitButton", [this](const std::string&) { closeQuestJournal(); }); static const char* kItemNames[9] = { "item1name","item2name","item3name", "item4name","item5name","item6name", "item7name","item8name","item9name" }; for (int i = 0; i < 9; ++i) { uiManager.setTextButtonCallback(kItemNames[i], [this, i](const std::string&) { selectQuestByIndex(i); }); } refreshQuestJournalUi(); if (!visibleQuestIds.empty()) { selectQuestByIndex(0); } } void MenuManager::closeQuestJournal() { state = GameState::Gameplay; selectedQuestIndex = -1; visibleQuestIds.clear(); uiManager.popMenu(); } void MenuManager::toggleQuestJournal() { std::cout << "[quest] toggleQuestJournal: " << (isQuestJournalOpen() ? "closing" : "opening") << std::endl; if (state == GameState::QuestJournal) { closeQuestJournal(); } else { if (state == GameState::Inventory) { closeInventory(); } openQuestJournal(); } } void MenuManager::openPhoneScreen() { state = GameState::PhoneScreen; //uiManager.setNodeVisible("hint6a", false); //uiManager.setNodeVisible("hint6aarrow", false); //tutorialPhoneScreenOpened = true; if (tutorialNeedOpenTaxiScreen && (tutorialPhoneChatScreenOpened == false)) { uiManager.pushMenuFromSavedRoot(phoneMainHintABRoot); } else if (tutorialNeedOpenTaxiScreen) { uiManager.pushMenuFromSavedRoot(phoneMainHintBRoot); } else if (tutorialPhoneChatScreenOpened == false) { uiManager.pushMenuFromSavedRoot(phoneMainHintARoot); } else { uiManager.pushMenuFromSavedRoot(phoneMainRoot); } uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneEntirely(); }); uiManager.setButtonCallback("phoneMessenger", [this](const std::string&) { openPhoneMessenger(); }); uiManager.setButtonCallback("phoneBank", [this](const std::string&) { openPhoneBank(); }); uiManager.setButtonCallback("phoneVideo", [this](const std::string&) { openPhoneVideo(); }); uiManager.setButtonCallback("phoneTaxi", [this](const std::string&) { openPhoneTaxi(); }); if (isNight) { if (isDawn) { uiManager.setNodeVisible("phoneTimeDay", false); uiManager.setNodeVisible("phoneTimeNight", false); uiManager.setNodeVisible("phoneTimeDawn", true); } else { uiManager.setNodeVisible("phoneTimeDay", false); uiManager.setNodeVisible("phoneTimeNight", true); uiManager.setNodeVisible("phoneTimeDawn", false); } } else { uiManager.setNodeVisible("phoneTimeDay", true); uiManager.setNodeVisible("phoneTimeNight", false); uiManager.setNodeVisible("phoneTimeDawn", false); } } void MenuManager::openPhoneMessenger() { if (tutorialPhoneChatScreenOpened) { uiManager.pushMenuFromSavedRoot(phoneChatListRoot); } else { uiManager.pushMenuFromSavedRoot(phoneChatListHintRoot); } //uiManager.pushMenuFromSavedRoot(phoneChatListRoot); refreshChatUnreadIndicators(); uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneEntirely(); }); uiManager.setButtonCallback("phoneMain", [this](const std::string&) {}); uiManager.setTextButtonCallback("chat1button", [this](const std::string&) { chatUnread_[0] = false; openPhoneChatFromList(0, phoneChat1Root); }); uiManager.setTextButtonCallback("chat2button", [this](const std::string&) { chatUnread_[1] = false; openPhoneChatFromList(1, phoneChat2Root); }); uiManager.setTextButtonCallback("chat3button", [this](const std::string&) { chatUnread_[2] = false; openPhoneChatFromList(2, phoneChat3Root); }); } void MenuManager::refreshChatUnreadIndicators() { uiManager.setNodeVisible("chat1Unread", chatUnread_[0]); uiManager.setNodeVisible("chat2Unread", chatUnread_[1]); uiManager.setNodeVisible("chat3Unread", chatUnread_[2]); } void MenuManager::setChatUnread(int chatIndex, bool unread) { if (chatIndex < 0 || chatIndex > 2) return; chatUnread_[chatIndex] = unread; } void MenuManager::spendMoney(int amount) { money_ -= amount; } void MenuManager::openPhoneBank() { uiManager.pushMenuFromSavedRoot(phoneBankRoot); uiManager.setText("balanceText", formatMoney(money_)); uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneEntirely(); }); uiManager.setButtonCallback("buttonBack", [this](const std::string&) { uiManager.popMenu(); }); } void MenuManager::openPhoneVideo() { uiManager.pushMenuFromSavedRoot(phoneVideoRoot); uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneEntirely(); }); uiManager.setButtonCallback("buttonBack", [this](const std::string&) { uiManager.popMenu(); }); uiManager.setButtonCallback("videoSkip", [this](const std::string&) { closePhoneEntirely(); if (isNight) { startDialogueFunc("dialog_video002"); } else { startNightTransitionFunc(); } }); } void MenuManager::openPhoneTaxi() { if (currentLocationName_ == "uni_interior") { closePhoneEntirely(); if (startDialogueFunc) startDialogueFunc("dialog_taxi003"); } else if (currentLocationName_ == "uni_exterior") { openPhoneMapScreen(phoneMapUniRoot); } else { openPhoneMapScreen(phoneMapDormRoot); } } void MenuManager::openPhoneMapScreen(std::shared_ptr mapRoot) { uiManager.pushMenuFromSavedRoot(mapRoot); uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneEntirely(); }); uiManager.setButtonCallback("buttonBack", [this](const std::string&) { uiManager.popMenu(); }); uiManager.setButtonCallback("mapGo", [this](const std::string&) { tutorialNeedOpenTaxiScreen = false; if (callTaxiFunc) { callTaxiFunc(); } money_ -= 500; closePhoneEntirely(); if (startDialogueFunc) startDialogueFunc("dialog_taxi002"); }); } void MenuManager::openPhoneChatFromList(int chatIndex, std::shared_ptr chatRoot) { activeChatIndex_ = chatIndex; phoneChatVisibleBubbles_.clear(); tutorialPhoneChatScreenOpened = true; uiManager.pushMenuFromSavedRoot(chatRoot); rebuildChatBubblesFromHistory(chatIndex); uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneScreenFromChat(); }); uiManager.setButtonCallback("phoneMain", [this](const std::string&) {}); uiManager.setTextButtonCallback("chatTitleButton", [this](const std::string&) { returnToPhoneChatList(); }); if (chatOpenCallback) { chatOpenCallback(chatIndex); } } void MenuManager::returnToPhoneChatList() { activeChatIndex_ = -1; phoneChatVisibleBubbles_.clear(); uiManager.popMenu(); if (tutorialPhoneChatScreenOpened) { uiManager.setNodeVisible("hint_m002", false); } else { uiManager.setNodeVisible("hint_m002", true); } refreshChatUnreadIndicators(); } void MenuManager::closePhoneEntirely() { activeChatIndex_ = -1; state = GameState::Gameplay; phoneChatVisibleBubbles_.clear(); const int depth = uiManager.menuStackSize(); for (int i = 0; i < depth; ++i) uiManager.popMenu(); if (tutorialNeedOpenTaxiScreen) { uiManager.setNodeVisible("hint7", true); uiManager.setNodeVisible("hint7arrow", true); } else { uiManager.setNodeVisible("hint7", false); uiManager.setNodeVisible("hint7arrow", false); if (tutorialPhoneChatScreenOpened) { uiManager.setNodeVisible("hint6a", false); uiManager.setNodeVisible("hint6aarrow", false); } else { uiManager.setNodeVisible("hint6a", true); uiManager.setNodeVisible("hint6aarrow", true); } } } void MenuManager::closePhoneScreenFromChat() { closePhoneEntirely(); } void MenuManager::closePhoneScreen() { closePhoneEntirely(); } void MenuManager::tutorialShowTaxiHint() { std::cout << "tutorialShowTaxiHint" << std::endl; tutorialNeedOpenTaxiScreen = true; if (state == GameState::Gameplay) { uiManager.setNodeVisible("hint6a", false); uiManager.setNodeVisible("hint6aarrow", false); uiManager.setNodeVisible("hint7", true); uiManager.setNodeVisible("hint7arrow", true); } } // Registers phoneButton / journalButton callbacks on the current HUD root // and hides any hints that have already been completed. // Called after every replaceRoot during step 5. void MenuManager::setupStep5Callbacks() { if (uiManager.findButton("phoneButton")) { uiManager.setButtonCallback("phoneButton", [this](const std::string&) { openPhoneScreen(); }); } if (uiManager.findButton("inventoryButton")) { uiManager.setButtonCallback("inventoryButton", [this](const std::string&) { openInventory(); }); } if (uiManager.findButton("journalButton")) { uiManager.setButtonCallback("journalButton", [this](const std::string&) { openQuestJournal(); }); } if (tutorialNeedOpenTaxiScreen) { uiManager.setNodeVisible("hint6a", false); uiManager.setNodeVisible("hint6aarrow", false); uiManager.setNodeVisible("hint7", true); uiManager.setNodeVisible("hint7arrow", true); } else { uiManager.setNodeVisible("hint7", false); uiManager.setNodeVisible("hint7arrow", false); } if (tutorialPhoneChatScreenOpened) { uiManager.setNodeVisible("hint6a", false); uiManager.setNodeVisible("hint6aarrow", false); } if (tutorialJournalScreenOpened) { uiManager.setNodeVisible("hint6b", false); uiManager.setNodeVisible("hint6barrow", false); } if (tutorialPhoneChatScreenOpened && tutorialJournalScreenOpened) { tutorialStep = TutorialStep::Step6; } } void MenuManager::setupGameplayHudCallbacks() { if (uiManager.findButton("phoneButton")) uiManager.setButtonCallback("phoneButton", [this](const std::string&) { openPhoneScreen(); }); if (uiManager.findButton("inventoryButton")) uiManager.setButtonCallback("inventoryButton", [this](const std::string&) { openInventory(); }); if (uiManager.findButton("journalButton")) uiManager.setButtonCallback("journalButton", [this](const std::string&) { openQuestJournal(); }); if (uiManager.findButton("darklandsButton")) uiManager.setButtonCallback("darklandsButton", [this](const std::string&) { startDarklandsTransitionFunc(); }); hideAllToastWidgets(); applyToastsToUi(); } void MenuManager::onLocationChanged(const std::string& locationName) { if (state != GameState::Gameplay) return; currentLocationName_ = locationName; if (locationName == "uni_exterior") { uiManager.replaceRoot(currentIsDarklands_ ? hudUniExtDarkRoot : hudUniExtRoot); applyCurrentHealthBar(); setupGameplayHudCallbacks(); } else if (locationName == "uni_interior") { applyUniIntHud(); } else { // Returning to dorm: reuse step5ab, suppress already-completed hints uiManager.replaceRoot(hudStep5abRoot); applyCurrentHealthBar(); setupStep5Callbacks(); } } void MenuManager::advanceTutorialStep() { std::shared_ptr nextRoot; switch (tutorialStep) { case TutorialStep::Step0: tutorialStep = TutorialStep::Step1; nextRoot = hudStep1Root; topUiManager.replaceRoot(nullptr); break; case TutorialStep::Step1: tutorialStep = TutorialStep::Step2; nextRoot = hudStep2Root; break; case TutorialStep::Step2: tutorialStep = TutorialStep::Step3; nextRoot = hudStep3Root; break; case TutorialStep::Step3: tutorialStep = TutorialStep::Step4; nextRoot = hudStep4Root; break; default: return; // Step4/Step5 transitions are driven by onItemPickedUp } if (state == GameState::Gameplay && nextRoot) { uiManager.replaceRoot(nextRoot); applyCurrentHealthBar(); uiManager.setButtonCallback("inventoryButton", [this](const std::string&) { openInventory(); }); } } void MenuManager::onItemPickedUp(const std::string& itemId) { if (itemId == "note_spell" && uniIntTutorialState_ == UniIntTutorialState::Step10) { uniIntTutorialState_ = UniIntTutorialState::Step11; if (currentLocationName_ == "uni_interior" && state == GameState::Gameplay) applyUniIntHud(); } if (tutorialStep != TutorialStep::Step4 && tutorialStep != TutorialStep::Step5) { return; } // Dorm tutorial HUD logic must not run in other locations. // currentLocationName_ is empty only before the first teleport (still in dorm). if (!currentLocationName_.empty() && currentLocationName_ != "location_dorm") { return; } if (itemId == "phone") tutorialPhonePickedUp = true; if (itemId == "journal") tutorialJournalPickedUp = true; if (tutorialStep == TutorialStep::Step4) { tutorialStep = TutorialStep::Step5; } refreshItemPickupHud(); } void MenuManager::refreshItemPickupHud() { if (state != GameState::Gameplay) return; std::shared_ptr nextRoot; if (tutorialPhonePickedUp && tutorialJournalPickedUp) { nextRoot = hudStep5abRoot; } else if (tutorialPhonePickedUp) { nextRoot = hudStep5aRoot; } else if (tutorialJournalPickedUp) { nextRoot = hudStep5bRoot; } if (nextRoot) { uiManager.replaceRoot(nextRoot); applyCurrentHealthBar(); // Register item-screen buttons and re-apply any already-completed hint visibility. setupStep5Callbacks(); } } void MenuManager::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; return a->orderIndex < b->orderIndex; }); static const char* kItemNames[9] = { "item1name","item2name","item3name", "item4name","item5name","item6name", "item7name","item8name","item9name" }; for (int i = 0; i < 9; ++i) { if (i < static_cast(quests.size())) { const auto* quest = quests[i]; visibleQuestIds.push_back(quest->definition.id); const bool selected = (i == selectedQuestIndex); std::array color; if (selected) { color = { 0.996f, 0.977f, 0.761f, 1.0f }; } else if (quest->status == Quest::QuestStatus::Completed) { color = { 0.02f, 0.875f, 0.447f, 0.6f }; } else if (quest->status == Quest::QuestStatus::Failed) { color = { 1.0f, 0.25f, 0.25f, 0.6f }; } else { color = { 0.996f, 0.977f, 0.761f, 0.7f }; } auto tb = uiManager.findTextButton(kItemNames[i]); if (tb) { auto tex = selected ? texItemSelected_ : texItemTransparent_; tb->texNormal = tb->texHover = tb->texPressed = tex; } uiManager.setTextButtonText(kItemNames[i], quest->definition.title); uiManager.setTextButtonColor(kItemNames[i], color); uiManager.setNodeVisible(kItemNames[i], true); auto node = uiManager.findNode(kItemNames[i]); if (node && tb && tb->textRenderer) { const float availW = node->width - 2.0f * tb->textPaddingX; const int numLines = countWrappedLines(quest->definition.title, *tb->textRenderer, availW); node->height = numLines * 30.0f + 30.0f; } } else { uiManager.setTextButtonText(kItemNames[i], ""); uiManager.setNodeVisible(kItemNames[i], false); auto node = uiManager.findNode(kItemNames[i]); if (node) node->height = 60.0f; } } uiManager.updateAllLayouts(); } void MenuManager::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; uiManager.setText("quest_title", def.title); static const char* kCheckboxes[3] = { "objective1checkbox", "objective2checkbox", "objective3checkbox" }; static const char* kObjNames[3] = { "objective1name", "objective2name", "objective3name" }; std::vector visibleObjs; for (const auto& obj : def.objectives) if (obj.visible) visibleObjs.push_back(&obj); for (int i = 0; i < 3; ++i) { if (i < static_cast(visibleObjs.size())) { const auto& obj = *visibleObjs[i]; const bool isActive = (&obj - def.objectives.data() == quest->activeObjectiveIndex); auto img = uiManager.findStaticImage(kCheckboxes[i]); if (img) img->texture = obj.completed ? texObjectiveCompleted_ : texObjectiveBlank_; std::array color; if (obj.completed) { color = { 0.02f, 0.875f, 0.447f, 0.6f }; } else if (isActive) { color = { 0.996f, 0.977f, 0.761f, 1.0f }; } else { color = { 0.996f, 0.977f, 0.761f, 0.9f }; } uiManager.setText(kObjNames[i], obj.text); uiManager.setTextColor(kObjNames[i], color); uiManager.setNodeVisible(kCheckboxes[i], true); uiManager.setNodeVisible(kObjNames[i], true); } else { uiManager.setNodeVisible(kCheckboxes[i], false); uiManager.setNodeVisible(kObjNames[i], false); } } uiManager.setText("quest_description", def.description); refreshQuestJournalUi(); } void MenuManager::resetPhoneChatNodes() { static const char* kChatNodes[] = { "message01in", "message02out", "message03in", "message04out", "message05in", "message06in", "message07in", "message08out", "message09in", "message10in", "message11in", nullptr }; for (int i = 0; kChatNodes[i]; ++i) { uiManager.setNodeVisible(kChatNodes[i], false); auto n = uiManager.findNode(kChatNodes[i]); if (n) { n->scaleX = 1.0f; n->scaleY = 1.0f; } } } void MenuManager::recomputePhoneChatPositions() { float totalHeight = 0.0f; for (size_t i = 0; i < phoneChatVisibleBubbles_.size(); ++i) { totalHeight += phoneChatVisibleBubbles_[i].height; if (i > 0) totalHeight += CHAT_SPACING; } const float available = CHAT_TOP_Y - CHAT_BOTTOM_Y; const float topY = (totalHeight <= available) ? CHAT_TOP_Y : CHAT_BOTTOM_Y + totalHeight; float cursor = topY; for (auto& bubble : phoneChatVisibleBubbles_) { auto node = uiManager.findNode(bubble.nodeName); if (!node) continue; node->localY = cursor - bubble.height; cursor -= bubble.height + CHAT_SPACING; } uiManager.updateAllLayouts(); } void MenuManager::rebuildChatBubblesFromHistory(int chatIndex) { uiManager.clearChatBubbles("chatMessagesContainer"); phoneChatVisibleBubbles_.clear(); if (chatIndex < 0 || chatIndex > 2) return; for (const auto& msg : chatHistory_[chatIndex]) { const bool inc = msg.incoming; const std::string nodeName = uiManager.addChatBubble( "chatMessagesContainer", msg.text, inc, inc ? texBubbleInCenter_ : texBubbleOutCenter_, inc ? texBubbleInLT_ : texBubbleOutLT_, inc ? texBubbleInLB_ : texBubbleOutLB_, inc ? texBubbleInRT_ : texBubbleOutRT_, inc ? texBubbleInRB_ : texBubbleOutRB_, renderer, "resources/fonts/DroidSans.ttf", 20, CONST_ZIP_FILE); if (!nodeName.empty()) { auto n = uiManager.findNode(nodeName); phoneChatVisibleBubbles_.push_back({ nodeName, n ? n->height : 60.0f }); } } recomputePhoneChatPositions(); } void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) { if (activeChatIndex_ < 0) return; auto& history = chatHistory_[activeChatIndex_]; if (static_cast(history.size()) >= 5) { history.erase(history.begin()); } history.push_back({ text, incoming }); if (state != GameState::PhoneScreen) return; const std::string nodeName = uiManager.addChatBubble( "chatMessagesContainer", text, incoming, incoming ? texBubbleInCenter_ : texBubbleOutCenter_, incoming ? texBubbleInLT_ : texBubbleOutLT_, incoming ? texBubbleInLB_ : texBubbleOutLB_, incoming ? texBubbleInRT_ : texBubbleOutRT_, incoming ? texBubbleInRB_ : texBubbleOutRB_, renderer, "resources/fonts/DroidSans.ttf", 20, CONST_ZIP_FILE); if (nodeName.empty()) return; auto node = uiManager.findNode(nodeName); if (node) { node->scaleX = 0.0f; node->scaleY = 0.0f; phoneChatVisibleBubbles_.push_back({ nodeName, node->height }); } recomputePhoneChatPositions(); uiManager.startPopIn(nodeName, 300.0f); } void MenuManager::setDarklandsMode(bool enabled) { currentIsDarklands_ = enabled; if (currentLocationName_ == "uni_interior") { if (enabled && uniIntTutorialState_ == UniIntTutorialState::Step11) { uniIntTutorialState_ = UniIntTutorialState::DarklandsActive; } applyUniIntHud(); } else if (currentLocationName_ == "uni_exterior") { if (state == GameState::Gameplay) { uiManager.replaceRoot(enabled ? hudUniExtDarkRoot : hudUniExtRoot); applyCurrentHealthBar(); setupGameplayHudCallbacks(); } } else { uiManager.setNodeVisible("darklandsButton", !enabled); uiManager.setNodeVisible("phoneButton", !enabled); } } void MenuManager::applyUniIntHud() { if (state != GameState::Gameplay) return; std::shared_ptr root; if (currentIsDarklands_) { switch (uniIntTutorialState_) { case UniIntTutorialState::DarklandsStep13: root = hudUniIntStep13Root; break; case UniIntTutorialState::DarklandsFull: root = hudUniIntDarkFullRoot; break; default: root = hudUniIntStep12Root; break; } } else { switch (uniIntTutorialState_) { case UniIntTutorialState::Step10: root = hudUniIntStep10Root; break; case UniIntTutorialState::Step11: root = hudUniIntStep11Root; break; default: root = hudUniIntFullRoot; break; } } uiManager.replaceRoot(root); applyCurrentHealthBar(); setupGameplayHudCallbacks(); // already calls hideAllToastWidgets + applyToastsToUi } void MenuManager::onPlayerStartedWalking() { if (currentLocationName_ == "uni_interior" && currentIsDarklands_ && uniIntTutorialState_ == UniIntTutorialState::DarklandsActive) { uiManager.setNodeVisible("hint_darklands003", false); uiManager.setNodeVisible("hint_darklands003_arrow", false); } } void MenuManager::advanceUniIntDarklandsHud() { if (uniIntTutorialState_ != UniIntTutorialState::DarklandsActive) return; uniIntTutorialState_ = UniIntTutorialState::DarklandsStep13; if (currentLocationName_ == "uni_interior" && currentIsDarklands_ && state == GameState::Gameplay) applyUniIntHud(); } void MenuManager::onEnemyKilledInUniInterior() { if (uniIntTutorialState_ != UniIntTutorialState::DarklandsActive && uniIntTutorialState_ != UniIntTutorialState::DarklandsStep13) return; uniIntTutorialState_ = UniIntTutorialState::DarklandsFull; if (currentLocationName_ == "uni_interior" && currentIsDarklands_ && state == GameState::Gameplay) applyUniIntHud(); } void MenuManager::updateHealthBar(float hp, float maxHp) { currentPlayerHp_ = hp; currentPlayerMaxHp_ = maxHp; applyCurrentHealthBar(); } void MenuManager::onCutsceneStarted() { cutsceneHudActive_ = true; topUiManager.replaceRoot(hudCutsceneRoot_); topUiManager.setButtonCallback("skipButton", [this](const std::string&) { if (skipCutsceneFunc) skipCutsceneFunc(); }); } void MenuManager::onCutsceneFinished() { cutsceneHudActive_ = false; topUiManager.replaceRoot(nullptr); if (state == GameState::Gameplay) onLocationChanged(currentLocationName_); } void MenuManager::applyCurrentHealthBar() { if (currentPlayerMaxHp_ <= 0.f) return; const float fraction = std::clamp(currentPlayerHp_ / currentPlayerMaxHp_, 0.f, 1.f); uiManager.setSliderValue("healthBarFill", fraction); std::string hpText = std::to_string(static_cast(currentPlayerHp_)) + "/" + std::to_string(static_cast(currentPlayerMaxHp_)); if (hpText.size() < 7) hpText.insert(0, 7 - hpText.size(), ' '); uiManager.setText("healthValue", hpText); } // ---- Toast notification system ---- float MenuManager::ToastEntry::currentAlpha() const { switch (state) { case State::FadeIn: return min(timer / TOAST_FADE_MS, 1.0f); case State::Visible: return 1.0f; case State::FadeOut: return max(1.0f - timer / TOAST_FADE_MS, 0.0f); } return 0.0f; } void MenuManager::showToast(const std::string& iconPath, const std::string& text) { if (activeToasts_.size() < 3) { ToastEntry e; e.iconPath = iconPath; e.text = text; activeToasts_.push_back(std::move(e)); } else { toastQueue_.push_back({ iconPath, text }); } applyToastsToUi(); } void MenuManager::updateToasts(float deltaMs) { if (activeToasts_.empty() && toastQueue_.empty()) return; bool changed = false; for (auto& e : activeToasts_) { e.timer += deltaMs; // Advance state machine if (e.state == ToastEntry::State::FadeIn && e.timer >= TOAST_FADE_MS) { e.state = ToastEntry::State::Visible; e.timer = 0.0f; changed = true; } else if (e.state == ToastEntry::State::Visible && e.timer >= TOAST_VISIBLE_MS) { e.state = ToastEntry::State::FadeOut; e.timer = 0.0f; changed = true; } else if (e.state == ToastEntry::State::FadeOut && e.timer >= TOAST_FADE_MS) { changed = true; } } // Remove finished entries (FadeOut complete) auto removed = std::remove_if(activeToasts_.begin(), activeToasts_.end(), [](const ToastEntry& e) { return e.state == ToastEntry::State::FadeOut && e.timer >= TOAST_FADE_MS; }); if (removed != activeToasts_.end()) { activeToasts_.erase(removed, activeToasts_.end()); changed = true; } // Pull queued messages into now-free slots (with FadeIn) while (!toastQueue_.empty() && activeToasts_.size() < 3) { ToastEntry e; e.iconPath = toastQueue_.front().iconPath; e.text = toastQueue_.front().text; toastQueue_.pop_front(); activeToasts_.push_back(std::move(e)); changed = true; } if (changed) applyToastsToUi(); // Also refresh alpha smoothly every frame while any slot is fading else if (!activeToasts_.empty()) applyToastsToUi(); } void MenuManager::applyToastsToUi() { static const char* imgNames[3] = { "toast001", "toast002", "toast003" }; static const char* txtNames[3] = { "toast001text", "toast002text", "toast003text" }; for (int i = 0; i < 3; ++i) { if (i < static_cast(activeToasts_.size())) { const ToastEntry& e = activeToasts_[i]; const float a = e.currentAlpha(); auto img = uiManager.findStaticImage(imgNames[i]); auto tv = uiManager.findTextView(txtNames[i]); if (img) { img->texture = renderer.textureManager.LoadFromPng(e.iconPath, CONST_ZIP_FILE, true); img->alpha = a; } if (tv) { tv->color[3] = a; } uiManager.setText(txtNames[i], e.text); uiManager.setNodeVisible(imgNames[i], true); uiManager.setNodeVisible(txtNames[i], true); } else { uiManager.setNodeVisible(imgNames[i], false); uiManager.setNodeVisible(txtNames[i], false); } } } void MenuManager::hideAllToastWidgets() { static const char* names[6] = { "toast001", "toast001text", "toast002", "toast002text", "toast003", "toast003text" }; for (const char* name : names) uiManager.setNodeVisible(name, false); } void MenuManager::update(float deltaMs) { updateToasts(deltaMs); } } // namespace ZL