diff --git a/CMakeLists.txt b/CMakeLists.txt index 486d5ab..c66bca8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,8 @@ project(Letnik3Zadnja) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) +option(FETCH "Fetch SDL dependencies instead of using system-installed packages" OFF) + # Compile flags set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Werror -Wno-unused-parameter -O2") @@ -17,10 +19,10 @@ add_executable(${PROJECT_NAME} ${SOURCES}) target_include_directories(${PROJECT_NAME} PRIVATE include) # ------------------------------------------------------------ -# Platform-specific dependency handling +# Dependency handling # ------------------------------------------------------------ -if(WIN32) +if(FETCH OR WIN32) # Include FetchContent to download SDL libraries include(FetchContent) diff --git a/include/game/agame/player.hpp b/include/game/agame/player.hpp index a816be4..bb6aa13 100644 --- a/include/game/agame/player.hpp +++ b/include/game/agame/player.hpp @@ -12,6 +12,7 @@ GAME_ENTITY(Player) void setGroundTexture(std::shared_ptr tex); void respawnRandomSea(float landBoundaryX); bool isShipMode() const { return mIsShipMode; } + void setShipMode(bool isShip) { mIsShipMode = isShip; } // Set form state for replay void onCollisionEnter(Object::Entity* other) override; private: Object::Sound mSound; diff --git a/include/game/agame/sampletextbox.hpp b/include/game/agame/sampletextbox.hpp deleted file mode 100644 index bed7973..0000000 --- a/include/game/agame/sampletextbox.hpp +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once -#include - -namespace Game::AGame { - class SampleTextBox : public Object::UITextBox { - using Object::UITextBox::UITextBox; // Inherit constructors - - public: - ~SampleTextBox() override = default; - - void start() override { - // Call the base class start to initialize the text box - mZIndex = 1000; // Ensure it renders on top of most other entities - Object::UITextBox::start(); - setText("Hello, World!"); - - mIsActive = false; - mIsVisible = false; - } - void update(float deltaTime) override { - // Call the base class update to handle input and text refreshing - Object::UITextBox::update(deltaTime); - } - }; -} \ No newline at end of file diff --git a/include/game/gamemanager.hpp b/include/game/gamemanager.hpp index f9c7793..4004192 100644 --- a/include/game/gamemanager.hpp +++ b/include/game/gamemanager.hpp @@ -19,7 +19,8 @@ namespace Game { enum class GameStateEnum { RUNNING, PAUSED, - STOPPED + STOPPED, + REPLAY }; enum class SharedDataType { @@ -59,7 +60,14 @@ namespace Game { static void processPendingEntityRemovals(); static void pushPlayerPosition(Object::Transform transform) { mPlayerTransformHistory.push_back(transform); } + static void pushPlayerFormState(bool isShipMode) { mPlayerFormHistory.push_back(isShipMode); } static void getPlayerPositionHistory(std::vector& outHistory) { outHistory = mPlayerTransformHistory; } + static void getPlayerFormHistory(std::vector& outHistory) { outHistory = mPlayerFormHistory; } + + // Replay mode API + static bool initReplayMode(); + static bool playReplayFrame(); + static void stopReplayMode(); private: int mTargetUpdatesPerSecond = TARGET_UPDATE_RATE; @@ -69,8 +77,17 @@ namespace Game { static std::unordered_map mSharedFloats; static std::unordered_map mSharedBools; static std::vector mPlayerTransformHistory; + static std::vector mPlayerFormHistory; static GameStateEnum mCurrentGameState; float mLastDelta = 0.f; + + // Replay data + struct ReplayFrame { + float x, y, rotation, scaleX, scaleY; + bool isShipMode; + }; + static std::vector mReplayFrames; + static size_t mCurrentReplayFrame; }; template diff --git a/include/game/input.hpp b/include/game/input.hpp index c080360..223f525 100644 --- a/include/game/input.hpp +++ b/include/game/input.hpp @@ -1,6 +1,10 @@ #pragma once #include +#include +#include +#include +#include namespace Game { class Input { @@ -16,13 +20,23 @@ namespace Game { static bool isMouseButtonJustReleased(Uint8 button); static float getMouseX(); static float getMouseY(); + + // Text input from SDL text-input events (pushed by window thread, consumed by game thread) + static void pushText(const std::string& utf8); + static void consumeText(std::vector& out); + private: static const bool* mCurrentKeyStates; - static const bool* mPreviousKeyStates; + static std::vector mPreviousKeyStates; static int mNumKeys; + static int mPrevNumKeys; static SDL_MouseButtonFlags mCurrentMouseButtonStates; static SDL_MouseButtonFlags mPreviousMouseButtonStates; static float mMouseX; static float mMouseY; + + // Text input queue and mutex (window thread writes via pushText, game thread reads via consumeText) + static std::mutex mTextMutex; + static std::vector mPendingText; }; } \ No newline at end of file diff --git a/include/object/ui/uibutton.hpp b/include/object/ui/uibutton.hpp index 2c7f84f..d53ae1d 100644 --- a/include/object/ui/uibutton.hpp +++ b/include/object/ui/uibutton.hpp @@ -25,5 +25,6 @@ namespace Game::Object { void* mClickFunction = nullptr; float mX, mY; std::string mText; + bool mIsHovered = false; }; } \ No newline at end of file diff --git a/include/object/ui/uitextbox.hpp b/include/object/ui/uitextbox.hpp index ab17666..ff167eb 100644 --- a/include/object/ui/uitextbox.hpp +++ b/include/object/ui/uitextbox.hpp @@ -4,62 +4,54 @@ #include #include #include -#include #include #include +#include namespace Game::Object { - - struct UITextboxConfig { - SDL_Color bgColor = {20, 20, 20, 210}; - SDL_Color borderColor = {110, 110, 110, 255}; - SDL_Color focusedBorderColor = {200, 175, 70, 255}; - SDL_Color textColor = {255, 255, 255, 255}; - SDL_Color focusedTextColor = {255, 240, 180, 255}; - SDL_Color placeholderColor = {120, 120, 120, 200}; - float borderThickness = 2.f; - float paddingX = 8.f; - float paddingY = 4.f; - float minWidth = 160.f; - float minHeight = 32.f; - int maxLength = 0; // 0 = unlimited - std::string placeholder = ""; - float cursorBlinkRate = 0.53f; // seconds per blink phase - }; - class UITextBox : public Entity { public: - UITextBox(const std::string& name, std::shared_ptr font, - const Transform& transform, - float x = 0.f, float y = 0.f, - UITextboxConfig config = {}); + UITextBox(const std::string& name, std::shared_ptr background, std::shared_ptr font, const Transform& transform, float x = 0.f, float y = 0.f); ~UITextBox() override = default; - void start() override; + void start() override; void update(float deltaTime) override; + + // Custom render to show both background and text void render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) override; - void setText(const std::string& text); - std::string getText() const; - std::string getValue() const; - bool isFocused() const; + void setText(const std::string& text); + std::string getText() const; + void setPlaceholder(const std::string& placeholder) { mPlaceholder = placeholder; mLastRenderedText.clear(); } + + // Insert UTF-8 text at the current cursor position (delivered from Input queue) + void insertText(const std::string& utf8); + + void setMaxLength(size_t maxLen) { mMaxLength = maxLen; } + void setPasswordMode(bool enable) { mPasswordMode = enable; } + void setOnFocus(void* fn) { mOnFocus = fn; } void setPosition(float x, float y) { mX = x; mY = y; } std::pair getPosition() const { return {mX, mY}; } + bool isFocused() const { return mIsFocused; } + private: - bool isMouseInsideBox() const; - void refreshVisualText(); + std::shared_ptr mBackground; // Background texture for the textbox + std::shared_ptr mFont; // Font used to render text float mX, mY; std::string mText; + size_t mCursorIndex = 0; + float mCursorBlinkTimer = 0.f; + bool mShowCursor = true; + size_t mMaxLength = 1024; + bool mPasswordMode = false; bool mIsFocused = false; - float mBoxWidth = 0.f; - float mBoxHeight = 0.f; - bool mNeedsTextRefresh = true; - UITextboxConfig mConfig; - - float mCursorTimer = 0.f; - bool mCursorVisible = true; + void* mOnFocus = nullptr; // optional function pointer + std::string mLastRenderedText; // to avoid rebuilding font unnecessarily + std::string mPlaceholder = ""; + float mReservedPlaceholderWidth = 0.f; // Reserved pixel width for placeholder to avoid layout shifts + std::mutex mRenderMutex; // Protects mLastRenderedText and mReservedPlaceholderWidth from main-thread updates }; } diff --git a/include/renderer/font.hpp b/include/renderer/font.hpp index ff6277d..52b5b14 100644 --- a/include/renderer/font.hpp +++ b/include/renderer/font.hpp @@ -17,8 +17,8 @@ namespace Game::Renderer { // Build the texture for the font; Call getSDLTexture() afterwards void build(SDL_Color color, std::string text); - // Rebuild GPU-backed texture after a renderer/device reset - bool reload(SDL_Renderer* renderer); + // Rebuild GPU-backed texture after a renderer/device reset + bool reload(SDL_Renderer* renderer); SDL_Texture* getSDLTexture(); std::string getId(); diff --git a/include/renderer/texture.hpp b/include/renderer/texture.hpp index 811f97e..cd8abb8 100644 --- a/include/renderer/texture.hpp +++ b/include/renderer/texture.hpp @@ -21,6 +21,7 @@ namespace Game::Renderer { float getHeight(); bool isTiled() { return mIsTiled; } void setTiled(bool tiled) { mIsTiled = tiled; } + void setSDLTexture(SDL_Texture* tex) { mTex = tex; } // Reload GPU-backed texture using a new renderer after device reset virtual bool reload(SDL_Renderer* renderer); diff --git a/include/window/window.hpp b/include/window/window.hpp index 577cc1b..5a752cb 100644 --- a/include/window/window.hpp +++ b/include/window/window.hpp @@ -28,6 +28,8 @@ namespace Game::Window { static SDL_Window* getSDLWindowBackend() { std::scoped_lock lock(sMutex); return sWindowBackend; } Renderer::Renderer* getRenderer() { std::scoped_lock lock(mMutex); return &mRenderer; } + // Post a task to be executed on the window/event thread. + static void postToMainThread(std::function fn); private: mutable std::mutex mMutex; diff --git a/src/game/agame/background.cpp b/src/game/agame/background.cpp index 6c09060..b59c984 100644 --- a/src/game/agame/background.cpp +++ b/src/game/agame/background.cpp @@ -36,17 +36,23 @@ namespace { file << "Točke: " << score << "\n"; file << "Datum: " << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S") << "\n"; - // Replay system + // Replay system with form state std::vector playerHistory; + std::vector formHistory; Game::GameManager::getPlayerPositionHistory(playerHistory); + Game::GameManager::getPlayerFormHistory(formHistory); + std::ofstream replayFile("replay.txt", std::ios::trunc); if (!replayFile.is_open()) { WARN("Neuspešno odpiranje replay.txt za pisanje"); return; } - for (const auto& transform : playerHistory) { - replayFile << transform.x << " " << transform.y << " " << transform.rotation << " " << transform.scaleX << " " << transform.scaleY << "\n"; + for (size_t i = 0; i < playerHistory.size(); ++i) { + const auto& transform = playerHistory[i]; + bool isShipMode = (i < formHistory.size()) ? formHistory[i] : true; + int shipFlag = isShipMode ? 1 : 0; + replayFile << transform.x << " " << transform.y << " " << transform.rotation << " " << transform.scaleX << " " << transform.scaleY << " " << shipFlag << "\n"; } LOG("Zapis končne statistike in replaya igre dokončan"); diff --git a/src/game/agame/hudtext.cpp b/src/game/agame/hudtext.cpp index f814fe5..5cc6ad9 100644 --- a/src/game/agame/hudtext.cpp +++ b/src/game/agame/hudtext.cpp @@ -33,13 +33,19 @@ namespace Game::AGame { }; if (GameManager::getSharedData("gameLost")) { - setText("Umrl si!"); + if (getText() != "Umrl si!") { + const std::string s = "Umrl si!"; + Window::Window::postToMainThread([this, s]() { setText(s); }); + } anchorTopRight(); return; } if (GameManager::getSharedData("gameWon")) { - setText("Zmagal si!"); + if (getText() != "Zmagal si!") { + const std::string s = "Zmagal si!"; + Window::Window::postToMainThread([this, s]() { setText(s); }); + } anchorTopRight(); return; } @@ -53,7 +59,10 @@ namespace Game::AGame { << " | Smeti " << GameManager::getSharedData("trashActiveCount") << " | Sovražniki " << GameManager::getSharedData("enemyActiveCount"); - setText(stream.str()); + const std::string newHudText = stream.str(); + if (getText() != newHudText) { + Window::Window::postToMainThread([this, newHudText]() { setText(newHudText); }); + } anchorTopRight(); } } diff --git a/src/game/agame/player.cpp b/src/game/agame/player.cpp index ec112ac..1f4fe59 100644 --- a/src/game/agame/player.cpp +++ b/src/game/agame/player.cpp @@ -162,8 +162,9 @@ namespace Game::AGame { setTexture(mGroundTex); } - // Push replay + // Push replay (position and form state) GameManager::pushPlayerPosition(mTransform); + GameManager::pushPlayerFormState(mIsShipMode); } void Player::onCollisionEnter(Object::Entity* other) { diff --git a/src/game/gamemanager.cpp b/src/game/gamemanager.cpp index 18da05f..98c51f6 100644 --- a/src/game/gamemanager.cpp +++ b/src/game/gamemanager.cpp @@ -1,8 +1,14 @@ #include #include +#include +#include +#include +#include namespace Game { GameStateEnum GameManager::mCurrentGameState = GameStateEnum::RUNNING; + std::vector GameManager::mReplayFrames; + size_t GameManager::mCurrentReplayFrame = 0; void GameManager::run(std::stop_token stopToken) { using namespace std::chrono_literals; @@ -27,19 +33,30 @@ namespace Game { try { Input::update(); // Update input states at the start of each frame - auto entities = State::GameState::getInstance().getEntitiesSnapshot(); - for (auto* entity : entities) { - if (!entity || !entity->isActive()) { - continue; + + // Handle REPLAY state + if (mCurrentGameState == GameStateEnum::REPLAY) { + if (!playReplayFrame()) { + // No more frames - end replay + stopReplayMode(); + mCurrentGameState = GameStateEnum::STOPPED; + LOG("Replay finished"); } + } else { + auto entities = State::GameState::getInstance().getEntitiesSnapshot(); + for (auto* entity : entities) { + if (!entity || !entity->isActive()) { + continue; + } - // Update components first - entity->updateComponents(seconds); - if (!entity->isActive()) { - continue; + // Update components first + entity->updateComponents(seconds); + if (!entity->isActive()) { + continue; + } + + entity->update(seconds); } - - entity->update(seconds); } } catch (const std::exception& e) { ERROR("Exception in GameManager thread: " << e.what()); @@ -68,6 +85,7 @@ namespace Game { std::unordered_map GameManager::mSharedFloats; std::unordered_map GameManager::mSharedBools; std::vector GameManager::mPlayerTransformHistory; + std::vector GameManager::mPlayerFormHistory; void GameManager::removeSharedData(const std::string& key, SharedDataType type) { if (type == SharedDataType::STRING) { @@ -84,5 +102,65 @@ namespace Game { void GameManager::processPendingEntityRemovals() { State::GameState::getInstance().processPendingRemovals(); } + + bool GameManager::initReplayMode() { + // Read and parse replay.txt + std::ifstream replayFile("replay.txt"); + if (!replayFile.is_open()) { + WARN("Failed to open replay.txt for reading"); + return false; + } + + mReplayFrames.clear(); + mCurrentReplayFrame = 0; + std::string line; + + while (std::getline(replayFile, line)) { + if (line.empty()) continue; + + std::istringstream iss(line); + ReplayFrame frame; + int isShip; + + if (iss >> frame.x >> frame.y >> frame.rotation >> frame.scaleX >> frame.scaleY >> isShip) { + frame.isShipMode = (isShip != 0); + mReplayFrames.push_back(frame); + } else { + WARN("Failed to parse replay line: " << line); + } + } + + replayFile.close(); + LOG("Loaded " << mReplayFrames.size() << " replay frames"); + return !mReplayFrames.empty(); + } + + bool GameManager::playReplayFrame() { + if (mReplayFrames.empty() || mCurrentReplayFrame >= mReplayFrames.size()) { + return false; // Replay finished + } + + const ReplayFrame& frame = mReplayFrames[mCurrentReplayFrame]; + + // Apply frame to player + auto* player = dynamic_cast(State::GameState::getInstance().getEntityByName("Player")); + if (player) { + player->getTransform()->x = frame.x; + player->getTransform()->y = frame.y; + player->getTransform()->rotation = frame.rotation; + player->getTransform()->scaleX = frame.scaleX; + player->getTransform()->scaleY = frame.scaleY; + player->setShipMode(frame.isShipMode); + } + + mCurrentReplayFrame++; + return mCurrentReplayFrame < mReplayFrames.size(); // Return true if more frames exist + } + + void GameManager::stopReplayMode() { + mReplayFrames.clear(); + mCurrentReplayFrame = 0; + mCurrentGameState = GameStateEnum::STOPPED; + } } \ No newline at end of file diff --git a/src/game/input.cpp b/src/game/input.cpp index 44d2732..5715647 100644 --- a/src/game/input.cpp +++ b/src/game/input.cpp @@ -1,35 +1,103 @@ #include +#include +#include +#include namespace Game { const bool* Input::mCurrentKeyStates = nullptr; - const bool* Input::mPreviousKeyStates = nullptr; + std::vector Input::mPreviousKeyStates = {}; int Input::mNumKeys = 0; + int Input::mPrevNumKeys = 0; SDL_MouseButtonFlags Input::mCurrentMouseButtonStates = 0; SDL_MouseButtonFlags Input::mPreviousMouseButtonStates = 0; float Input::mMouseX = 0.0f; float Input::mMouseY = 0.0f; + std::mutex Input::mTextMutex; + std::vector Input::mPendingText; void Input::update() { - mPreviousKeyStates = mCurrentKeyStates; + // Copy the previous keyboard state (snapshot) so we can detect just-pressed + if (mCurrentKeyStates && mNumKeys > 0) { + mPreviousKeyStates.assign(mCurrentKeyStates, mCurrentKeyStates + mNumKeys); + mPrevNumKeys = mNumKeys; + } else { + // If we don't have a previous snapshot, initialize previous vector to zeros with current size + if (mNumKeys > 0) mPreviousKeyStates.assign(mNumKeys, 0); + mPrevNumKeys = mNumKeys; + } + mCurrentKeyStates = SDL_GetKeyboardState(&mNumKeys); mPreviousMouseButtonStates = mCurrentMouseButtonStates; - mCurrentMouseButtonStates = SDL_GetMouseState(&mMouseX, &mMouseY); + float rawMouseX = 0.0f; + float rawMouseY = 0.0f; + mCurrentMouseButtonStates = SDL_GetMouseState(&rawMouseX, &rawMouseY); + + // Convert mouse coordinates from real display/window pixels into + // centered logical game coordinates (1280x720 world space). + static constexpr float LOGICAL_WIDTH = 1280.0f; + static constexpr float LOGICAL_HEIGHT = 720.0f; + + float displayW = LOGICAL_WIDTH; + float displayH = LOGICAL_HEIGHT; + + SDL_Window* sdlWindow = Window::Window::getSDLWindowBackend(); + if (sdlWindow) { + int windowPixelsW = static_cast(LOGICAL_WIDTH); + int windowPixelsH = static_cast(LOGICAL_HEIGHT); + SDL_GetWindowSizeInPixels(sdlWindow, &windowPixelsW, &windowPixelsH); + + SDL_DisplayID displayId = SDL_GetDisplayForWindow(sdlWindow); + const SDL_DisplayMode* displayMode = SDL_GetCurrentDisplayMode(displayId); + if (displayMode && displayMode->w > 0 && displayMode->h > 0) { + displayW = static_cast(displayMode->w); + displayH = static_cast(displayMode->h); + } else if (windowPixelsW > 0 && windowPixelsH > 0) { + displayW = static_cast(windowPixelsW); + displayH = static_cast(windowPixelsH); + } + } + + const float invScaleX = LOGICAL_WIDTH / displayW; + const float invScaleY = LOGICAL_HEIGHT / displayH; + mMouseX = rawMouseX * invScaleX - (LOGICAL_WIDTH * 0.5f); + mMouseY = rawMouseY * invScaleY - (LOGICAL_HEIGHT * 0.5f); + + // Consume and deliver queued text input from window thread to focused textbox + std::vector texts; + consumeText(texts); + if (!texts.empty()) { + auto entities = State::GameState::getInstance().getEntitiesSnapshot(); + for (auto* e : entities) { + if (!e) continue; + auto* tb = dynamic_cast(e); + if (tb && tb->isFocused()) { + for (auto& s : texts) { + tb->insertText(s); + } + break; // Deliver to first focused textbox only + } + } + } } bool Input::isKeyPressed(SDL_Scancode key) { if (key < 0 || key >= mNumKeys) return false; - return mCurrentKeyStates[key]; + return mCurrentKeyStates && mCurrentKeyStates[key]; } bool Input::isKeyJustPressed(SDL_Scancode key) { if (key < 0 || key >= mNumKeys) return false; - return mCurrentKeyStates[key] && (!mPreviousKeyStates || !mPreviousKeyStates[key]); + bool cur = mCurrentKeyStates && mCurrentKeyStates[key]; + bool prev = (key < static_cast(mPreviousKeyStates.size())) ? static_cast(mPreviousKeyStates[key]) : false; + return cur && !prev; } bool Input::isKeyJustReleased(SDL_Scancode key) { if (key < 0 || key >= mNumKeys) return false; - return (!mCurrentKeyStates[key]) && mPreviousKeyStates && mPreviousKeyStates[key]; + bool cur = mCurrentKeyStates && mCurrentKeyStates[key]; + bool prev = (key < static_cast(mPreviousKeyStates.size())) ? static_cast(mPreviousKeyStates[key]) : false; + return !cur && prev; } bool Input::isMouseButtonPressed(Uint8 button) { @@ -51,4 +119,14 @@ namespace Game { float Input::getMouseY() { return mMouseY; } + + void Input::pushText(const std::string& utf8) { + std::scoped_lock lock(mTextMutex); + mPendingText.push_back(utf8); + } + + void Input::consumeText(std::vector& out) { + std::scoped_lock lock(mTextMutex); + out.swap(mPendingText); + } } \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 4fa284e..03e1afa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,46 +11,261 @@ #include #include #include -#include +#include +#include #include +#include using namespace Game; -int main() { - PLNIMP("Letnik3Zadnja - Licenca: LGPLv2.1-only, CC BY-SA 4.0"); - // Prompt for player name before initializing the window/engine - std::string playerName; - std::cout << "Vnesi uporabniško ime (pusti prazno za 'Igralec'): "; - std::getline(std::cin, playerName); - if (playerName.empty()) playerName = "Igralec"; +// Global SDL renderer pointer used by menu callback to create textures/fonts +static SDL_Renderer* gSDLRenderer = nullptr; +static Uint32 gStartGameEventType = static_cast(-1); +static std::atomic_bool gStartGameQueued = false; + +namespace { + // Create a solid-color texture (e.g., for textbox backgrounds) + std::shared_ptr createSolidColorTexture(SDL_Renderer* renderer, int width, int height, Uint8 r, Uint8 g, Uint8 b, Uint8 a) { + // Create an SDL surface filled with the specified color + SDL_Surface* surf = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_ARGB8888); + if (!surf) return nullptr; + + Uint32 color = SDL_MapSurfaceRGB(surf, r, g, b); + SDL_FillSurfaceRect(surf, nullptr, color); + + // Create texture from surface + SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer, surf); + SDL_DestroySurface(surf); + + if (!tex) return nullptr; + + // Set the texture to blend with alpha so it composites correctly + SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND); + SDL_SetTextureAlphaMod(tex, a); + + // Wrap the SDL_Texture in a Texture object + auto texturePtr = std::make_shared("textbox_background"); + texturePtr->setSDLTexture(tex); // Set the texture via protected setter + return texturePtr; + } +} + +static void performStartGameTransition() { + // Read name from the UI textbox + auto* tbEntity = dynamic_cast(State::GameState::getInstance().getEntityByName("NameBox")); + std::string playerName = "Igralec"; + if (tbEntity) { + auto txt = tbEntity->getText(); + if (!txt.empty()) playerName = txt; + } + Game::GameManager::setSharedData("playerName", playerName); - Window::Window window = Window::Window(); - window.init(1280, 720, "Game Window"); - - State::GameState::getInstance().addEntity(std::make_unique("BG", std::make_shared("../resources/bgtest.png", window.getRenderer()->getSDLRenderer()), Object::DEFAULT_TRANSFORM)); + // Spawn the main game entities now that we have a player name + // Background + State::GameState::getInstance().addEntity(std::make_unique("BG", std::make_shared("../resources/bgtest.png", gSDLRenderer), Object::DEFAULT_TRANSFORM)); + // Player auto* player = dynamic_cast( State::GameState::getInstance().addEntity( std::make_unique( "Player", - std::make_shared( - "../resources/l3ladja.png", - window.getRenderer()->getSDLRenderer() - ), + std::make_shared("../resources/l3ladja.png", gSDLRenderer), Object::DEFAULT_TRANSFORM ) ) ); if (player) { player->addComponent(); - player->setShipTexture(std::make_shared("../resources/l3ladja.png", window.getRenderer()->getSDLRenderer())); - player->setGroundTexture(std::make_shared("../resources/l3player.png", window.getRenderer()->getSDLRenderer())); + player->setShipTexture(std::make_shared("../resources/l3ladja.png", gSDLRenderer)); + player->setGroundTexture(std::make_shared("../resources/l3player.png", gSDLRenderer)); } - State::GameState::getInstance().addEntity(std::make_unique("HUD", std::make_shared("../resources/roboto.ttf", window.getRenderer()->getSDLRenderer(), 60, "HUDFont"), Object::Transform{0.f, 0.f, 0.f, 1.f, 1.f}, 320.f, 40.f)); + // HUD + State::GameState::getInstance().addEntity(std::make_unique("HUD", std::make_shared("../resources/roboto.ttf", gSDLRenderer, 60, "HUDFont"), Object::Transform{0.f, 0.f, 0.f, 1.f, 1.f}, 320.f, 40.f)); + + // Remove menu entities safely at end-of-frame to avoid invalidating + // pointers currently being iterated by the game update snapshot. + auto deactivateAndQueueRemoval = [](const std::string& entityName) { + if (auto* entity = State::GameState::getInstance().getEntityByName(entityName)) { + entity->setActive(false); + State::GameState::getInstance().queueEntityRemoval(entityName); + } + }; + + deactivateAndQueueRemoval("Title"); + deactivateAndQueueRemoval("NameBox"); + deactivateAndQueueRemoval("StartButton"); +} + +static void performReplayTransition() { + // Spawn minimal game entities for replay + State::GameState::getInstance().addEntity(std::make_unique("BG", std::make_shared("../resources/bgtest.png", gSDLRenderer), Object::DEFAULT_TRANSFORM)); + + auto* player = dynamic_cast( + State::GameState::getInstance().addEntity( + std::make_unique( + "Player", + std::make_shared("../resources/l3ladja.png", gSDLRenderer), + Object::DEFAULT_TRANSFORM + ) + ) + ); + if (player) { + player->addComponent(); + player->setShipTexture(std::make_shared("../resources/l3ladja.png", gSDLRenderer)); + player->setGroundTexture(std::make_shared("../resources/l3player.png", gSDLRenderer)); + } + + // HUD + State::GameState::getInstance().addEntity(std::make_unique("HUD", std::make_shared("../resources/roboto.ttf", gSDLRenderer, 60, "HUDFont"), Object::Transform{0.f, 0.f, 0.f, 1.f, 1.f}, 320.f, 40.f)); + + // Initialize and start replay + if (GameManager::initReplayMode()) { + GameManager::setCurrentGameState(GameStateEnum::REPLAY); + LOG("Replay mode started"); + } else { + WARN("Failed to initialize replay mode"); + GameManager::setCurrentGameState(GameStateEnum::STOPPED); + } + + // Remove menu entities + auto deactivateAndQueueRemoval = [](const std::string& entityName) { + if (auto* entity = State::GameState::getInstance().getEntityByName(entityName)) { + entity->setActive(false); + State::GameState::getInstance().queueEntityRemoval(entityName); + } + }; + + deactivateAndQueueRemoval("Title"); + deactivateAndQueueRemoval("NameBox"); + deactivateAndQueueRemoval("StartButton"); + deactivateAndQueueRemoval("ReplayButton"); +} + +static void startGameCallback() { + // UIButton clicks are handled on the game thread. Queue a custom SDL user + // event so entity/texture creation happens on the window/event thread. + bool alreadyQueued = gStartGameQueued.exchange(true); + if (alreadyQueued) { + return; + } + + if (gStartGameEventType == static_cast(-1)) { + // Fallback if custom events are unavailable. + performStartGameTransition(); + return; + } + + SDL_Event event{}; + event.type = gStartGameEventType; + event.user.data1 = reinterpret_cast(&performStartGameTransition); + if (!SDL_PushEvent(&event)) { + WARN("Failed to push start-game user event: " << SDL_GetError()); + performStartGameTransition(); + } +} + +static void replayGameCallback() { + // UIButton clicks are handled on the game thread. Queue a custom SDL user + // event so entity/texture creation happens on the window/event thread. + bool alreadyQueued = gStartGameQueued.exchange(true); + if (alreadyQueued) { + return; + } + + if (gStartGameEventType == static_cast(-1)) { + // Fallback if custom events are unavailable. + performReplayTransition(); + return; + } + + SDL_Event event{}; + event.type = gStartGameEventType; + event.user.data1 = reinterpret_cast(&performReplayTransition); + if (!SDL_PushEvent(&event)) { + WARN("Failed to push replay user event: " << SDL_GetError()); + performReplayTransition(); + } +} + +int main() { + PLNIMP("Letnik3Zadnja - Licenca: LGPLv2.1-only, CC BY-SA 4.0"); + + Window::Window window = Window::Window(); + window.init(1280, 720, "Game Window"); + + // Make SDL renderer available to callbacks + gSDLRenderer = window.getRenderer()->getSDLRenderer(); + + // Register a custom event used to perform menu -> game transition on the + // window/event thread. + gStartGameEventType = SDL_RegisterEvents(1); + + // Separate fonts for each UI element (each font owns its own texture). + // Reusing one font object would make all UI labels share one texture. + auto titleFont = std::make_shared("../resources/roboto.ttf", gSDLRenderer, 72, "TitleFont"); + auto nameBoxFont = std::make_shared("../resources/roboto.ttf", gSDLRenderer, 58, "NameBoxFont"); + auto startButtonFont = std::make_shared("../resources/roboto.ttf", gSDLRenderer, 72, "StartButtonFont"); + auto replayButtonFont = std::make_shared("../resources/roboto.ttf", gSDLRenderer, 60, "ReplayButtonFont"); + + // Determine display resolution and convert to centered logical coordinates for UI layout + static constexpr float LOGICAL_WIDTH = 1280.0f; + static constexpr float LOGICAL_HEIGHT = 720.0f; + + float displayW = LOGICAL_WIDTH; + float displayH = LOGICAL_HEIGHT; + + SDL_Window* sdlWin = Window::Window::getSDLWindowBackend(); + if (sdlWin) { + int windowPixelsW = static_cast(LOGICAL_WIDTH); + int windowPixelsH = static_cast(LOGICAL_HEIGHT); + SDL_GetWindowSizeInPixels(sdlWin, &windowPixelsW, &windowPixelsH); + + SDL_DisplayID displayId = SDL_GetDisplayForWindow(sdlWin); + const SDL_DisplayMode* displayMode = SDL_GetCurrentDisplayMode(displayId); + if (displayMode && displayMode->w > 0 && displayMode->h > 0) { + displayW = static_cast(displayMode->w); + displayH = static_cast(displayMode->h); + } else if (windowPixelsW > 0 && windowPixelsH > 0) { + displayW = static_cast(windowPixelsW); + displayH = static_cast(windowPixelsH); + } + } + + const float centerDisplayX = displayW * 0.5f; + const float centerDisplayY = displayH * 0.5f; + const float cx = centerDisplayX * (LOGICAL_WIDTH / displayW) - (LOGICAL_WIDTH * 0.5f); + const float cy = centerDisplayY * (LOGICAL_HEIGHT / displayH) - (LOGICAL_HEIGHT * 0.5f); + + LOG("cx: " << cx << ", cy: " << cy); + + // Position UI elements relative to center (cx, cy are center offsets in logical space) + const float titleY = cy - 160.f; // Title above center + const float textboxY = cy; // Textbox at center + const float startButtonY = cy + 120.f; // Start button below center + const float replayButtonY = cy + 220.f; // Replay button below start button + + // Title text (centered) + auto* title = dynamic_cast(State::GameState::getInstance().addEntity(std::make_unique("Title", titleFont, Object::DEFAULT_TRANSFORM, cx, titleY))); + if (title) title->setText("Game Name"); + + // Name input box (larger and more visible) with a light gray background + auto textboxBg = createSolidColorTexture(gSDLRenderer, 400, 70, 180, 180, 180, 220); // Larger, slightly darker gray + auto* nameBox = dynamic_cast(State::GameState::getInstance().addEntity(std::make_unique("NameBox", textboxBg, nameBoxFont, Object::DEFAULT_TRANSFORM, cx, textboxY))); + if (nameBox) nameBox->setPlaceholder("Enter name..."); + + // Start button (below center) + auto btnTex = std::dynamic_pointer_cast(startButtonFont); + auto* btnEntity = dynamic_cast(State::GameState::getInstance().addEntity(std::make_unique("StartButton", btnTex, Object::DEFAULT_TRANSFORM, reinterpret_cast(&startGameCallback), cx, startButtonY))); + if (btnEntity) btnEntity->setText("Start Game"); + + // Replay button (below start button) + auto replayBtnTex = std::dynamic_pointer_cast(replayButtonFont); + auto* replayBtnEntity = dynamic_cast(State::GameState::getInstance().addEntity(std::make_unique("ReplayButton", replayBtnTex, Object::DEFAULT_TRANSFORM, reinterpret_cast(&replayGameCallback), cx, replayButtonY))); + if (replayBtnEntity) replayBtnEntity->setText("Replay"); window.run(); - + return 0; } \ No newline at end of file diff --git a/src/object/ui/uibutton.cpp b/src/object/ui/uibutton.cpp index b5d4881..5f055eb 100644 --- a/src/object/ui/uibutton.cpp +++ b/src/object/ui/uibutton.cpp @@ -1,4 +1,5 @@ #include +#include namespace Game::Object { UIButton::UIButton(const std::string& name, std::shared_ptr texture, const Transform& transform, void* clickFunction, float x, float y) @@ -19,8 +20,21 @@ namespace Game::Object { float textTop = mTransform.y; float textBottom = mTransform.y + mTex->getHeight() * mTransform.adjustedScaleY(); - if (mouseX >= textLeft && mouseX <= textRight && mouseY >= textTop && mouseY <= textBottom) { - std::dynamic_pointer_cast(mTex)->build({200, 200, 200, 255}, mText); // Darken text when hovered + const bool isInside = mouseX >= textLeft && mouseX <= textRight && mouseY >= textTop && mouseY <= textBottom; + + if (isInside && !mIsHovered) { + mIsHovered = true; + auto fontPtr = std::dynamic_pointer_cast(mTex); + std::string txt = mText; + if (fontPtr) Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({200,200,200,255}, txt); }); + } else if (!isInside && mIsHovered) { + mIsHovered = false; + auto fontPtr = std::dynamic_pointer_cast(mTex); + std::string txt = mText; + if (fontPtr) Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({255,255,255,255}, txt); }); + } + + if (isInside) { if (Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT) && mClickFunction) { using ClickFnType = void(*)(); ClickFnType clickFn = reinterpret_cast(mClickFunction); @@ -32,7 +46,9 @@ namespace Game::Object { void UIButton::setText(const std::string& text) { mText = text; - std::dynamic_pointer_cast(mTex)->build({255, 255, 255, 255}, text); + auto fontPtr = std::dynamic_pointer_cast(mTex); + std::string txt = text; + if (fontPtr) Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({255,255,255,255}, txt); }); } std::string UIButton::getText() const { diff --git a/src/object/ui/uitext.cpp b/src/object/ui/uitext.cpp index 6772cba..cbb687c 100644 --- a/src/object/ui/uitext.cpp +++ b/src/object/ui/uitext.cpp @@ -1,4 +1,5 @@ #include +#include namespace Game::Object { UIText::UIText(const std::string& name, std::shared_ptr font, const Transform& transform, float x, float y) @@ -31,7 +32,15 @@ namespace Game::Object { void UIText::setText(const std::string& text) { mText = text; - std::dynamic_pointer_cast(mTex)->build({255, 255, 255, 255}, text); + // Schedule font texture build on the window thread to avoid + // creating/destroying renderer resources from the game thread. + auto fontPtr = std::dynamic_pointer_cast(mTex); + if (fontPtr) { + std::string t = text; + Window::Window::postToMainThread([fontPtr, t]() mutable { + fontPtr->build({255,255,255,255}, t); + }); + } } std::string UIText::getText() const { diff --git a/src/object/ui/uitextbox.cpp b/src/object/ui/uitextbox.cpp index abb8583..5739737 100644 --- a/src/object/ui/uitextbox.cpp +++ b/src/object/ui/uitextbox.cpp @@ -1,164 +1,278 @@ #include #include -#include +#include namespace Game::Object { - - UITextBox::UITextBox(const std::string& name, std::shared_ptr font, - const Transform& transform, float x, float y, UITextboxConfig config) - : Entity(name, font, transform), mX(x), mY(y), mConfig(config) { } + UITextBox::UITextBox(const std::string& name, std::shared_ptr background, std::shared_ptr font, const Transform& transform, float x, float y) + : Entity(name, background, transform), mBackground(background), mFont(font), mX(x), mY(y) { + if (!mBackground) { + // Fallback to font texture if background is not provided + mBackground = std::dynamic_pointer_cast(mFont); + setTexture(mBackground); + } + } void UITextBox::start() { - // Compute visual box size first (respecting min sizes and scale), then center the box - mBoxWidth = static_cast(mTex ? mTex->getWidth() : 0.f) * mTransform.adjustedScaleX(); - mBoxHeight = static_cast(mTex ? mTex->getHeight() : 0.f) * mTransform.adjustedScaleY(); + // Build initial text texture (placeholder if present) so textbox has a + // visible indicator and stable dimensions for click hit-testing. + const std::string initialDisplay = (mText.empty() && !mPlaceholder.empty()) ? mPlaceholder : (mPasswordMode ? std::string(mText.size(), '*') : mText); + if (mFont && !initialDisplay.empty()) { + auto fontPtr = mFont; + std::string txt = initialDisplay; + Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({255,255,255,255}, txt); }); + mLastRenderedText = initialDisplay; + } - if (mBoxWidth < mConfig.minWidth) mBoxWidth = mConfig.minWidth; - if (mBoxHeight < mConfig.minHeight) mBoxHeight = mConfig.minHeight; + // Center the textbox on the requested position + if (mBackground) { + mTransform.x = mX - mBackground->getWidth() * mTransform.adjustedScaleX() / 2.f; + mTransform.y = mY - mBackground->getHeight() * mTransform.adjustedScaleY() / 2.f; + } + // Ensure cursor index is valid + mCursorIndex = std::min(mCursorIndex, mText.size()); + } - // Center using the computed box dimensions rather than raw texture size so padding/min sizes are respected - mTransform.x = mX - mBoxWidth / 2.f; - mTransform.y = mY - mBoxHeight / 2.f; - - refreshVisualText(); + static char scancodeToChar(SDL_Scancode scancode, bool shift) { + // Basic mapping for letters, digits and common symbols + if (scancode >= SDL_SCANCODE_A && scancode <= SDL_SCANCODE_Z) { + char c = 'a' + (scancode - SDL_SCANCODE_A); + if (shift) c = static_cast(std::toupper(c)); + return c; + } + if (scancode >= SDL_SCANCODE_1 && scancode <= SDL_SCANCODE_0) { + // Note: SDL_SCANCODE_1..SDL_SCANCODE_0 is not contiguous for '0', handle digits separately + } + switch (scancode) { + case SDL_SCANCODE_SPACE: return ' '; + case SDL_SCANCODE_COMMA: return shift ? '<' : ','; + case SDL_SCANCODE_PERIOD: return shift ? '>' : '.'; + case SDL_SCANCODE_MINUS: return shift ? '_' : '-'; + case SDL_SCANCODE_EQUALS: return shift ? '+' : '='; + case SDL_SCANCODE_SEMICOLON: return shift ? ':' : ';'; + case SDL_SCANCODE_APOSTROPHE: return shift ? '"' : '\''; + case SDL_SCANCODE_SLASH: return shift ? '?' : '/'; + case SDL_SCANCODE_BACKSLASH: return shift ? '|' : '\\'; + case SDL_SCANCODE_LEFTBRACKET: return shift ? '{' : '['; + case SDL_SCANCODE_RIGHTBRACKET: return shift ? '}' : ']'; + case SDL_SCANCODE_GRAVE: return shift ? '~' : '`'; + default: break; + } + // Digits + if (scancode >= SDL_SCANCODE_1 && scancode <= SDL_SCANCODE_9) { + const char numsShift[] = {')','!','@','#','$','%','^','&','*'}; + int idx = scancode - SDL_SCANCODE_1; + return shift ? numsShift[idx] : ('1' + idx); + } + if (scancode == SDL_SCANCODE_0) return shift ? ')' : '0'; + return 0; } void UITextBox::update(float deltaTime) { if (!mIsActive) return; - if (Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT)) { - mIsFocused = isMouseInsideBox(); - mNeedsTextRefresh = true; + // Cursor blink + mCursorBlinkTimer += deltaTime; + if (mCursorBlinkTimer >= 0.5f) { + mShowCursor = !mShowCursor; + mCursorBlinkTimer = 0.f; + } + + float mouseX = Input::getMouseX(); + float mouseY = Input::getMouseY(); + float left = mTransform.x; + float right = mTransform.x + (mBackground ? mBackground->getWidth() * mTransform.adjustedScaleX() : 0.f); + float top = mTransform.y; + float bottom = mTransform.y + (mBackground ? mBackground->getHeight() * mTransform.adjustedScaleY() : 0.f); + + bool inside = (mouseX >= left && mouseX <= right && mouseY >= top && mouseY <= bottom); + if (inside && Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT)) { + mIsFocused = true; + // Enable SDL text input on main thread (requires SDL_Window pointer) + Window::Window::postToMainThread([]() { + SDL_Window* win = Window::Window::getSDLWindowBackend(); + if (win) SDL_StartTextInput(win); + }); + if (mOnFocus) { + using FnType = void(*)(); + FnType fn = reinterpret_cast(mOnFocus); + fn(); + } + } else if (Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT) && !inside) { + mIsFocused = false; + // Disable SDL text input on main thread + Window::Window::postToMainThread([]() { + SDL_Window* win = Window::Window::getSDLWindowBackend(); + if (win) SDL_StopTextInput(win); + }); } if (mIsFocused) { - // Cursor blink - mCursorTimer += deltaTime; - if (mCursorTimer >= mConfig.cursorBlinkRate) { - mCursorTimer = 0.f; - mCursorVisible = !mCursorVisible; - mNeedsTextRefresh = true; + // Handle special keys + if (Input::isKeyJustPressed(SDL_SCANCODE_BACKSPACE)) { + if (mCursorIndex > 0 && !mText.empty()) { + mText.erase(mCursorIndex - 1, 1); + mCursorIndex = std::max(0, mCursorIndex - 1); + } + } + if (Input::isKeyJustPressed(SDL_SCANCODE_LEFT)) { + if (mCursorIndex > 0) mCursorIndex--; + } + if (Input::isKeyJustPressed(SDL_SCANCODE_RIGHT)) { + if (mCursorIndex < mText.size()) mCursorIndex++; } - if (Input::isKeyJustPressed(SDL_SCANCODE_BACKSPACE) && !mText.empty()) { - mText.pop_back(); - mNeedsTextRefresh = true; - } + // Character insertion from scancodes - if (Input::isKeyJustPressed(SDL_SCANCODE_RETURN) || Input::isKeyJustPressed(SDL_SCANCODE_KP_ENTER)) { - mIsFocused = false; - mCursorTimer = 0.f; - mCursorVisible = true; - mNeedsTextRefresh = true; - } + bool shift = Input::isKeyPressed(SDL_SCANCODE_LSHIFT) || Input::isKeyPressed(SDL_SCANCODE_RSHIFT); - for (int key = 0; key < SDL_SCANCODE_COUNT; ++key) { - SDL_Scancode scancode = static_cast(key); - if (!Input::isKeyJustPressed(scancode)) continue; - - SDL_Keycode keycode = SDL_GetKeyFromScancode(scancode, SDL_GetModState(), true); - if (keycode >= 32 && keycode <= 126) { - if (mConfig.maxLength == 0 || static_cast(mText.size()) < mConfig.maxLength) { - mText.push_back(static_cast(keycode)); - mNeedsTextRefresh = true; + // Letters A-Z + for (int sc = SDL_SCANCODE_A; sc <= SDL_SCANCODE_Z; ++sc) { + if (Input::isKeyJustPressed(static_cast(sc))) { + char c = scancodeToChar(static_cast(sc), shift); + if (c && mText.size() < mMaxLength) { + mText.insert(mCursorIndex, 1, c); + mCursorIndex++; } } } + + // Digits 1-9 and 0 + for (int sc = SDL_SCANCODE_1; sc <= SDL_SCANCODE_9; ++sc) { + if (Input::isKeyJustPressed(static_cast(sc))) { + char c = scancodeToChar(static_cast(sc), shift); + if (c && mText.size() < mMaxLength) { + mText.insert(mCursorIndex, 1, c); + mCursorIndex++; + } + } + } + if (Input::isKeyJustPressed(SDL_SCANCODE_0)) { + char c = scancodeToChar(SDL_SCANCODE_0, shift); + if (c && mText.size() < mMaxLength) { + mText.insert(mCursorIndex, 1, c); + mCursorIndex++; + } + } + + // Space and a few other keys + if (Input::isKeyJustPressed(SDL_SCANCODE_SPACE)) { + if (mText.size() < mMaxLength) { + mText.insert(mCursorIndex, 1, ' '); + mCursorIndex++; + } + } } - if (mNeedsTextRefresh) { - refreshVisualText(); + // Update rendered text only when needed + std::string display = mPasswordMode ? std::string(mText.size(), '*') : mText; + if (display.empty() && !mIsFocused && !mPlaceholder.empty()) { + display = mPlaceholder; + } + if (display != mLastRenderedText) { + if (mFont) { + auto fontPtr = mFont; + std::string txt = display; + Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({255,255,255,255}, txt); }); + mLastRenderedText = display; + + if (mBackground) { + mTransform.x = mX - mBackground->getWidth() * mTransform.adjustedScaleX() / 2.f; + mTransform.y = mY - mBackground->getHeight() * mTransform.adjustedScaleY() / 2.f; + } + } + } + + // When textbox is focused and display is empty, reserve placeholder width by rendering transparent placeholder + // This prevents the textbox from shifting when placeholder disappears on focus + if (mIsFocused && display.empty() && !mPlaceholder.empty() && mFont && mReservedPlaceholderWidth == 0.f) { + auto fontPtr = mFont; + std::string placeholder = mPlaceholder; + // Post task to build placeholder with full transparency (alpha=0) to reserve width + Window::Window::postToMainThread([fontPtr, placeholder, this]() { + fontPtr->build({255,255,255,0}, placeholder); // alpha=0 for transparency + // Store the width of the placeholder texture for layout stability + float w = static_cast(fontPtr->getWidth()); + { + std::scoped_lock lock(mRenderMutex); + mReservedPlaceholderWidth = w; + } + }); } } void UITextBox::render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) { - if (!mIsVisible) return; + if (!mIsActive || !mIsVisible) return; - SDL_Renderer* r = renderer->getSDLRenderer(); + // Render background texture if available + if (mBackground) { + SDL_FRect dst; + SDL_GetTextureSize(mBackground->getSDLTexture(), &dst.w, &dst.h); + dst.w *= mTransform.scaleX * UNIVERSAL_SCALE_COEFFICIENT; + dst.h *= mTransform.scaleY * UNIVERSAL_SCALE_COEFFICIENT; + dst.x = mTransform.x - config.camX + config.screenW / 2.f; + dst.y = mTransform.y - config.camY + config.screenH / 2.f; - const float bx = mTransform.x - mConfig.paddingX - config.camX + config.screenW / 2.f; - const float by = mTransform.y - mConfig.paddingY - config.camY + config.screenH / 2.f; - const float bw = mBoxWidth + 2.f * mConfig.paddingX; - const float bh = mBoxHeight + 2.f * mConfig.paddingY; - const float t = mConfig.borderThickness; + SDL_FPoint center; + center.x = dst.w / 2.f; + center.y = dst.h / 2.f; - // Background - SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); - const SDL_Color& bg = mConfig.bgColor; - SDL_SetRenderDrawColor(r, bg.r, bg.g, bg.b, bg.a); - const SDL_FRect bgRect = {bx, by, bw, bh}; - SDL_RenderFillRect(r, &bgRect); + SDL_RenderTextureRotated( + renderer->getSDLRenderer(), + mBackground->getSDLTexture(), + nullptr, + &dst, + mTransform.rotation, + ¢er, + mIsFlipped ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE + ); + } - // Border (4 filled rects for configurable thickness) - const SDL_Color& bc = mIsFocused ? mConfig.focusedBorderColor : mConfig.borderColor; - SDL_SetRenderDrawColor(r, bc.r, bc.g, bc.b, bc.a); - const SDL_FRect borders[4] = { - {bx, by, bw, t }, // top - {bx, by + bh - t, bw, t }, // bottom - {bx, by, t, bh}, // left - {bx + bw - t, by, t, bh}, // right - }; - SDL_RenderFillRects(r, borders, 4); + // Render font texture (text) on top of background + if (mFont && mFont->getSDLTexture()) { + SDL_FRect dst; + SDL_GetTextureSize(mFont->getSDLTexture(), &dst.w, &dst.h); + dst.w *= mTransform.scaleX * UNIVERSAL_SCALE_COEFFICIENT; + dst.h *= mTransform.scaleY * UNIVERSAL_SCALE_COEFFICIENT; + dst.x = mTransform.x - config.camX + config.screenW / 2.f; + dst.y = mTransform.y - config.camY + config.screenH / 2.f; - // Text (or placeholder) via base render - Entity::render(renderer, config); + SDL_FPoint center; + center.x = dst.w / 2.f; + center.y = dst.h / 2.f; + + SDL_RenderTextureRotated( + renderer->getSDLRenderer(), + mFont->getSDLTexture(), + nullptr, + &dst, + mTransform.rotation, + ¢er, + mIsFlipped ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE + ); + } } void UITextBox::setText(const std::string& text) { - mText = text; - mNeedsTextRefresh = true; - refreshVisualText(); + mText = text.substr(0, mMaxLength); + mCursorIndex = std::min(mText.size(), mCursorIndex); + mLastRenderedText.clear(); // force rebuild } - std::string UITextBox::getText() const { return mText; } - std::string UITextBox::getValue() const { return mText; } - bool UITextBox::isFocused() const { return mIsFocused; } - - bool UITextBox::isMouseInsideBox() const { - // Get screen-space mouse coordinates - const float screenMouseX = Input::getMouseX(); - const float screenMouseY = Input::getMouseY(); - - // Get window dimensions - int windowW = 0, windowH = 0; - SDL_GetWindowSizeInPixels(SDL_GetMouseFocus(), &windowW, &windowH); - - // Get camera position - float camX = 0.f, camY = 0.f; - Object::Camera::getInstance().getPosition(camX, camY); - - // Convert screen coordinates to world coordinates - const float worldMouseX = screenMouseX - windowW / 2.f + camX; - const float worldMouseY = screenMouseY - windowH / 2.f + camY; - - // Check bounds in world space - const float left = mTransform.x - mConfig.paddingX; - const float right = left + mBoxWidth + 2.f * mConfig.paddingX; - const float top = mTransform.y - mConfig.paddingY; - const float bottom = top + mBoxHeight + 2.f * mConfig.paddingY; - - return worldMouseX >= left && worldMouseX <= right && worldMouseY >= top && worldMouseY <= bottom; + void UITextBox::insertText(const std::string& utf8) { + // Insert UTF-8 text at cursor position, respecting max length + if (utf8.empty() || mText.size() >= mMaxLength) return; + + size_t availableSpace = mMaxLength - mText.size(); + size_t insertLen = std::min(utf8.size(), availableSpace); + mText.insert(mCursorIndex, utf8.substr(0, insertLen)); + mCursorIndex += insertLen; + mLastRenderedText.clear(); // force rebuild on next update + mShowCursor = true; // Show cursor immediately when text inserted + mCursorBlinkTimer = 0.f; // Reset blink timer } - void UITextBox::refreshVisualText() { - const bool showPlaceholder = mText.empty() && !mIsFocused && !mConfig.placeholder.empty(); - - SDL_Color color; - std::string rendered; - - if (showPlaceholder) { - color = mConfig.placeholderColor; - rendered = mConfig.placeholder; - } else { - color = mIsFocused ? mConfig.focusedTextColor : mConfig.textColor; - rendered = mText.empty() ? " " : mText; - if (mIsFocused && mCursorVisible) { - rendered += "_"; - } - } - - std::dynamic_pointer_cast(mTex)->build(color, rendered); - mNeedsTextRefresh = false; + std::string UITextBox::getText() const { + return mText; } } diff --git a/src/renderer/font.cpp b/src/renderer/font.cpp index 8dbbf7e..0052512 100644 --- a/src/renderer/font.cpp +++ b/src/renderer/font.cpp @@ -54,6 +54,12 @@ namespace Game::Renderer { return; } + // Text surfaces contain transparency around glyphs. If blending is not + // enabled, transparent pixels can appear as opaque black boxes. + if (!SDL_SetTextureBlendMode(mTex, SDL_BLENDMODE_BLEND)) { + WARN("Failed to set blend mode to BLEND for font '" << mId << "': " << SDL_GetError()); + } + // Fonts look better with linear filtering than with nearest-neighbor scaling. if (!SDL_SetTextureScaleMode(mTex, SDL_SCALEMODE_LINEAR)) { WARN("Failed to set texture scale mode to LINEAR for font '" << mId << "': " << SDL_GetError()); diff --git a/src/window/window.cpp b/src/window/window.cpp index fde5809..8cbe3eb 100644 --- a/src/window/window.cpp +++ b/src/window/window.cpp @@ -2,9 +2,18 @@ #include #include +#include namespace Game::Window { std::mutex Window::sMutex; + // Tasks posted from other threads to run on the window thread + std::mutex sTasksMutex; + std::vector> sPostedTasks; + + void Window::postToMainThread(std::function fn) { + std::scoped_lock lock(sTasksMutex); + sPostedTasks.push_back(std::move(fn)); + } Window::Window() : mWindow(nullptr), mRenderer(), mGameManager(), mRunning(false) { } @@ -88,9 +97,34 @@ namespace Game::Window { SDL_Event event; while (mRunning) { const auto frameStart = std::chrono::steady_clock::now(); + // Execute any tasks posted to the main/window thread + std::vector> tasks; + { + std::scoped_lock lock(sTasksMutex); + tasks.swap(sPostedTasks); + } + for (auto& t : tasks) { + try { t(); } catch (...) { WARN("Exception in posted main-thread task"); } + } SDL_PumpEvents(); while (SDL_PollEvent(&event)) { + // Forward SDL text input events to the Input queue + if (event.type == SDL_EVENT_TEXT_INPUT) { + if (event.text.text && event.text.text[0]) { + Game::Input::pushText(std::string(event.text.text)); + } + continue; + } + + // Run custom callbacks on the window/event thread. + if (event.type >= SDL_EVENT_USER && event.user.data1) { + using UserEventFn = void(*)(); + UserEventFn fn = reinterpret_cast(event.user.data1); + fn(); + continue; + } + if (event.type == SDL_EVENT_QUIT) { mRunning = false; }