Files
shadow-over-bishkek001/src/MenuManager.cpp
T

2557 lines
97 KiB
C++

#include "MenuManager.h"
#include "Game.h"
#include "Localization.h"
#include "items/ItemRegistry.h"
#include "Campaign.h"
#include "render/TextRenderer.h"
#include "utils/Utils.h"
#include <iostream>
#include <fstream>
#include <algorithm>
#include <string>
#include <SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
namespace FRG {
#ifdef __EMSCRIPTEN__
//extern Game* Game::s_instance;
#endif
// Localization
static const std::string EMPTY_LANGUAGE_RU = u8"(пусто)";
static const std::string EMPTY_LANGUAGE_EN = "(empty)";
static const std::string BACK_TO_MAIN_MENU_RU = u8"Вернуться в меню";
static const std::string BACK_TO_MAIN_MENU_EN = "Back to Main Menu";
static const std::string MUSIC_DOWNLOAD_ERROR_RU = u8"Ошибка скачивания музыки";
static const std::string MUSIC_DOWNLOAD_NOT_STARTED_RU = u8"Скачать пакет с музыкой (27 MB).";
static const std::string MUSIC_DOWNLOAD_PROGRESS_PREFIX_RU = u8"Загрузка музыки: ";
static const std::string MUSIC_DOWNLOAD_ERROR_EN = u8"Music download error";
static const std::string MUSIC_DOWNLOAD_NOT_STARTED_EN = u8"Download music package (27 MB).";
static const std::string MUSIC_DOWNLOAD_PROGRESS_PREFIX_EN = u8"Music download: ";
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 trimChatPreview(const std::string& msg, int maxChars = 22) {
int charCount = 0;
int bytePos = 0;
const int len = static_cast<int>(msg.size());
while (bytePos < len && charCount < maxChars) {
const unsigned char c = static_cast<unsigned char>(msg[bytePos]);
if (c < 0x80) bytePos += 1;
else if (c < 0xE0) bytePos += 2;
else if (c < 0xF0) bytePos += 3;
else bytePos += 4;
++charCount;
}
if (bytePos >= len) return msg;
return msg.substr(0, bytePos) + "...";
}
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<int>(digits.size());
for (int i = 0; i < len; ++i) {
if (i > 0 && (len - i) % 3 == 0) result += ' ';
result += digits[i];
}
if (negative) result = "-" + result;
//Localization
if (g_currentLanguage == Language::English)
{
return result + " som";
}
return result + u8" сом";
}
static std::array<float, 4> 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 };
}
}
#ifdef __EMSCRIPTEN__
// Коллбэк успешной загрузки music.zip
void MenuManager::onMusicZipLoaded(unsigned /*handle*/, void* /*userData*/, const char* /*filename*/) {
logger() << "File music.zip downloaded successfully to VFS" << std::endl;
FRG::emscriptenSyncFs();
if (Game::s_instance != nullptr)
{
Game::s_instance->menuManager.musicDownloadProgress = 1.f;
Game::s_instance->menuManager.musicDownloadStarted = false;
Game::s_instance->menuManager.musicDownloadError = false;
logger() << "onMusicZipLoaded step 2: " << Game::s_instance->menuManager.lastMusicTrack << std::endl;
if (Game::s_instance->menuManager.lastMusicTrack != "")
{
logger() << "onMusicZipLoaded step 3" << std::endl;
Game::s_instance->menuManager.audioPlayer_.crossFadeMusicAsync(Game::s_instance->menuManager.lastMusicTrack);
}
logger() << "onMusicZipLoaded step 4" << std::endl;
if (Game::s_instance->menuManager.playerInSettingsNoMusic)
{
if (Game::s_instance->menuManager.playerInSettingsNoMusicGraphics)
{
Game::s_instance->menuManager.uiManager.popMenu();
Game::s_instance->menuManager.uiManager.popMenu();
Game::s_instance->menuManager.showSettingsScreen();
Game::s_instance->menuManager.showSettingsGraphicsScreen();
Game::s_instance->menuManager.uiManager.updateAllLayouts();
Game::s_instance->menuManager.topUiManager.updateAllLayouts();
}
else
{
Game::s_instance->menuManager.uiManager.popMenu();
Game::s_instance->menuManager.showSettingsScreen();
Game::s_instance->menuManager.uiManager.updateAllLayouts();
Game::s_instance->menuManager.topUiManager.updateAllLayouts();
}
}
Game::s_instance->menuManager.playerInSettingsNoMusic = false;
Game::s_instance->menuManager.playerInSettingsNoMusicGraphics = false;
//Game::s_instance->menuManager.uiManager.setText("musicDownloadButton", musicDownloadCompleted);
}
}
void MenuManager::onMusicZipError(unsigned /*handle*/, void* /*userData*/, int httpStatus) {
logger() << "Failed to download music.zip, HTTP status: " << httpStatus << std::endl;
if (Game::s_instance != nullptr)
{
std::string musicDownloadError = MUSIC_DOWNLOAD_ERROR_RU;
Game::s_instance->menuManager.musicDownloadStarted = false;
Game::s_instance->menuManager.musicDownloadError = true;
Game::s_instance->menuManager.uiManager.setText("musicDownloadButton",
musicDownloadError);
}
}
void MenuManager::onMusicZipProgress(unsigned /*handle*/, void* /*userData*/, int percentComplete) {
// Прогресс для музыки при необходимости
if (Game::s_instance != nullptr)
{
std::string musicDownloadProgressPrefix = MUSIC_DOWNLOAD_PROGRESS_PREFIX_RU;
Game::s_instance->menuManager.musicDownloadProgress = percentComplete / 100.0f;
Game::s_instance->menuManager.uiManager.setText("musicDownloadButton",
musicDownloadProgressPrefix + std::to_string(percentComplete) + "%");
}
}
#endif
MenuManager::MenuManager(Renderer& iRenderer, IGameServer& server, AudioPlayerAsync& audioPlayer) :
renderer(iRenderer),
server_(server),
audioPlayer_(audioPlayer)
{
//Localization
localizedLocationName["uni_interior"][Language::Russian] = u8"Университет, 3 этаж";
localizedLocationName["uni_interior"][Language::English] = "University, 3rd floor";
localizedLocationName["uni_exterior"][Language::Russian] = u8"Университет, двор";
localizedLocationName["uni_exterior"][Language::English] = "University, exterior";
localizedLocationName["location_dorm"][Language::Russian] = u8"Общежитие, 1 этаж";
localizedLocationName["location_dorm"][Language::English] = "Dormitory, 1st floor";
}
std::shared_ptr<UiNode> MenuManager::loadLocalizedUi(const std::string& basePath, const std::string& zipFile) {
// Service the OS message queue between UI screens so the window doesn't
// get flagged as "not responding" while loading the ~50 UI layout files.
SDL_PumpEvents();
static const std::string marker = "resources/w/ui/";
if (basePath.rfind(marker, 0) == 0) {
const std::string localizedPath = "resources/w/ui_" + languageToCode(g_currentLanguage) + "/" + basePath.substr(marker.size());
try {
return loadUiFromFile(localizedPath, renderer, zipFile);
} catch (const std::exception&) {
// Localized screen not available — fall back to the base path below.
}
}
return loadUiFromFile(basePath, renderer, zipFile);
}
std::shared_ptr<Texture> MenuManager::loadLocalizedTexture(const std::string& basePath, const std::string& zipFile) {
static const std::string marker = "resources/w/ui/";
if (basePath.rfind(marker, 0) == 0) {
const std::string localizedPath = "resources/w/ui_" + languageToCode(g_currentLanguage) + "/" + basePath.substr(marker.size());
try {
return renderer.textureManager.LoadFromPng(localizedPath, zipFile, true);
}
catch (const std::exception&) {
// Localized screen not available — fall back to the base path below.
}
}
return renderer.textureManager.LoadFromPng(basePath, zipFile, true);
}
void MenuManager::loadUiRoots(const std::string& zipFile) {
#ifdef __ANDROID__
hudDormRoot = loadLocalizedUi("resources/w/ui/hud_dorm_android.json", zipFile);
hudUniIntRoot = loadLocalizedUi("resources/w/ui/hud_uni_int_android.json", zipFile);
hudUniIntDarkRoot = loadLocalizedUi("resources/w/ui/hud_uni_int_dark_android.json", zipFile);
#else
hudDormRoot = loadLocalizedUi("resources/w/ui/hud_dorm.json", zipFile);
hudUniIntRoot = loadLocalizedUi("resources/w/ui/hud_uni_int.json", zipFile);
hudUniIntDarkRoot = loadLocalizedUi("resources/w/ui/hud_uni_int_dark.json", zipFile);
#endif
hudUniExtRoot = loadLocalizedUi("resources/w/ui/hud_uni_ext.json", zipFile);
hudUniExtDarkRoot = loadLocalizedUi("resources/w/ui/hud_uni_ext_dark.json", zipFile);
//hudCutsceneRoot_ = loadLocalizedUi("resources/w/ui/hud_cutscene.json", zipFile);
hudCutsceneRoot_ = loadUiFromFile("resources/w/ui/hud_cutscene.json", renderer, zipFile);
hudTopHintRoot_ = loadLocalizedUi("resources/w/ui/hud_top_hint01.json", zipFile);
phoneMainRoot = loadLocalizedUi("resources/w/ui/screen_phone.json", zipFile);
phoneBankRoot = loadLocalizedUi("resources/w/ui/screen_phone_bank.json", zipFile);
phoneVideoRoot = loadLocalizedUi("resources/w/ui/screen_phone_video.json", zipFile);
phoneMapDormRoot = loadLocalizedUi("resources/w/ui/screen_phone_map_dorm.json", zipFile);
phoneMapUniRoot = loadLocalizedUi("resources/w/ui/screen_phone_map_uni.json", zipFile);
verticalPhoneMainRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone.json", zipFile);
verticalPhoneBankRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone_bank.json", zipFile);
verticalPhoneVideoRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone_video.json", zipFile);
verticalPhoneMapDormRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone_map_dorm.json", zipFile);
verticalPhoneMapUniRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone_map_uni.json", zipFile);
phoneChatListRoot = loadLocalizedUi("resources/w/ui/screen_phone_chat_list.json", zipFile);
verticalPhoneChatListRoot = loadLocalizedUi("resources/w/ui/portrait_screen_phone_chat_list.json", zipFile);
phoneChat1Root = loadLocalizedUi("resources/w/ui/screen_phone_chat1.json", zipFile);
phoneChat2Root = loadLocalizedUi("resources/w/ui/screen_phone_chat2.json", zipFile);
phoneChat3Root = loadLocalizedUi("resources/w/ui/screen_phone_chat3.json", zipFile);
verticalPhoneChat1Root = loadLocalizedUi("resources/w/ui/portrait_screen_phone_chat1.json", zipFile);
verticalPhoneChat2Root = loadLocalizedUi("resources/w/ui/portrait_screen_phone_chat2.json", zipFile);
verticalPhoneChat3Root = loadLocalizedUi("resources/w/ui/portrait_screen_phone_chat3.json", zipFile);
modalMenuRoot = loadLocalizedUi("resources/w/ui/screen_modal_menu.json", zipFile);
newInventoryRoot = loadLocalizedUi("resources/w/ui/screen_inventory.json", zipFile);
portraitInventoryListRoot = loadLocalizedUi("resources/w/ui/portrait_screen_inventory.json", zipFile);
portraitInventoryItemRoot = loadLocalizedUi("resources/w/ui/portrait_screen_inventory_item.json", zipFile);
questJournalRoot = loadLocalizedUi("resources/w/ui/screen_journal.json", zipFile);
portraitQuestJournalListRoot = loadLocalizedUi("resources/w/ui/portrait_screen_journal.json", zipFile);
portraitQuestJournalItemRoot = loadLocalizedUi("resources/w/ui/portrait_screen_journal_item.json", zipFile);
#if defined(__ANDROID__) || defined(__EMSCRIPTEN__)
mainMenuRoot = loadLocalizedUi("resources/w/ui/screen_main_menu_android.json", zipFile);
#else
mainMenuRoot = loadLocalizedUi("resources/w/ui/screen_main_menu.json", zipFile);
#endif
#if defined(__ANDROID__)
aboutScreenRoot = loadLocalizedUi("resources/w/ui/screen_about_android.json", zipFile);
#elif defined(__EMSCRIPTEN__)
aboutScreenRoot = loadLocalizedUi("resources/w/ui/screen_about_web.json", zipFile);
#elif defined(STEAMSDK)
aboutScreenRoot = loadLocalizedUi("resources/w/ui/screen_about.json", zipFile);
#else
aboutScreenRoot = loadLocalizedUi("resources/w/ui/screen_about_web.json", zipFile); //Windows and Linux non-steam
#endif
creditsScreenRoot = loadLocalizedUi("resources/w/ui/screen_credits.json", zipFile);
portraitCreditsScreenRoot = loadLocalizedUi("resources/w/ui/portrait_screen_credits.json", zipFile);
#ifdef __EMSCRIPTEN__
settingsScreenNoMusicWebRoot = loadLocalizedUi("resources/w/ui/screen_settings_nomusic_web.json", zipFile);
#endif
settingsScreenRoot = loadLocalizedUi("resources/w/ui/screen_settings.json", zipFile);
settingsGraphicsScreenRoot = loadLocalizedUi("resources/w/ui/screen_settings_graphics.json", zipFile);
languageScreenRoot = loadLocalizedUi("resources/w/ui/screen_language.json", zipFile);
loadSavedGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_load.json", zipFile);
campaignScreenRoot = loadLocalizedUi("resources/w/ui/screen_campaign.json", zipFile);
loadingScreenRoots_.clear();
auto preloadLoadingScreen = [this, &zipFile](const std::string& path) {
if (!path.empty() && !loadingScreenRoots_.count(path))
loadingScreenRoots_[path] = loadLocalizedUi(path, zipFile);
};
for (const CampaignConfig& campaign : CampaignRegistry::instance().all()) {
preloadLoadingScreen(campaign.loadingScreenPath);
for (const LocationConfig& location : campaign.locations)
preloadLoadingScreen(location.loadingScreenPath);
}
saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile);
}
void MenuManager::setup(const std::string& zipFile) {
loadUiRoots(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);
// Quest definitions are loaded per campaign by Game (see Game::activateCampaign).
toastTextureQuestNew = loadLocalizedTexture("resources/w/ui/img/toast/quest_new001.png", zipFile);
toastTextureQuestCompleted = loadLocalizedTexture("resources/w/ui/img/toast/quest_completed001.png", zipFile);
toastTextureQuestFailed = loadLocalizedTexture("resources/w/ui/img/toast/quest_failed001.png", zipFile);
toastTextureItemAdded = loadLocalizedTexture("resources/w/ui/img/toast/item_received001.png", zipFile);
toastTextureItemRemoved = loadLocalizedTexture("resources/w/ui/img/toast/item_removed001.png", zipFile);
languageLoadingRu = renderer.textureManager.LoadFromPng("resources/loading_land003_ru.png", zipFile);
languageLoadingEn = renderer.textureManager.LoadFromPng("resources/loading_land003_en.png", zipFile);
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::startNewGame(const std::string& campaignId) {
// The reset runs on a later main-thread task, which then shows the HUD itself;
// the main menu stays up until then. Showing the HUD now would draw one frame
// from the previous game's state (e.g. its phone/journal buttons).
if (startGameFunc) startGameFunc(campaignId);
}
void MenuManager::showLoadingScreen(const std::string& layoutPath) {
auto it = loadingScreenRoots_.find(layoutPath);
if (it == loadingScreenRoots_.end() || !it->second) {
std::cerr << "[menu] Loading screen not preloaded: " << layoutPath << std::endl;
return; // Game falls back to the startup loading image
}
loadingUiManager.replaceRoot(it->second);
loadingScreenShown_ = true;
}
void MenuManager::hideLoadingScreen() {
loadingUiManager.replaceRoot(nullptr);
loadingScreenShown_ = false;
}
void MenuManager::enterGameplay(bool isFromLoad) {
if (isFromLoad) {
uiManager.clearMenuStack();
topUiManager.clearMenuStack();
}
showGameplayHud();
}
std::shared_ptr<UiNode> MenuManager::gameplayHudRoot() const {
const auto snapshot = server_.snapshot();
const std::string& loc = snapshot->currentLocationId;
if (loc == "uni_exterior") return snapshot->isDarklands ? hudUniExtDarkRoot : hudUniExtRoot;
if (loc == "uni_interior") return snapshot->isDarklands ? hudUniIntDarkRoot : hudUniIntRoot;
return hudDormRoot; // dorm, or a new game before the location is set
}
void MenuManager::showGameplayHud() {
uiState_ = GameUiState::Gameplay;
uiManager.replaceRoot(gameplayHudRoot());
applyCurrentHealthBar();
setupGameplayHudCallbacks();
refreshHudVisibility();
}
void MenuManager::refreshHudVisibility() {
const auto itemIds = server_.snapshot()->inventoryItemIds;
const auto snapshot = server_.snapshot();
const TutorialStep step = snapshot->tutorialStep;
const bool tutorial = snapshot->isTutorialActive();
const bool hasPhone = std::find(itemIds.begin(), itemIds.end(), "phone") != itemIds.end();
const bool hasJournal = std::find(itemIds.begin(), itemIds.end(), "journal") != itemIds.end();
const bool hasNote = std::find(itemIds.begin(), itemIds.end(), "note_spell") != itemIds.end();
const bool dark = snapshot->isDarklands;
auto show = [this](std::initializer_list<const char*> names, bool visible) {
for (const char* name : names) uiManager.setNodeVisible(name, visible);
};
// --- HUD buttons: present while the matching item is in the inventory ---
show({ "inventoryButton" }, !itemIds.empty());
show({ "phoneButton" }, hasPhone && !dark);
show({ "journalButton" }, hasJournal);
show({ "darklandsButton" }, hasNote && !dark);
// --- Tutorial hints ---
// The dialogue hint lives in topUiManager so it is drawn above the dialogue.
if (!cutsceneHudActive_) {
auto topRoot = (step == TutorialStep::AdvanceDialogue) ? hudTopHintRoot_ : nullptr;
if (topUiManager.getRoot() != topRoot) topUiManager.replaceRoot(topRoot);
}
// Dorm HUD: one hint per linear step...
show({ "hint2" }, step == TutorialStep::RotateCamera);
show({ "hint3" }, step == TutorialStep::Walk);
show({ "hint4" }, step == TutorialStep::Zoom);
show({ "hint5" }, step == TutorialStep::PickUpItem);
// ...then the hints for the picked-up items, which can be done in any order.
const bool chatHint = tutorial && !snapshot->tutorialPhoneChatScreenOpened;
const bool journalHint = tutorial && !snapshot->tutorialJournalScreenOpened;
const bool taxiHint = tutorial && snapshot->tutorialNeedOpenTaxiScreen;
show({ "hint6a", "hint6aarrow" }, hasPhone && chatHint && !taxiHint);
show({ "hint6b", "hint6barrow" }, hasJournal && journalHint);
show({ "hint7", "hint7arrow" }, taxiHint);
// Phone screens.
show({ "hint_m001", "hint_m001arrow" }, chatHint); // main screen: open the messenger
show({ "hint_m003", "hint_m003arrow" }, taxiHint); // main screen: open the taxi app
show({ "hint_m002" }, chatHint); // chat list: open a chat
// University interior HUDs.
show({ "hint_darklands001", "hint_darklands001_arrow" }, step == TutorialStep::EnterDarklands && hasNote);
show({ "hint_darklands003", "hint_darklands003_arrow" }, step == TutorialStep::DarklandsHealth);
show({ "hint_darklands002" }, step == TutorialStep::DarklandsAttack);
}
void MenuManager::showMainMenu() {
server_.submit(SetCurrentLocationCommand{""});
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
uiState_ = GameUiState::MainMenu;
uiManager.clearMenuStack();
topUiManager.replaceRoot(nullptr);
topUiManager.clearMenuStack();
uiManager.replaceRoot(mainMenuRoot);
uiManager.setTextButtonCallback("menuStartButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
showCampaignScreen();
});
uiManager.setTextButtonCallback("menuAboutButton", [this](const std::string&) {
showAboutScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuCreditsButton", [this](const std::string&) {
showCreditsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuSettingsButton", [this](const std::string&) {
#ifdef __EMSCRIPTEN__
std::string musicPath = FRG::getPersistentResourcePath("music.zip");
if (FRG::isZipValid(musicPath)) {
showSettingsScreen();
}
else {
showSettingsNoMusicWebScreen();
}
#else
showSettingsScreen();
#endif
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuLanguageButton", [this](const std::string&) {
showLanguageScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuLoadButton", [this](const std::string&) {
showLoadGameScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
#if !defined(EMSCRIPTEN) && !defined(__ANDROID__)
uiManager.setTextButtonCallback("menuExitButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
Environment::exitGameLoop = true;
});
#else
uiManager.setNodeVisible("menuExitButton", false);
#endif
}
void MenuManager::showMenuAfterGameCompleted() {
audioPlayer_.crossFadeMusicAsync("music/main menu final.ogg");
lastMusicTrack = "music/main menu final.ogg";
uiState_ = GameUiState::MainMenu;
uiManager.clearMenuStack();
topUiManager.replaceRoot(nullptr);
topUiManager.clearMenuStack();
uiManager.replaceRoot(mainMenuRoot);
uiManager.setTextButtonCallback("menuStartButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
showCampaignScreen();
});
uiManager.setTextButtonCallback("menuAboutButton", [this](const std::string&) {
showAboutScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuCreditsButton", [this](const std::string&) {
showCreditsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuSettingsButton", [this](const std::string&) {
showSettingsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuLanguageButton", [this](const std::string&) {
showLanguageScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("menuLoadButton", [this](const std::string&) {
showLoadGameScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
#ifndef EMSCRIPTEN
uiManager.setTextButtonCallback("menuExitButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
Environment::exitGameLoop = true;
});
#else
uiManager.setNodeVisible("menuExitButton", false);
#endif
//Now open the about screen:
showAboutScreen();
}
void MenuManager::showAboutScreen() {
uiManager.pushMenuFromSavedRoot(aboutScreenRoot);
uiManager.setTextButtonCallback("aboutBackButton", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("steamButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
SDL_OpenURL("https://store.steampowered.com/app/4801010/");
});
uiManager.setTextButtonCallback("websiteButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
SDL_OpenURL("https://shadowoverbishkek.com/");
});
#ifdef __ANDROID__
uiManager.setTextButtonCallback("reviewButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
SDL_OpenURL("https://play.google.com/store/apps/details?id=fishrungames.shadowoverbishkek");
});
#endif
uiManager.setTextButtonCallback("telegramButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
SDL_OpenURL("https://telegram.me/fishrungames");
});
uiManager.setTextButtonCallback("discordButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
SDL_OpenURL("https://discord.gg/S8jgGHXjw");
});
}
void MenuManager::showCreditsScreen() {
isPlayerInCreditsMenu = true;
if (isPortraitMode())
{
uiManager.pushMenuFromSavedRoot(portraitCreditsScreenRoot);
}
else
{
uiManager.pushMenuFromSavedRoot(creditsScreenRoot);
}
static std::string gameTitle;
#ifdef __ANDROID__
if (g_currentLanguage == Language::English)
{
gameTitle = "Shadow over Bishkek";
}
else if (g_currentLanguage == Language::Russian)
{
gameTitle = u8"Тень над Бишкеком";
}
uiManager.setText("creditsTitleText", gameTitle);
#endif
uiManager.setButtonCallback("creditsBackButton", [this](const std::string&) {
isPlayerInCreditsMenu = false;
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.updateAllLayouts();
uiManager.stopAnimationOnNode("creditsLinearLayout", "slowScroll");
uiManager.resetAnimationOnNode("creditsLinearLayout");
uiManager.startAnimationOnNode("creditsLinearLayout", "slowScroll");
}
void MenuManager::showSettingsScreen() {
uiManager.pushMenuFromSavedRoot(settingsScreenRoot);
uiManager.setTextButtonCallback("settingsBackButton", [this](const std::string&) {
saveSettings();
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("graphicsButton", [this](const std::string&) {
showSettingsGraphicsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
// Set initial slider positions before registering callbacks so
// setSliderValue does not fire an unregistered callback.
const float musicFrac = audioPlayer_.getMusicVolume() / 128.0f;
const float soundFrac = audioPlayer_.getSoundVolume() / 128.0f;
uiManager.setSliderValue("musicVolumeSlider", musicFrac);
uiManager.setSliderValue("soundVolumeSlider", soundFrac);
// Localization
if (g_currentLanguage == Language::English)
{
musicVolumePrefix = "Music volume: ";
soundVolumePrefix = "Sound volume: ";
musicToggleOn = "Music: ON";
musicToggleOff = "Music: OFF";
soundToggleOn = "Sound: ON";
soundToggleOff = "Sound: OFF";
}
else if (g_currentLanguage == Language::Russian)
{
musicVolumePrefix = u8"Громкость музыки: ";
soundVolumePrefix = u8"Громкость звука: ";
musicToggleOn = u8"Музыка: ВКЛ";
musicToggleOff = u8"Музыка: ВЫКЛ";
soundToggleOn = u8"Звук: ВКЛ";
soundToggleOff = u8"Звук: ВЫКЛ";
}
uiManager.setText("musicVolumeText",
musicVolumePrefix + std::to_string(audioPlayer_.getMusicVolume()));
uiManager.setText("soundVolumeText",
soundVolumePrefix + std::to_string(audioPlayer_.getSoundVolume()));
uiManager.setSliderCallback("musicVolumeSlider",
[this](const std::string&, float value) {
const int vol = static_cast<int>(value * 128.0f + 0.5f);
audioPlayer_.setMusicVolume(vol);
uiManager.setText("musicVolumeText",
musicVolumePrefix + std::to_string(vol));
});
uiManager.setSliderCallback("soundVolumeSlider",
[this](const std::string&, float value) {
const int vol = static_cast<int>(value * 128.0f + 0.5f);
audioPlayer_.setSoundVolume(vol);
uiManager.setText("soundVolumeText",
soundVolumePrefix + std::to_string(vol));
});
// Music on/off toggle — helper to sync button text and color from current state
auto refreshMusicToggle = [this]() {
const bool on = audioPlayer_.isMusicEnabled();
uiManager.setTextButtonText("musicToggleButton", on ? musicToggleOn : musicToggleOff);
/*uiManager.setTextButtonColor("musicToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
};
auto refreshSoundToggle = [this]() {
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? soundToggleOn : soundToggleOff);
/*uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
};
refreshMusicToggle();
refreshSoundToggle();
uiManager.setTextButtonCallback("musicToggleButton", [this](const std::string&) {
audioPlayer_.setMusicEnabled(!audioPlayer_.isMusicEnabled());
const bool on = audioPlayer_.isMusicEnabled();
uiManager.setTextButtonText("musicToggleButton", on ? musicToggleOn : musicToggleOff);
/*uiManager.setTextButtonColor("musicToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("soundToggleButton", [this](const std::string&) {
audioPlayer_.setSoundEnabled(!audioPlayer_.isSoundEnabled());
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? soundToggleOn : soundToggleOff);
/*uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
setupTutorialToggle();
}
// Tutorial on/off button shared by the settings screens — takes effect on the next new game only.
void MenuManager::setupTutorialToggle() {
if (g_currentLanguage == Language::English)
{
tutorialToggleOn = "Tutorial: ON";
tutorialToggleOff = "Tutorial: OFF";
}
else if (g_currentLanguage == Language::Russian)
{
tutorialToggleOn = u8"Обучение: ВКЛ";
tutorialToggleOff = u8"Обучение: ВЫКЛ";
}
uiManager.setTextButtonText("tutorialToggleButton", tutorialEnabledSetting ? tutorialToggleOn : tutorialToggleOff);
uiManager.setTextButtonCallback("tutorialToggleButton", [this](const std::string&) {
tutorialEnabledSetting = !tutorialEnabledSetting;
uiManager.setTextButtonText("tutorialToggleButton", tutorialEnabledSetting ? tutorialToggleOn : tutorialToggleOff);
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
}
void MenuManager::showSettingsGraphicsScreen() {
uiManager.pushMenuFromSavedRoot(settingsGraphicsScreenRoot);
uiManager.setTextButtonCallback("settingsBackButton", [this](const std::string&) {
saveSettings();
uiManager.popMenu();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.updateAllLayouts();
topUiManager.updateAllLayouts();
#ifdef __EMSCRIPTEN__
playerInSettingsNoMusicGraphics = false;
#endif
});
// Localization
if (g_currentLanguage == Language::English)
{
fullScreenToggleOn = "Full Screen: ON";
fullScreenToggleOff = "Full Screen: OFF";
shadowToggleOn = "Shadows: ON";
shadowToggleOff = "Shadows: OFF";
}
else if (g_currentLanguage == Language::Russian)
{
fullScreenToggleOn = u8"Полноэкранный режим: ВКЛ";
fullScreenToggleOff = u8"Полноэкранный режим: ВЫКЛ";
shadowToggleOn = u8"Тени: ВКЛ";
shadowToggleOff = u8"Тени: ВЫКЛ";
}
auto refreshFullscreenToggle = [this]() {
const bool on = Environment::isFullscreen;
uiManager.setTextButtonText("fullScreenToggleButton", on ? fullScreenToggleOn : fullScreenToggleOff);
};
auto refreshShadowToggle = [this]() {
const bool on = this->shadowsEnabled;
uiManager.setTextButtonText("shadowToggleButton", on ? shadowToggleOn : shadowToggleOff);
};
refreshFullscreenToggle();
refreshShadowToggle();
#ifdef __ANDROID__
uiManager.setNodeVisible("fullScreenToggleButton", false);
#else
uiManager.setTextButtonCallback("fullScreenToggleButton", [this, refreshFullscreenToggle](const std::string&) {
Environment::setFullscreen(!Environment::isFullscreen);
refreshFullscreenToggle();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.updateAllLayouts();
topUiManager.updateAllLayouts();
});
#endif
uiManager.setTextButtonCallback("shadowToggleButton", [this, refreshShadowToggle](const std::string&) {
bool newValue = !this->shadowsEnabled;
shadowMapSettingsChangedFunc(newValue);
refreshShadowToggle();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
}
void MenuManager::refreshFullScreenToggleButton()
{
// Localization
if (g_currentLanguage == Language::English)
{
fullScreenToggleOn = "Full Screen: ON";
fullScreenToggleOff = "Full Screen: OFF";
}
else if (g_currentLanguage == Language::Russian)
{
fullScreenToggleOn = u8"Полноэкранный режим: ВКЛ";
fullScreenToggleOff = u8"Полноэкранный режим: ВЫКЛ";
}
const bool on = Environment::isFullscreen;
uiManager.setTextButtonText("fullScreenToggleButton", on ? fullScreenToggleOn : fullScreenToggleOff);
}
#ifdef __EMSCRIPTEN__
void MenuManager::showSettingsNoMusicWebScreen()
{
playerInSettingsNoMusic = true;
uiManager.pushMenuFromSavedRoot(settingsScreenNoMusicWebRoot);
uiManager.setTextButtonCallback("settingsBackButton", [this](const std::string&) {
saveSettings();
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
playerInSettingsNoMusic = false;
});
uiManager.setTextButtonCallback("graphicsButton", [this](const std::string&) {
playerInSettingsNoMusicGraphics = true;
showSettingsGraphicsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
// Set initial slider positions before registering callbacks so
// setSliderValue does not fire an unregistered callback.
const float soundFrac = audioPlayer_.getSoundVolume() / 128.0f;
uiManager.setSliderValue("soundVolumeSlider", soundFrac);
static std::string musicDownloadErrorString;// = MUSIC_DOWNLOAD_ERROR_RU;
static std::string musicDownloadNotStartedString;// = MUSIC_DOWNLOAD_NOT_STARTED_RU;
static std::string musicDownloadProgressPrefix;// = MUSIC_DOWNLOAD_PROGRESS_PREFIX_RU;
// Localization
if (g_currentLanguage == Language::English)
{
soundVolumePrefix = "Sound volume: ";
soundToggleOn = "Sound: ON";
soundToggleOff = "Sound: OFF";
musicDownloadErrorString = MUSIC_DOWNLOAD_ERROR_EN;
musicDownloadNotStartedString = MUSIC_DOWNLOAD_NOT_STARTED_EN;
musicDownloadProgressPrefix = MUSIC_DOWNLOAD_PROGRESS_PREFIX_EN;
}
else if (g_currentLanguage == Language::Russian)
{
soundVolumePrefix = u8"Громкость звука: ";
soundToggleOn = u8"Звук: ВКЛ";
soundToggleOff = u8"Звук: ВЫКЛ";
musicDownloadErrorString = MUSIC_DOWNLOAD_ERROR_RU;
musicDownloadNotStartedString = MUSIC_DOWNLOAD_NOT_STARTED_RU;
musicDownloadProgressPrefix = MUSIC_DOWNLOAD_PROGRESS_PREFIX_RU;
}
// musicDownloadStarted = false;
//musicDownloadError = false;
if (musicDownloadError)
{
uiManager.setText("musicDownloadButton", musicDownloadErrorString);
}
else
{
if (musicDownloadStarted == false)
{
uiManager.setText("musicDownloadButton", musicDownloadNotStartedString);
}
else
{
//uiManager.setText("musicDownloadButton", musicDownloadProgressString);
uiManager.setText("musicDownloadButton",
musicDownloadProgressPrefix + std::to_string(round(musicDownloadProgress*100)) + "%");
}
}
uiManager.setText("soundVolumeText",
soundVolumePrefix + std::to_string(audioPlayer_.getSoundVolume()));
uiManager.setSliderCallback("soundVolumeSlider",
[this](const std::string&, float value) {
const int vol = static_cast<int>(value * 128.0f + 0.5f);
audioPlayer_.setSoundVolume(vol);
uiManager.setText("soundVolumeText",
soundVolumePrefix + std::to_string(vol));
});
// Music on/off toggle — helper to sync button text and color from current state
auto refreshSoundToggle = [this]() {
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? soundToggleOn : soundToggleOff);
/*uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
};
refreshSoundToggle();
uiManager.setTextButtonCallback("soundToggleButton", [this](const std::string&) {
audioPlayer_.setSoundEnabled(!audioPlayer_.isSoundEnabled());
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? soundToggleOn : soundToggleOff);
/*uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });*/
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
setupTutorialToggle();
uiManager.setTextButtonCallback("musicDownloadButton", [this](const std::string&) {
if (musicDownloadStarted) return; // Already downloading, ignore further clicks
musicDownloadStarted = true;
musicDownloadError = false;
std::string musicPath = FRG::getPersistentResourcePath("music.zip");
emscripten_async_wget2(
"music.zip",
musicPath.c_str(),
"GET",
nullptr,
nullptr,
onMusicZipLoaded,
onMusicZipError,
onMusicZipProgress
);
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
}
#endif
void MenuManager::showLanguageScreen() {
uiManager.pushMenuFromSavedRoot(languageScreenRoot);
uiManager.setTextButtonCallback("languageBackButton", [this](const std::string&) {
saveSettings();
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setTextButtonCallback("languageRussianButton", [this](const std::string&) {
#ifndef __EMSCRIPTEN__
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
#endif
FRG::g_currentLanguage = FRG::Language::Russian;
reloadLocalizedGameContent();
saveSettings();
});
uiManager.setTextButtonCallback("languageEnglishButton", [this](const std::string&) {
#ifndef __EMSCRIPTEN__
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
#endif
FRG::g_currentLanguage = FRG::Language::English;
reloadLocalizedGameContent();
saveSettings();
});
}
void MenuManager::reloadLocalizedGameContent() {
// Screens below the Language screen on the menu stack hold onto their old
// (pre-reload) UiNode instances, so simply reopening Language on top of
// them would leave Main Menu / Settings / the pause menu showing stale
// text once the player steps back. Instead, unwind the whole chain and
// replay it with freshly (re)loaded, localized roots.
const bool wasInMainMenu = (uiState_ == GameUiState::MainMenu);
if (!wasInMainMenu) {
// Reached via the in-game pause menu (Modal -> Settings -> Language).
// Fully unwind back to the untouched gameplay HUD underneath — it
// isn't rebuilt here, only the pause/settings/language chain above it.
const int depth = uiManager.menuStackSize();
for (int i = 0; i < depth; ++i) uiManager.popMenu();
}
// Unloading/reloading ~50 UI screens and their textures is heavy enough to
// freeze the window for a noticeable moment, so run it the same way the
// initial resource load runs: a queue of closures drained one per frame
// with the loading screen shown, instead of one big blocking call here.
std::vector<std::function<void()>> steps;
steps.push_back([this]() {
ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE);
if (const CampaignConfig* campaign = CampaignRegistry::instance().find(server_.snapshot()->campaignId))
server_.submit(ReloadQuestDefinitionsCommand{campaign->questsPath, CONST_ZIP_FILE});
});
steps.push_back([this]() {
renderer.textureManager.UnloadByPrefix("resources/w/ui");
loadUiRoots(CONST_ZIP_FILE);
toastTextureQuestNew = loadLocalizedTexture("resources/w/ui/img/toast/quest_new001.png", CONST_ZIP_FILE);
toastTextureQuestCompleted = loadLocalizedTexture("resources/w/ui/img/toast/quest_completed001.png", CONST_ZIP_FILE);
toastTextureQuestFailed = loadLocalizedTexture("resources/w/ui/img/toast/quest_failed001.png", CONST_ZIP_FILE);
toastTextureItemAdded = loadLocalizedTexture("resources/w/ui/img/toast/item_received001.png", CONST_ZIP_FILE);
toastTextureItemRemoved = loadLocalizedTexture("resources/w/ui/img/toast/item_removed001.png", CONST_ZIP_FILE);
});
steps.push_back([this]() {
showMainMenu();
});
if (runAsLoadingSequence) {
runAsLoadingSequence(std::move(steps));
} else {
for (auto& step : steps) step();
}
}
void MenuManager::showLoadGameScreen()
{
uiManager.pushMenuFromSavedRoot(loadSavedGameScreenRoot);
uiManager.setTextButtonCallback("loadBackButton", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
static const char* kSlotButtons[4] = {
"loadSlot1Button", "loadSlot2Button", "loadSlot3Button", "loadSlot4Button"
};
for (int i = 0; i < 4; ++i) {
int slot = i + 1;
if (getSlotInfoFunc) {
uiManager.setTextButtonText(kSlotButtons[i], slotLabel(getSlotInfoFunc(slot)));
}
uiManager.setTextButtonCallback(kSlotButtons[i], [this, slot](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
// Game enters gameplay itself once the save (and its campaign) is loaded.
if (onLoadGame) onLoadGame(slot);
});
}
}
std::string MenuManager::slotLabel(const SaveSlotInfo& info) {
if (info.empty)
return FRG::g_currentLanguage == FRG::Language::English ? EMPTY_LANGUAGE_EN : EMPTY_LANGUAGE_RU;
std::string label = localizedLocationName[info.locationName][FRG::g_currentLanguage] + " " + info.savedAt;
// Name the campaign unless it's the main game, so saves of different campaigns are told apart.
const CampaignConfig* campaign = CampaignRegistry::instance().find(info.campaignId);
if (campaign && campaign->id != CampaignRegistry::kDefaultCampaignId)
label = campaign->title(FRG::g_currentLanguage) + ": " + label;
return label;
}
void MenuManager::showCampaignScreen()
{
uiManager.pushMenuFromSavedRoot(campaignScreenRoot);
uiManager.setTextButtonCallback("campaignBackButton", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
// One button per campaign in campaigns.json order; spare buttons are hidden.
static const char* kCampaignButtons[4] = {
"campaignSlot1Button", "campaignSlot2Button", "campaignSlot3Button", "campaignSlot4Button"
};
const auto& campaigns = CampaignRegistry::instance().all();
for (int i = 0; i < 4; ++i) {
const bool used = i < static_cast<int>(campaigns.size());
uiManager.setNodeVisible(kCampaignButtons[i], used);
if (!used) continue;
const std::string campaignId = campaigns[i].id;
uiManager.setTextButtonText(kCampaignButtons[i], campaigns[i].title(FRG::g_currentLanguage));
uiManager.setTextButtonCallback(kCampaignButtons[i], [this, campaignId](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.popMenu();
startNewGame(campaignId);
});
}
}
void MenuManager::showSaveGameScreen()
{
uiManager.pushMenuFromSavedRoot(saveGameScreenRoot);
uiManager.setTextButtonCallback("saveBackButton", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
static const char* kSlotButtons[4] = {
"saveSlot1Button", "saveSlot2Button", "saveSlot3Button", "saveSlot4Button"
};
for (int i = 0; i < 4; ++i) {
int slot = i + 1;
const char* buttonName = kSlotButtons[i];
if (getSlotInfoFunc) {
uiManager.setTextButtonText(buttonName, slotLabel(getSlotInfoFunc(slot)));
}
uiManager.setTextButtonCallback(buttonName, [this, slot, buttonName](const std::string&) {
if (onSaveGame) onSaveGame(slot);
if (getSlotInfoFunc) {
uiManager.setTextButtonText(buttonName, slotLabel(getSlotInfoFunc(slot)));
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
}
});
}
}
void MenuManager::openInventory() {
uiState_ = GameUiState::Inventory;
inventoryMenuStackDepth_ = 1;
const bool portrait = isPortraitMode();
uiManager.pushMenuFromSavedRoot(portrait ? portraitInventoryListRoot : newInventoryRoot);
uiManager.setButtonCallback("inventoryExitButton2", [this](const std::string&) {
closeInventory();
});
uiManager.setButtonCallback("inventoryExitButton", [this](const std::string&) {
closeInventory();
});
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
const auto items = inventoryItems();
/*logger() << "Item count: " << items.size() << std::endl;
for (int x = 0; x < items.size(); x++)
{
std::cout << "item " << x << ": " << items[x].id << std::endl;
}*/
const int maxSlots = 9;
for (int i = 0; i < maxSlots; ++i) {
std::string btnName = "item" + std::to_string(i + 1) + "Button";
if (i < static_cast<int>(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;
}
//logger() << "Set button ballback to button: " << btnName << std::endl;
uiManager.setButtonCallback(btnName, [this, i](const std::string&) {
//logger() << "Callback called for button number " << i << std::endl;
selectInventoryItem(i);
//logger() << "Callback called for button number " << i << " END" << std::endl;
audioPlayer_.playSoundAsync("resources/audio/sound_bag001.ogg");
});
} else {
uiManager.setNodeVisible(btnName, false);
}
}
inventorySelectedIndex_ = -1;
if (!portrait && !items.empty()) {
selectInventoryItem(0);
}
audioPlayer_.playSoundAsync("resources/audio/sound_bag_open.ogg");
}
void MenuManager::selectInventoryItem(int index) {
logger() << "MenuManager::selectInventoryItem: " << index << std::endl;
const auto items = inventoryItems();
if (index < 0 || index >= static_cast<int>(items.size())) return;
if (isPortraitMode() && inventoryMenuStackDepth_ == 1) {
openPortraitInventoryItem(index);
return;
}
// Revert previously selected button to its regular icon
if (inventorySelectedIndex_ >= 0 && inventorySelectedIndex_ < static_cast<int>(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() {
uiState_ = GameUiState::Gameplay;
for (int i = 0; i < inventoryMenuStackDepth_; ++i) uiManager.popMenu();
inventoryMenuStackDepth_ = 0;
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/sound_bag_close.ogg");
}
void MenuManager::openQuestJournal() {
uiState_ = GameUiState::QuestJournal;
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
tutorial->tutorialPhoneChatScreenOpened, true,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
journalMenuStackDepth_ = 1;
const bool portrait = isPortraitMode();
uiManager.pushMenuFromSavedRoot(portrait ? portraitQuestJournalListRoot : questJournalRoot);
uiManager.setButtonCallback("journalExitButton", [this](const std::string&) {
closeQuestJournal();
audioPlayer_.playSoundAsync("resources/audio/656126__itsthegoodstuff__paging-through-book.ogg");
});
uiManager.setButtonCallback("journalExitButton2", [this](const std::string&) {
closeQuestJournal();
audioPlayer_.playSoundAsync("resources/audio/656126__itsthegoodstuff__paging-through-book.ogg");
});
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
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);
audioPlayer_.playSoundAsync("resources/audio/255571__stradie__flipping-paper.ogg");
});
}
refreshQuestJournalUi();
if (!portrait && !visibleQuestIds.empty()) {
selectQuestByIndex(0);
}
audioPlayer_.playSoundAsync("resources/audio/255571__stradie__flipping-paper.ogg");
}
void MenuManager::closeQuestJournal() {
uiState_ = GameUiState::Gameplay;
selectedQuestIndex = -1;
visibleQuestIds.clear();
for (int i = 0; i < journalMenuStackDepth_; ++i) uiManager.popMenu();
journalMenuStackDepth_ = 0;
uiManager.updateAllLayouts();
}
void MenuManager::toggleQuestJournal() {
logger() << "[quest] toggleQuestJournal: " << (isQuestJournalOpen() ? "closing" : "opening") << std::endl;
if (uiState_ == GameUiState::QuestJournal) {
closeQuestJournal();
}
else {
if (uiState_ == GameUiState::Inventory) {
closeInventory();
}
openQuestJournal();
}
}
void MenuManager::openPhoneScreen() {
uiState_ = GameUiState::PhoneScreen;
currentPhoneSubScreen_ = PhoneSubScreen::Main;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneMainRoot : phoneMainRoot);
refreshHudVisibility();
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setButtonCallback("phoneMessenger", [this](const std::string&) {
openPhoneMessenger();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneBank", [this](const std::string&) {
openPhoneBank();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneVideo", [this](const std::string&) {
openPhoneVideo();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneTaxi", [this](const std::string&) {
openPhoneTaxi();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
const auto timeState = server_.snapshot();
if (timeState->isNight)
{
if (timeState->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);
}
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
}
void MenuManager::openPhoneMessenger() {
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneChatListRoot : phoneChatListRoot);
refreshHudVisibility();
refreshChatUnreadIndicators();
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chat1button", [this](const std::string&) {
setChatUnread(0, false);
openPhoneChatFromList(0, isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat2button", [this](const std::string&) {
setChatUnread(1, false);
openPhoneChatFromList(1, isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setTextButtonCallback("chat3button", [this](const std::string&) {
setChatUnread(2, false);
openPhoneChatFromList(2, isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
}
void MenuManager::refreshChatUnreadIndicators() {
const auto state = server_.snapshot();
static const char* kUnreadNodes[3] = { "chat1Unread", "chat2Unread", "chat3Unread" };
static const char* kMsgNodes[3] = { "chat1msg", "chat2msg", "chat3msg" };
for (int i = 0; i < 3; ++i) {
uiManager.setNodeVisible(kUnreadNodes[i], state->chatUnread[i]);
if (!state->chatPreview[i].empty())
uiManager.setText(kMsgNodes[i], state->chatPreview[i]);
}
}
void MenuManager::setChatUnread(int chatIndex, bool unread, const std::string& previewMsg) {
if (chatIndex < 0 || chatIndex > 2) return;
const auto state = server_.snapshot();
std::string preview = state->chatPreview[chatIndex];
if (!previewMsg.empty()) {
preview = trimChatPreview(previewMsg);
} else if (!unread && !state->chatHistory[chatIndex].empty()) {
preview = trimChatPreview(state->chatHistory[chatIndex].back().text);
}
server_.submit(SetChatCommand{chatIndex, unread, std::move(preview)});
}
void MenuManager::spendMoney(int amount) {
server_.submit(SpendMoneyCommand{amount});
}
std::vector<Item> MenuManager::inventoryItems() const {
std::vector<Item> items;
for (const std::string& id : server_.snapshot()->inventoryItemIds) {
if (const Item* item = ItemRegistry::instance().findById(id)) items.push_back(*item);
}
return items;
}
bool MenuManager::hasInventoryItem(const std::string& id) const {
const auto& ids = server_.snapshot()->inventoryItemIds;
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
void MenuManager::openPhoneBank() {
currentPhoneSubScreen_ = PhoneSubScreen::Bank;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneBankRoot : phoneBankRoot);
uiManager.setText("balanceText", formatMoney(server_.snapshot()->money));
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setButtonCallback("buttonBack", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
}
void MenuManager::openPhoneVideo() {
currentPhoneSubScreen_ = PhoneSubScreen::Video;
uiManager.pushMenuFromSavedRoot(isPortraitMode() ? verticalPhoneVideoRoot : phoneVideoRoot);
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setButtonCallback("buttonBack", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("videoSkip", [this](const std::string&) {
closePhoneEntirely();
const auto state = server_.snapshot();
if (state->isNight)
{
startDialogueFunc("dialog_video002");
}
else
{
auto dayIt = state->globalInts.find("day");
const int day = dayIt == state->globalInts.end() ? 0 : dayIt->second;
if (day == 0)
{
startDialogueFunc("dialog_video003");
}
else
{
audioPlayer_.playSoundAsync("resources/audio/78564__joedeshon__alarm_clock_ticking_02.ogg");
startNightTransitionFunc();
}
}
});
}
void MenuManager::openPhoneTaxi() {
const std::string location = server_.snapshot()->currentLocationId;
if (location == "uni_interior") {
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi003");
} else if (location == "uni_exterior") {
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
} else {
currentPhoneSubScreen_ = PhoneSubScreen::MapDorm;
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapDormRoot : phoneMapDormRoot);
}
}
void MenuManager::openPhoneMapScreen(std::shared_ptr<UiNode> mapRoot) {
uiManager.pushMenuFromSavedRoot(mapRoot);
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneEntirely();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setButtonCallback("buttonBack", [this](const std::string&) {
uiManager.popMenu();
uiManager.updateAllLayouts();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("mapGo", [this](const std::string&) {
auto state = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
false, true});
if (!state->taxiIsCalled)
{
server_.submit(SetTaxiCalledCommand{true});
server_.submit(StartTaxiCommand{state->currentLocationId});
server_.submit(SetTriggerZoneEnabledCommand{
state->currentLocationId, "taxi_zone001", false});
server_.submit(SpendMoneyCommand{500});
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi002");
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
}
else
{
closePhoneEntirely();
if (startDialogueFunc) startDialogueFunc("dialog_taxi004");
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
}
});
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
}
void MenuManager::openPhoneChatFromList(int chatIndex, std::shared_ptr<UiNode> chatRoot) {
activeChatIndex_ = chatIndex;
currentPhoneSubScreen_ = PhoneSubScreen::Chat;
phoneChatVisibleBubbles_.clear();
auto tutorial = server_.snapshot();
server_.submit(SetTutorialFlagsCommand{
true, tutorial->tutorialJournalScreenOpened,
tutorial->tutorialNeedOpenTaxiScreen, tutorial->tutorialTaxiHintDone});
uiManager.pushMenuFromSavedRoot(chatRoot);
// Build a dedicated chatUiManager root sized to the FBO chat area
{
const bool portrait = isPortraitMode();
const float fboW = portrait ? CHAT_FBO_WIDTH_P : CHAT_FBO_WIDTH_L;
const float fboH = portrait ? (CHAT_FBO_OFFSET_UP_P + CHAT_FBO_OFFSET_DOWN_P)
: (CHAT_FBO_OFFSET_UP_L + CHAT_FBO_OFFSET_DOWN_L);
auto fboRoot = std::make_shared<UiNode>();
fboRoot->name = "chatFboRoot";
fboRoot->width = fboW;
fboRoot->height = fboH;
fboRoot->screenRect = { 0.0f, 0.0f, fboW, fboH };
auto fboContainer = std::make_shared<UiNode>();
fboContainer->name = "chatMessagesContainer";
fboContainer->width = fboW;
fboContainer->height = fboH;
fboContainer->screenRect = { 0.0f, 0.0f, fboW, fboH };
fboRoot->children.push_back(fboContainer);
chatUiManager.replaceRoot(fboRoot);
chatFrameBuffer_ = std::make_unique<FrameBuffer>(
static_cast<int>(fboW), static_cast<int>(fboH), false);
chatFboQuadLastProjW_ = -1.0f;
}
rebuildChatBubblesFromHistory(chatIndex, isPortraitMode());
uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) {
closePhoneScreenFromChat();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
uiManager.setButtonCallback("phoneMain", [this](const std::string&) {});
uiManager.setTextButtonCallback("chatTitleButton", [this](const std::string&) {
returnToPhoneChatList();
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
});
if (chatOpenCallback) {
chatOpenCallback(chatIndex);
}
}
void MenuManager::returnToPhoneChatList() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::ChatList;
phoneChatVisibleBubbles_.clear();
chatUiManager.replaceRoot(nullptr);
chatFrameBuffer_.reset();
uiManager.popMenu();
uiManager.updateAllLayouts();
refreshHudVisibility();
refreshChatUnreadIndicators();
}
void MenuManager::closePhoneEntirely() {
if (activeChatIndex_ >= 0 && activeChatIndex_ <= 2) setChatUnread(activeChatIndex_, false);
activeChatIndex_ = -1;
currentPhoneSubScreen_ = PhoneSubScreen::None;
uiState_ = GameUiState::Gameplay;
phoneChatVisibleBubbles_.clear();
chatUiManager.replaceRoot(nullptr);
chatFrameBuffer_.reset();
const int depth = uiManager.menuStackSize();
for (int i = 0; i < depth; ++i) uiManager.popMenu();
uiManager.updateAllLayouts();
refreshHudVisibility();
}
void MenuManager::closePhoneScreenFromChat() {
closePhoneEntirely();
}
void MenuManager::closePhoneScreen() {
closePhoneEntirely();
}
void MenuManager::showModalMenuScreen() {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.pushMenuFromSavedRoot(modalMenuRoot);
uiManager.setButtonCallback("modalExitButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.popMenu();
uiManager.updateAllLayouts();
});
uiManager.setButtonCallback("menuCloseButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.popMenu();
uiManager.updateAllLayouts();
});
uiManager.setButtonCallback("menuSaveButton", [this](const std::string&) {
showSaveGameScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setButtonCallback("menuLoadButton", [this](const std::string&) {
showLoadGameScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setButtonCallback("menuSettingsButton", [this](const std::string&) {
showSettingsScreen();
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
});
uiManager.setButtonCallback("menuExitButton", [this](const std::string&) {
audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg");
uiManager.popMenu();
showMainMenu();
});
}
void MenuManager::tutorialShowTaxiHint()
{
logger() << "tutorialShowTaxiHint" << std::endl;
auto state = server_.snapshot();
if (!state->isTutorialActive() || state->tutorialTaxiHintDone) return;
server_.submit(SetTutorialFlagsCommand{
state->tutorialPhoneChatScreenOpened, state->tutorialJournalScreenOpened,
true, state->tutorialTaxiHintDone});
refreshHudVisibility();
}
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(); });
if (uiManager.findButton("settingsButton"))
uiManager.setButtonCallback("settingsButton", [this](const std::string&) { showModalMenuScreen(); });
hideAllToastWidgets();
applyToastsToUi();
}
void MenuManager::onLocationChanged(const std::string& locationName) {
if (uiState_ != GameUiState::Gameplay) return;
server_.submit(SetCurrentLocationCommand{locationName});
const auto state = server_.snapshot();
showGameplayHud();
if (locationName == "uni_exterior" || locationName == "uni_interior") {
if (state->isDarklands)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek fight calm.ogg");
lastMusicTrack = "music/bishkek fight calm.ogg";
}
else if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
}
else
{
audioPlayer_.crossFadeMusicAsync("music/bishkek univer day.ogg");
lastMusicTrack = "music/bishkek univer day.ogg";
}
} else {
if (state->isNight)
{
audioPlayer_.crossFadeMusicAsync("music/bishkek night.ogg");
lastMusicTrack = "music/bishkek night.ogg";
}
else
{
audioPlayer_.crossFadeMusicAsync("music/obshaga.ogg");
lastMusicTrack = "music/obshaga.ogg";
}
}
}
void MenuManager::onItemPickedUp(const std::string& itemId) {
// The new item's HUD button appears through refreshHudVisibility().
if (itemId == "phone" || itemId == "journal")
server_.submit(CompleteTutorialStepCommand{TutorialStep::PickUpItem});
refreshHudVisibility();
}
void MenuManager::refreshQuestJournalUi() {
visibleQuestIds.clear();
const auto state = server_.snapshot();
std::vector<const Quest::QuestState*> quests;
quests.reserve(state->quests.size());
for (const auto& quest : state->quests) quests.push_back(&quest);
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<int>(quests.size())) {
const auto* quest = quests[i];
visibleQuestIds.push_back(quest->definition.id);
const bool selected = (i == selectedQuestIndex);
std::array<float, 4> 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);
if (isPortraitMode()) {
node->height = numLines * 48.0f + 48.0f;
} else {
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<int>(visibleQuestIds.size())) {
return;
}
if (isPortraitMode() && journalMenuStackDepth_ == 1) {
openPortraitQuestJournalItem(index);
return;
}
selectedQuestIndex = index;
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
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<const Quest::QuestObjective*> 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<int>(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<float, 4> 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::openPortraitInventoryItem(int index) {
const auto items = inventoryItems();
inventorySelectedIndex_ = index;
inventoryMenuStackDepth_ = 2;
uiManager.pushMenuFromSavedRoot(portraitInventoryItemRoot);
uiManager.setButtonCallback("inventoryExitButton", [this](const std::string&) {
closePortraitInventoryItem();
audioPlayer_.playSoundAsync("resources/audio/sound_bag001.ogg");
});
uiManager.setButtonCallback("inventoryExitButton2", [this](const std::string&) {
closePortraitInventoryItem();
audioPlayer_.playSoundAsync("resources/audio/sound_bag001.ogg");
});
uiManager.setButtonCallback("inventoryMain", [this](const std::string&) {});
const auto& item = items[index];
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);
uiManager.updateAllLayouts();
}
void MenuManager::closePortraitInventoryItem() {
inventoryMenuStackDepth_ = 1;
uiManager.popMenu();
uiManager.updateAllLayouts();
}
void MenuManager::openPortraitQuestJournalItem(int index) {
selectedQuestIndex = index;
journalMenuStackDepth_ = 2;
uiManager.pushMenuFromSavedRoot(portraitQuestJournalItemRoot);
uiManager.setButtonCallback("journalItemExitButton", [this](const std::string&) {
closePortraitJournalItem();
audioPlayer_.playSoundAsync("resources/audio/255571__stradie__flipping-paper.ogg");
});
uiManager.setButtonCallback("journalItemExitButton2", [this](const std::string&) {
closeQuestJournal();
audioPlayer_.playSoundAsync("resources/audio/656126__itsthegoodstuff__paging-through-book.ogg");
});
uiManager.setButtonCallback("journalMain", [this](const std::string&) {});
const auto state = server_.snapshot();
const Quest::QuestState* quest = nullptr;
for (const auto& candidate : state->quests)
if (candidate.definition.id == visibleQuestIds[index]) quest = &candidate;
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" };
static const char* kObjNameLayouts[3] = { "objective1nameLayout", "objective2nameLayout", "objective3nameLayout" };
std::vector<const Quest::QuestObjective*> 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<int>(visibleObjs.size())) {
const auto& obj = *visibleObjs[i];
const bool isActive = (&obj - def.objectives.data() == quest->activeObjectiveIndex);
auto img = uiManager.findStaticImage(kCheckboxes[i]);
auto textView = uiManager.findTextView(kObjNames[i]);
if (img) img->texture = obj.completed ? texObjectiveCompleted_ : texObjectiveBlank_;
std::array<float, 4> 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 };
}
const int numLines = countWrappedLines(obj.text, *textView->textRenderer, 540);
if (numLines == 1)
{
uiManager.findNode(kObjNameLayouts[i])->height = 80.0f;
}
else
{
uiManager.findNode(kObjNameLayouts[i])->height = 60.0f;
}
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);
uiManager.updateAllLayouts();
}
void MenuManager::closePortraitJournalItem() {
journalMenuStackDepth_ = 1;
uiManager.popMenu();
uiManager.updateAllLayouts();
refreshQuestJournalUi();
}
/*
void MenuManager::closePortraitJournalItemCompletely() {
journalMenuStackDepth_ = 1;
uiManager.popMenu();
uiManager.updateAllLayouts();
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 fboH = CHAT_FBO_OFFSET_UP_L + CHAT_FBO_OFFSET_DOWN_L;
const float topY = (totalHeight <= fboH) ? fboH : totalHeight;
float cursor = topY;
for (auto& bubble : phoneChatVisibleBubbles_) {
auto node = chatUiManager.findNode(bubble.nodeName);
if (!node) continue;
node->localY = cursor - bubble.height;
cursor -= bubble.height + CHAT_SPACING;
}
chatUiManager.updateAllLayouts();
}
void MenuManager::recomputePhoneChatPositionsPortrait() {
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 fboH = CHAT_FBO_OFFSET_UP_P + CHAT_FBO_OFFSET_DOWN_P;
const float topY = (totalHeight <= fboH) ? fboH : totalHeight;
float cursor = topY;
for (auto& bubble : phoneChatVisibleBubbles_) {
auto node = chatUiManager.findNode(bubble.nodeName);
if (!node) continue;
node->localY = cursor - bubble.height;
cursor -= bubble.height + CHAT_SPACING;
}
chatUiManager.updateAllLayouts();
}
void MenuManager::rebuildChatBubblesFromHistory(int chatIndex, bool isPortrait) {
chatUiManager.clearChatBubbles("chatMessagesContainer");
phoneChatVisibleBubbles_.clear();
if (chatIndex < 0 || chatIndex > 2) return;
const auto state = server_.snapshot();
for (const auto& msg : state->chatHistory[chatIndex]) {
const bool inc = msg.incoming;
const std::string nodeName = chatUiManager.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", isPortrait ? 32 : 20, isPortrait, CONST_ZIP_FILE);
if (!nodeName.empty()) {
auto n = chatUiManager.findNode(nodeName);
phoneChatVisibleBubbles_.push_back({ nodeName, n ? n->height : 60.0f });
}
}
if (isPortrait)
{
recomputePhoneChatPositionsPortrait();
}
else
{
recomputePhoneChatPositions();
}
}
void MenuManager::onChatBubbleReady(const std::string& text, bool incoming) {
if (activeChatIndex_ < 0) return;
server_.submit(AppendChatMessageCommand{activeChatIndex_, text, incoming});
if (uiState_ != GameUiState::PhoneScreen) return;
bool isPortrait = isPortraitMode();
const std::string nodeName = chatUiManager.addChatBubble(
"chatMessagesContainer", text, incoming,
incoming ? texBubbleInCenter_ : texBubbleOutCenter_,
incoming ? texBubbleInLT_ : texBubbleOutLT_,
incoming ? texBubbleInLB_ : texBubbleOutLB_,
incoming ? texBubbleInRT_ : texBubbleOutRT_,
incoming ? texBubbleInRB_ : texBubbleOutRB_,
renderer, "resources/fonts/DroidSans.ttf", isPortrait ? 32 : 20, isPortrait, CONST_ZIP_FILE);
if (nodeName.empty()) return;
auto node = chatUiManager.findNode(nodeName);
if (node) {
node->scaleX = 0.0f;
node->scaleY = 0.0f;
phoneChatVisibleBubbles_.push_back({ nodeName, node->height });
}
if (isPortrait)
{
recomputePhoneChatPositionsPortrait();
}
else
{
recomputePhoneChatPositions();
}
chatUiManager.startPopIn(nodeName, 300.0f);
audioPlayer_.playSoundAsync("resources/audio/537061__imafoley__message-pop-sound.ogg");
}
void MenuManager::setDarklandsMode(bool enabled)
{
logger() << "MenuManager::setDarklandsMode called" << std::endl;
if (enabled && server_.snapshot()->currentLocationId == "uni_interior" && hasInventoryItem("note_spell"))
server_.submit(CompleteTutorialStepCommand{TutorialStep::EnterDarklands});
if (uiState_ == GameUiState::Gameplay)
showGameplayHud(); // darklands has its own HUD layouts
}
void MenuManager::onPlayerStartedWalking()
{
const auto state = server_.snapshot();
if (state->currentLocationId == "uni_interior" && state->isDarklands)
server_.submit(CompleteTutorialStepCommand{TutorialStep::DarklandsHealth});
}
void MenuManager::advanceUniIntDarklandsHud()
{
// The ghost zone can be reached without a floor tap (DarklandsHealth never completed).
const TutorialStep step = server_.snapshot()->tutorialStep;
if (step == TutorialStep::DarklandsHealth || step == TutorialStep::DarklandsExplore)
server_.submit(SetTutorialStepCommand{TutorialStep::DarklandsAttack});
}
void MenuManager::onEnemyKilledInUniInterior()
{
// Every location's enemies report here; only the university interior drives the tutorial.
const auto state = server_.snapshot();
if (state->currentLocationId != "uni_interior") return;
// The last tutorial step; like the attack hint, also ends the earlier darklands hints.
if (state->tutorialStep >= TutorialStep::DarklandsHealth)
server_.submit(SetTutorialStepCommand{TutorialStep::Completed});
}
void MenuManager::updateHealthBar(float hp, float maxHp) {
server_.submit(SetPlayerHealthCommand{hp, 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 (uiState_ == GameUiState::Gameplay)
onLocationChanged(server_.snapshot()->currentLocationId);
}
void MenuManager::applyCurrentHealthBar() {
logger() << "MenuManager::applyCurrentHealthBar called step 1" << std::endl;
const auto state = server_.snapshot();
logger() << "currentPlayerMaxHp_ is " << state->playerMaxHp << std::endl;
if (state->playerMaxHp <= 0.f) return;
logger() << "MenuManager::applyCurrentHealthBar called step 2" << std::endl;
logger() << "currentPlayerHp_ is " << state->playerHp << std::endl;
const float fraction = std::clamp(state->playerHp / state->playerMaxHp, 0.f, 1.f);
uiManager.setSliderValue("healthBarFill", fraction);
std::string hpText = std::to_string(static_cast<int>(state->playerHp)) + "/" +
std::to_string(static_cast<int>(state->playerMaxHp));
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(std::shared_ptr<Texture> toastTexture, const std::string& text) {
if (activeToasts_.size() < 3) {
ToastEntry e;
e.toastTexture = toastTexture;
e.text = text;
activeToasts_.push_back(std::move(e));
} else {
toastQueue_.push_back({ toastTexture, text });
}
applyToastsToUi();
}
void MenuManager::updateToasts(float deltaMs) {
// Advance timers and state transitions
for (auto& e : activeToasts_) {
e.timer += deltaMs;
if (e.state == ToastEntry::State::FadeIn && e.timer >= TOAST_FADE_MS) {
e.state = ToastEntry::State::Visible;
e.timer = 0.0f;
} else if (e.state == ToastEntry::State::Visible && e.timer >= TOAST_VISIBLE_MS) {
e.state = ToastEntry::State::FadeOut;
e.timer = 0.0f;
}
}
// Remove completed fade-outs
activeToasts_.erase(
std::remove_if(activeToasts_.begin(), activeToasts_.end(),
[](const ToastEntry& e) {
return e.state == ToastEntry::State::FadeOut && e.timer >= TOAST_FADE_MS;
}),
activeToasts_.end());
// Promote queued messages into free slots
while (!toastQueue_.empty() && activeToasts_.size() < 3) {
ToastEntry e;
e.toastTexture = toastQueue_.front().toastTexture;
e.text = toastQueue_.front().text;
toastQueue_.pop_front();
activeToasts_.push_back(std::move(e));
}
// Always sync UI — even with no active toasts, this hides stale widgets
// that couldn't be hidden while a pushed menu (inventory/journal/phone) was active.
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<int>(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 = e.toastTexture;
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);
chatUiManager.update(deltaMs);
if (uiState_ != GameUiState::MainMenu && uiState_ != GameUiState::About)
refreshHudVisibility();
}
void MenuManager::drawChatFbo(Renderer& renderer) {
if (!chatFrameBuffer_) return;
const bool portrait = isPortraitMode();
const float fboW = portrait ? CHAT_FBO_WIDTH_P : CHAT_FBO_WIDTH_L;
const float offsetUp = portrait ? CHAT_FBO_OFFSET_UP_P : CHAT_FBO_OFFSET_UP_L;
const float offsetDown= portrait ? CHAT_FBO_OFFSET_DOWN_P : CHAT_FBO_OFFSET_DOWN_L;
const float fboH = offsetUp + offsetDown;
const float projW = Environment::projectionWidth;
const float projH = Environment::projectionHeight;
// Pass 1: render chatUiManager into FBO
chatFrameBuffer_->Bind();
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
chatUiManager.draw(renderer, fboW, fboH);
chatFrameBuffer_->Unbind();
// Pass 2: blit FBO texture onto screen, centered with up/down offsets from screen center
if (chatFboQuadLastProjW_ != projW || chatFboQuadLastPortrait_ != portrait ||
chatFboQuadLastProjH_ != projH) {
const float x0 = (projW - fboW) * 0.5f;
const float y0 = projH * 0.5f - offsetDown;
const float x1 = x0 + fboW;
const float y1 = y0 + fboH;
VertexDataStruct vd;
vd.PositionData = { {x0,y0,0},{x0,y1,0},{x1,y1,0},
{x0,y0,0},{x1,y1,0},{x1,y0,0} };
vd.TexCoordData = { {0,0},{0,1},{1,1},
{0,0},{1,1},{1,0} };
chatFboQuad_.AssignFrom(vd);
chatFboQuadLastProjW_ = projW;
chatFboQuadLastProjH_ = projH;
chatFboQuadLastPortrait_ = portrait;
}
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
renderer.PushProjectionMatrix(projW, projH, -1, 1);
renderer.PushMatrix();
renderer.LoadIdentity();
renderer.RenderUniform1f("uAlpha", 1.0f);
glBindTexture(GL_TEXTURE_2D, chatFrameBuffer_->getTextureID());
renderer.DrawVertexRenderStruct(chatFboQuad_);
renderer.PopMatrix();
renderer.PopProjectionMatrix();
}
void MenuManager::saveSettings() {
nlohmann::json root;
root["musicVolume"] = audioPlayer_.getMusicVolume();
root["soundVolume"] = audioPlayer_.getSoundVolume();
root["musicEnabled"] = audioPlayer_.isMusicEnabled();
root["soundEnabled"] = audioPlayer_.isSoundEnabled();
root["fullscreen"] = Environment::isFullscreen;
root["language"] = FRG::languageToCode(FRG::g_currentLanguage);
root["shadow"] = shadowsEnabled;
root["tutorial"] = tutorialEnabledSetting;
saveJsonToFile(root, "settings.json");
}
void MenuManager::loadSettings() {
// Note: on desktop, main() already reads settings.json and creates the window
// directly in the right fullscreen state (see WIN32 main() in main.cpp) to avoid
// a windowed -> fullscreen flash. The block below just keeps Environment::isFullscreen
// in sync in case this is ever called from a path that didn't pre-apply it.
// Language is loaded separately, even earlier (see FRG::loadLanguageSetting()
// callers), because some location scripts start a dialogue as soon as they're
// loaded, before this function ever runs.
FRG::loadLanguageSetting();
const std::string content = FRG::readSavedTextFile("settings.json");
if (content.empty()) return;
try {
const nlohmann::json root = nlohmann::json::parse(content);
if (root.contains("musicVolume"))
audioPlayer_.setMusicVolume(root["musicVolume"].get<int>());
if (root.contains("soundVolume"))
audioPlayer_.setSoundVolume(root["soundVolume"].get<int>());
if (root.contains("musicEnabled"))
audioPlayer_.setMusicEnabled(root["musicEnabled"].get<bool>());
if (root.contains("soundEnabled"))
audioPlayer_.setSoundEnabled(root["soundEnabled"].get<bool>());
if (root.contains("fullscreen")) {
#ifndef EMSCRIPTEN
Environment::setFullscreen(root["fullscreen"].get<bool>());
#endif
}
if (root.contains("tutorial"))
tutorialEnabledSetting = root["tutorial"].get<bool>();
if (root.contains("shadow")) {
if (shadowMapSettingsChangedFunc)
{
shadowMapSettingsChangedFunc(root["shadow"].get<bool>());
}
}
else
{
if (shadowMapSettingsChangedFunc)
{
shadowMapSettingsChangedFunc(shadowsEnabled);
}
}
} catch (const std::exception& e) {
std::cerr << "[settings] Failed to parse settings.json: " << e.what() << std::endl;
}
}
bool MenuManager::isPortraitMode() const {
return Environment::projectionWidth < Environment::projectionHeight;
}
void MenuManager::onOrientationChanged() {
if (isPlayerInCreditsMenu) {
uiManager.popMenu();
showCreditsScreen();
uiManager.updateAllLayouts();
return;
}
if (uiState_ == GameUiState::Inventory) {
const int savedIndex = inventorySelectedIndex_;
const bool wasOnItemDetail = (inventoryMenuStackDepth_ == 2);
for (int i = 0; i < inventoryMenuStackDepth_; ++i) uiManager.popMenu();
inventoryMenuStackDepth_ = 0;
uiState_ = GameUiState::Gameplay;
inventorySelectedIndex_ = -1;
openInventory();
const int itemCount = static_cast<int>(inventoryItems().size());
if (isPortraitMode() && wasOnItemDetail &&
savedIndex >= 0 && savedIndex < itemCount) {
selectInventoryItem(savedIndex);
} else if (!isPortraitMode() && savedIndex > 0 && savedIndex < itemCount) {
selectInventoryItem(savedIndex);
}
uiManager.updateAllLayouts();
return;
}
if (uiState_ == GameUiState::QuestJournal) {
const int savedQuestIndex = selectedQuestIndex;
const bool wasOnItemDetail = (journalMenuStackDepth_ == 2);
for (int i = 0; i < journalMenuStackDepth_; ++i) uiManager.popMenu();
journalMenuStackDepth_ = 0;
uiState_ = GameUiState::Gameplay;
selectedQuestIndex = -1;
visibleQuestIds.clear();
openQuestJournal();
if (isPortraitMode() && wasOnItemDetail &&
savedQuestIndex >= 0 && savedQuestIndex < static_cast<int>(visibleQuestIds.size())) {
selectQuestByIndex(savedQuestIndex);
} else if (!isPortraitMode() && savedQuestIndex > 0 &&
savedQuestIndex < static_cast<int>(visibleQuestIds.size())) {
selectQuestByIndex(savedQuestIndex);
}
uiManager.updateAllLayouts();
return;
}
if (uiState_ != GameUiState::PhoneScreen) return;
const PhoneSubScreen savedSubScreen = currentPhoneSubScreen_;
const int savedChatIndex = activeChatIndex_;
// Collapse all stacked phone menus and reopen in the new orientation.
activeChatIndex_ = -1;
phoneChatVisibleBubbles_.clear();
const int depth = uiManager.menuStackSize();
for (int i = 0; i < depth; ++i) uiManager.popMenu();
uiState_ = GameUiState::Gameplay;
switch (savedSubScreen) {
case PhoneSubScreen::Bank:
openPhoneScreen();
openPhoneBank();
break;
case PhoneSubScreen::Video:
openPhoneScreen();
openPhoneVideo();
break;
case PhoneSubScreen::MapDorm:
openPhoneScreen();
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapDormRoot : phoneMapDormRoot);
currentPhoneSubScreen_ = PhoneSubScreen::MapDorm;
break;
case PhoneSubScreen::MapUni:
openPhoneScreen();
openPhoneMapScreen(isPortraitMode() ? verticalPhoneMapUniRoot : phoneMapUniRoot);
currentPhoneSubScreen_ = PhoneSubScreen::MapUni;
break;
case PhoneSubScreen::ChatList:
openPhoneScreen();
openPhoneMessenger();
break;
case PhoneSubScreen::Chat: {
openPhoneScreen();
openPhoneMessenger();
std::shared_ptr<UiNode> chatRoot;
if (savedChatIndex == 0) chatRoot = isPortraitMode() ? verticalPhoneChat1Root : phoneChat1Root;
else if (savedChatIndex == 1) chatRoot = isPortraitMode() ? verticalPhoneChat2Root : phoneChat2Root;
else if (savedChatIndex == 2) chatRoot = isPortraitMode() ? verticalPhoneChat3Root : phoneChat3Root;
if (chatRoot) openPhoneChatFromList(savedChatIndex, chatRoot);
break;
}
default:
openPhoneScreen();
break;
}
uiManager.updateAllLayouts();
}
} // namespace FRG