Compare commits

...

2 Commits

Author SHA1 Message Date
6534996a52 replay 2026-05-19 22:49:57 +02:00
0b45643ef2 menu 2026-05-19 22:35:10 +02:00
21 changed files with 833 additions and 236 deletions

View File

@@ -4,6 +4,8 @@ project(Letnik3Zadnja)
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
option(FETCH "Fetch SDL dependencies instead of using system-installed packages" OFF)
# Compile flags # Compile flags
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Werror -Wno-unused-parameter -O2") 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) 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 to download SDL libraries
include(FetchContent) include(FetchContent)

View File

@@ -12,6 +12,7 @@ GAME_ENTITY(Player)
void setGroundTexture(std::shared_ptr<Game::Renderer::Texture> tex); void setGroundTexture(std::shared_ptr<Game::Renderer::Texture> tex);
void respawnRandomSea(float landBoundaryX); void respawnRandomSea(float landBoundaryX);
bool isShipMode() const { return mIsShipMode; } bool isShipMode() const { return mIsShipMode; }
void setShipMode(bool isShip) { mIsShipMode = isShip; } // Set form state for replay
void onCollisionEnter(Object::Entity* other) override; void onCollisionEnter(Object::Entity* other) override;
private: private:
Object::Sound mSound; Object::Sound mSound;

View File

@@ -1,25 +0,0 @@
#pragma once
#include <object/ui/uitextbox.hpp>
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);
}
};
}

View File

@@ -19,7 +19,8 @@ namespace Game {
enum class GameStateEnum { enum class GameStateEnum {
RUNNING, RUNNING,
PAUSED, PAUSED,
STOPPED STOPPED,
REPLAY
}; };
enum class SharedDataType { enum class SharedDataType {
@@ -59,7 +60,14 @@ namespace Game {
static void processPendingEntityRemovals(); static void processPendingEntityRemovals();
static void pushPlayerPosition(Object::Transform transform) { mPlayerTransformHistory.push_back(transform); } static void pushPlayerPosition(Object::Transform transform) { mPlayerTransformHistory.push_back(transform); }
static void pushPlayerFormState(bool isShipMode) { mPlayerFormHistory.push_back(isShipMode); }
static void getPlayerPositionHistory(std::vector<Object::Transform>& outHistory) { outHistory = mPlayerTransformHistory; } static void getPlayerPositionHistory(std::vector<Object::Transform>& outHistory) { outHistory = mPlayerTransformHistory; }
static void getPlayerFormHistory(std::vector<bool>& outHistory) { outHistory = mPlayerFormHistory; }
// Replay mode API
static bool initReplayMode();
static bool playReplayFrame();
static void stopReplayMode();
private: private:
int mTargetUpdatesPerSecond = TARGET_UPDATE_RATE; int mTargetUpdatesPerSecond = TARGET_UPDATE_RATE;
@@ -69,8 +77,17 @@ namespace Game {
static std::unordered_map<std::string, float> mSharedFloats; static std::unordered_map<std::string, float> mSharedFloats;
static std::unordered_map<std::string, bool> mSharedBools; static std::unordered_map<std::string, bool> mSharedBools;
static std::vector<Object::Transform> mPlayerTransformHistory; static std::vector<Object::Transform> mPlayerTransformHistory;
static std::vector<bool> mPlayerFormHistory;
static GameStateEnum mCurrentGameState; static GameStateEnum mCurrentGameState;
float mLastDelta = 0.f; float mLastDelta = 0.f;
// Replay data
struct ReplayFrame {
float x, y, rotation, scaleX, scaleY;
bool isShipMode;
};
static std::vector<ReplayFrame> mReplayFrames;
static size_t mCurrentReplayFrame;
}; };
template<typename T> template<typename T>

View File

@@ -1,6 +1,10 @@
#pragma once #pragma once
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <vector>
#include <queue>
#include <mutex>
#include <string>
namespace Game { namespace Game {
class Input { class Input {
@@ -16,13 +20,23 @@ namespace Game {
static bool isMouseButtonJustReleased(Uint8 button); static bool isMouseButtonJustReleased(Uint8 button);
static float getMouseX(); static float getMouseX();
static float getMouseY(); 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<std::string>& out);
private: private:
static const bool* mCurrentKeyStates; static const bool* mCurrentKeyStates;
static const bool* mPreviousKeyStates; static std::vector<Uint8> mPreviousKeyStates;
static int mNumKeys; static int mNumKeys;
static int mPrevNumKeys;
static SDL_MouseButtonFlags mCurrentMouseButtonStates; static SDL_MouseButtonFlags mCurrentMouseButtonStates;
static SDL_MouseButtonFlags mPreviousMouseButtonStates; static SDL_MouseButtonFlags mPreviousMouseButtonStates;
static float mMouseX; static float mMouseX;
static float mMouseY; 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<std::string> mPendingText;
}; };
} }

View File

@@ -25,5 +25,6 @@ namespace Game::Object {
void* mClickFunction = nullptr; void* mClickFunction = nullptr;
float mX, mY; float mX, mY;
std::string mText; std::string mText;
bool mIsHovered = false;
}; };
} }

View File

@@ -4,62 +4,54 @@
#include <renderer/font.hpp> #include <renderer/font.hpp>
#include <renderer/texture.hpp> #include <renderer/texture.hpp>
#include <utility> #include <utility>
#include <string>
#include <game/input.hpp> #include <game/input.hpp>
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <mutex>
namespace Game::Object { 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 { class UITextBox : public Entity {
public: public:
UITextBox(const std::string& name, std::shared_ptr<Renderer::Font> font, UITextBox(const std::string& name, std::shared_ptr<Renderer::Texture> background, std::shared_ptr<Renderer::Font> font, const Transform& transform, float x = 0.f, float y = 0.f);
const Transform& transform,
float x = 0.f, float y = 0.f,
UITextboxConfig config = {});
~UITextBox() override = default; ~UITextBox() override = default;
void start() override; void start() override;
void update(float deltaTime) 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 render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) override;
void setText(const std::string& text); void setText(const std::string& text);
std::string getText() const; std::string getText() const;
std::string getValue() const; void setPlaceholder(const std::string& placeholder) { mPlaceholder = placeholder; mLastRenderedText.clear(); }
bool isFocused() const;
// 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; } void setPosition(float x, float y) { mX = x; mY = y; }
std::pair<float, float> getPosition() const { return {mX, mY}; } std::pair<float, float> getPosition() const { return {mX, mY}; }
bool isFocused() const { return mIsFocused; }
private: private:
bool isMouseInsideBox() const; std::shared_ptr<Renderer::Texture> mBackground; // Background texture for the textbox
void refreshVisualText(); std::shared_ptr<Renderer::Font> mFont; // Font used to render text
float mX, mY; float mX, mY;
std::string mText; std::string mText;
size_t mCursorIndex = 0;
float mCursorBlinkTimer = 0.f;
bool mShowCursor = true;
size_t mMaxLength = 1024;
bool mPasswordMode = false;
bool mIsFocused = false; bool mIsFocused = false;
float mBoxWidth = 0.f; void* mOnFocus = nullptr; // optional function pointer
float mBoxHeight = 0.f; std::string mLastRenderedText; // to avoid rebuilding font unnecessarily
bool mNeedsTextRefresh = true; std::string mPlaceholder = "";
UITextboxConfig mConfig; float mReservedPlaceholderWidth = 0.f; // Reserved pixel width for placeholder to avoid layout shifts
std::mutex mRenderMutex; // Protects mLastRenderedText and mReservedPlaceholderWidth from main-thread updates
float mCursorTimer = 0.f;
bool mCursorVisible = true;
}; };
} }

View File

@@ -17,8 +17,8 @@ namespace Game::Renderer {
// Build the texture for the font; Call getSDLTexture() afterwards // Build the texture for the font; Call getSDLTexture() afterwards
void build(SDL_Color color, std::string text); void build(SDL_Color color, std::string text);
// Rebuild GPU-backed texture after a renderer/device reset // Rebuild GPU-backed texture after a renderer/device reset
bool reload(SDL_Renderer* renderer); bool reload(SDL_Renderer* renderer);
SDL_Texture* getSDLTexture(); SDL_Texture* getSDLTexture();
std::string getId(); std::string getId();

View File

@@ -21,6 +21,7 @@ namespace Game::Renderer {
float getHeight(); float getHeight();
bool isTiled() { return mIsTiled; } bool isTiled() { return mIsTiled; }
void setTiled(bool tiled) { mIsTiled = tiled; } void setTiled(bool tiled) { mIsTiled = tiled; }
void setSDLTexture(SDL_Texture* tex) { mTex = tex; }
// Reload GPU-backed texture using a new renderer after device reset // Reload GPU-backed texture using a new renderer after device reset
virtual bool reload(SDL_Renderer* renderer); virtual bool reload(SDL_Renderer* renderer);

View File

@@ -28,6 +28,8 @@ namespace Game::Window {
static SDL_Window* getSDLWindowBackend() { std::scoped_lock lock(sMutex); return sWindowBackend; } static SDL_Window* getSDLWindowBackend() { std::scoped_lock lock(sMutex); return sWindowBackend; }
Renderer::Renderer* getRenderer() { std::scoped_lock lock(mMutex); return &mRenderer; } 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<void()> fn);
private: private:
mutable std::mutex mMutex; mutable std::mutex mMutex;

View File

@@ -36,17 +36,23 @@ namespace {
file << "Točke: " << score << "\n"; file << "Točke: " << score << "\n";
file << "Datum: " << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S") << "\n"; file << "Datum: " << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S") << "\n";
// Replay system // Replay system with form state
std::vector<Game::Object::Transform> playerHistory; std::vector<Game::Object::Transform> playerHistory;
std::vector<bool> formHistory;
Game::GameManager::getPlayerPositionHistory(playerHistory); Game::GameManager::getPlayerPositionHistory(playerHistory);
Game::GameManager::getPlayerFormHistory(formHistory);
std::ofstream replayFile("replay.txt", std::ios::trunc); std::ofstream replayFile("replay.txt", std::ios::trunc);
if (!replayFile.is_open()) { if (!replayFile.is_open()) {
WARN("Neuspešno odpiranje replay.txt za pisanje"); WARN("Neuspešno odpiranje replay.txt za pisanje");
return; return;
} }
for (const auto& transform : playerHistory) { for (size_t i = 0; i < playerHistory.size(); ++i) {
replayFile << transform.x << " " << transform.y << " " << transform.rotation << " " << transform.scaleX << " " << transform.scaleY << "\n"; 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"); LOG("Zapis končne statistike in replaya igre dokončan");
@@ -92,7 +98,11 @@ namespace Game::AGame {
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f; mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
LOG("W: " << mW << " H: " << mH); LOG("W: " << mW << " H: " << mH);
spawnLevel(1);
// Only spawn level entities during normal gameplay, not during replay
if (GameManager::getCurrentGameState() != GameStateEnum::REPLAY) {
spawnLevel(1);
}
} }
void Background::render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) { void Background::render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) {

View File

@@ -33,13 +33,19 @@ namespace Game::AGame {
}; };
if (GameManager::getSharedData<bool>("gameLost")) { if (GameManager::getSharedData<bool>("gameLost")) {
setText("Umrl si!"); if (getText() != "Umrl si!") {
const std::string s = "Umrl si!";
Window::Window::postToMainThread([this, s]() { setText(s); });
}
anchorTopRight(); anchorTopRight();
return; return;
} }
if (GameManager::getSharedData<bool>("gameWon")) { if (GameManager::getSharedData<bool>("gameWon")) {
setText("Zmagal si!"); if (getText() != "Zmagal si!") {
const std::string s = "Zmagal si!";
Window::Window::postToMainThread([this, s]() { setText(s); });
}
anchorTopRight(); anchorTopRight();
return; return;
} }
@@ -53,7 +59,10 @@ namespace Game::AGame {
<< " | Smeti " << GameManager::getSharedData<int>("trashActiveCount") << " | Smeti " << GameManager::getSharedData<int>("trashActiveCount")
<< " | Sovražniki " << GameManager::getSharedData<int>("enemyActiveCount"); << " | Sovražniki " << GameManager::getSharedData<int>("enemyActiveCount");
setText(stream.str()); const std::string newHudText = stream.str();
if (getText() != newHudText) {
Window::Window::postToMainThread([this, newHudText]() { setText(newHudText); });
}
anchorTopRight(); anchorTopRight();
} }
} }

View File

@@ -162,8 +162,9 @@ namespace Game::AGame {
setTexture(mGroundTex); setTexture(mGroundTex);
} }
// Push replay // Push replay (position and form state)
GameManager::pushPlayerPosition(mTransform); GameManager::pushPlayerPosition(mTransform);
GameManager::pushPlayerFormState(mIsShipMode);
} }
void Player::onCollisionEnter(Object::Entity* other) { void Player::onCollisionEnter(Object::Entity* other) {

View File

@@ -1,8 +1,14 @@
#include <game/gamemanager.hpp> #include <game/gamemanager.hpp>
#include <algorithm> #include <algorithm>
#include <fstream>
#include <sstream>
#include <game/agame/player.hpp>
#include <state/gamestate.hpp>
namespace Game { namespace Game {
GameStateEnum GameManager::mCurrentGameState = GameStateEnum::RUNNING; GameStateEnum GameManager::mCurrentGameState = GameStateEnum::RUNNING;
std::vector<GameManager::ReplayFrame> GameManager::mReplayFrames;
size_t GameManager::mCurrentReplayFrame = 0;
void GameManager::run(std::stop_token stopToken) { void GameManager::run(std::stop_token stopToken) {
using namespace std::chrono_literals; using namespace std::chrono_literals;
@@ -27,19 +33,30 @@ namespace Game {
try { try {
Input::update(); // Update input states at the start of each frame 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;
}
// Update components first // Handle REPLAY state
entity->updateComponents(seconds); if (mCurrentGameState == GameStateEnum::REPLAY) {
if (!entity->isActive()) { if (!playReplayFrame()) {
continue; // No more frames - end replay and pause
stopReplayMode();
mCurrentGameState = GameStateEnum::PAUSED;
LOG("Replay finished - pausing");
} }
} else {
auto entities = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* entity : entities) {
if (!entity || !entity->isActive()) {
continue;
}
entity->update(seconds); // Update components first
entity->updateComponents(seconds);
if (!entity->isActive()) {
continue;
}
entity->update(seconds);
}
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
ERROR("Exception in GameManager thread: " << e.what()); ERROR("Exception in GameManager thread: " << e.what());
@@ -68,6 +85,7 @@ namespace Game {
std::unordered_map<std::string, float> GameManager::mSharedFloats; std::unordered_map<std::string, float> GameManager::mSharedFloats;
std::unordered_map<std::string, bool> GameManager::mSharedBools; std::unordered_map<std::string, bool> GameManager::mSharedBools;
std::vector<Object::Transform> GameManager::mPlayerTransformHistory; std::vector<Object::Transform> GameManager::mPlayerTransformHistory;
std::vector<bool> GameManager::mPlayerFormHistory;
void GameManager::removeSharedData(const std::string& key, SharedDataType type) { void GameManager::removeSharedData(const std::string& key, SharedDataType type) {
if (type == SharedDataType::STRING) { if (type == SharedDataType::STRING) {
@@ -85,4 +103,79 @@ namespace Game {
State::GameState::getInstance().processPendingRemovals(); State::GameState::getInstance().processPendingRemovals();
} }
bool GameManager::initReplayMode() {
// Read and parse replay.txt (supports both 5-column legacy and 6-column new format)
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 = 0;
// Try to parse 6 columns first (new format with form state)
if (iss >> frame.x >> frame.y >> frame.rotation >> frame.scaleX >> frame.scaleY >> isShip) {
frame.isShipMode = (isShip != 0);
mReplayFrames.push_back(frame);
} else {
// Reset stream and try 5-column legacy format
iss.clear();
iss.seekg(0);
if (iss >> frame.x >> frame.y >> frame.rotation >> frame.scaleX >> frame.scaleY) {
frame.isShipMode = false; // Default to ground mode for legacy replays
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()) {
LOG("Replay complete: played " << mCurrentReplayFrame << " frames");
return false; // Replay finished
}
const ReplayFrame& frame = mReplayFrames[mCurrentReplayFrame];
// Apply frame to player
auto* player = dynamic_cast<AGame::Player*>(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);
// Log every 100 frames to track progress
if (mCurrentReplayFrame % 100 == 0) {
LOG("Playing replay frame " << mCurrentReplayFrame << "/" << mReplayFrames.size());
}
}
mCurrentReplayFrame++;
return mCurrentReplayFrame < mReplayFrames.size(); // Return true if more frames exist
}
void GameManager::stopReplayMode() {
mReplayFrames.clear();
mCurrentReplayFrame = 0;
mCurrentGameState = GameStateEnum::STOPPED;
}
} }

View File

@@ -1,35 +1,103 @@
#include <game/input.hpp> #include <game/input.hpp>
#include <window/window.hpp>
#include <object/ui/uitextbox.hpp>
#include <state/gamestate.hpp>
namespace Game { namespace Game {
const bool* Input::mCurrentKeyStates = nullptr; const bool* Input::mCurrentKeyStates = nullptr;
const bool* Input::mPreviousKeyStates = nullptr; std::vector<Uint8> Input::mPreviousKeyStates = {};
int Input::mNumKeys = 0; int Input::mNumKeys = 0;
int Input::mPrevNumKeys = 0;
SDL_MouseButtonFlags Input::mCurrentMouseButtonStates = 0; SDL_MouseButtonFlags Input::mCurrentMouseButtonStates = 0;
SDL_MouseButtonFlags Input::mPreviousMouseButtonStates = 0; SDL_MouseButtonFlags Input::mPreviousMouseButtonStates = 0;
float Input::mMouseX = 0.0f; float Input::mMouseX = 0.0f;
float Input::mMouseY = 0.0f; float Input::mMouseY = 0.0f;
std::mutex Input::mTextMutex;
std::vector<std::string> Input::mPendingText;
void Input::update() { 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); mCurrentKeyStates = SDL_GetKeyboardState(&mNumKeys);
mPreviousMouseButtonStates = mCurrentMouseButtonStates; 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<int>(LOGICAL_WIDTH);
int windowPixelsH = static_cast<int>(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<float>(displayMode->w);
displayH = static_cast<float>(displayMode->h);
} else if (windowPixelsW > 0 && windowPixelsH > 0) {
displayW = static_cast<float>(windowPixelsW);
displayH = static_cast<float>(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<std::string> texts;
consumeText(texts);
if (!texts.empty()) {
auto entities = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* e : entities) {
if (!e) continue;
auto* tb = dynamic_cast<Game::Object::UITextBox*>(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) { bool Input::isKeyPressed(SDL_Scancode key) {
if (key < 0 || key >= mNumKeys) return false; if (key < 0 || key >= mNumKeys) return false;
return mCurrentKeyStates[key]; return mCurrentKeyStates && mCurrentKeyStates[key];
} }
bool Input::isKeyJustPressed(SDL_Scancode key) { bool Input::isKeyJustPressed(SDL_Scancode key) {
if (key < 0 || key >= mNumKeys) return false; if (key < 0 || key >= mNumKeys) return false;
return mCurrentKeyStates[key] && (!mPreviousKeyStates || !mPreviousKeyStates[key]); bool cur = mCurrentKeyStates && mCurrentKeyStates[key];
bool prev = (key < static_cast<int>(mPreviousKeyStates.size())) ? static_cast<bool>(mPreviousKeyStates[key]) : false;
return cur && !prev;
} }
bool Input::isKeyJustReleased(SDL_Scancode key) { bool Input::isKeyJustReleased(SDL_Scancode key) {
if (key < 0 || key >= mNumKeys) return false; if (key < 0 || key >= mNumKeys) return false;
return (!mCurrentKeyStates[key]) && mPreviousKeyStates && mPreviousKeyStates[key]; bool cur = mCurrentKeyStates && mCurrentKeyStates[key];
bool prev = (key < static_cast<int>(mPreviousKeyStates.size())) ? static_cast<bool>(mPreviousKeyStates[key]) : false;
return !cur && prev;
} }
bool Input::isMouseButtonPressed(Uint8 button) { bool Input::isMouseButtonPressed(Uint8 button) {
@@ -51,4 +119,14 @@ namespace Game {
float Input::getMouseY() { float Input::getMouseY() {
return mMouseY; return mMouseY;
} }
void Input::pushText(const std::string& utf8) {
std::scoped_lock lock(mTextMutex);
mPendingText.push_back(utf8);
}
void Input::consumeText(std::vector<std::string>& out) {
std::scoped_lock lock(mTextMutex);
out.swap(mPendingText);
}
} }

View File

@@ -11,44 +11,266 @@
#include <renderer/texture.hpp> #include <renderer/texture.hpp>
#include <renderer/font.hpp> #include <renderer/font.hpp>
#include <object/ui/uitextbox.hpp> #include <object/ui/uitextbox.hpp>
#include <game/agame/sampletextbox.hpp> #include <object/ui/uibutton.hpp>
#include <object/ui/uitext.hpp>
#include <object/components/boxcollider.hpp> #include <object/components/boxcollider.hpp>
#include <atomic>
using namespace Game; using namespace Game;
int main() { // Global SDL renderer pointer used by menu callback to create textures/fonts
PLNIMP("Letnik3Zadnja - Licenca: LGPLv2.1-only, CC BY-SA 4.0"); static SDL_Renderer* gSDLRenderer = nullptr;
// Prompt for player name before initializing the window/engine static Uint32 gStartGameEventType = static_cast<Uint32>(-1);
std::string playerName; static std::atomic_bool gStartGameQueued = false;
std::cout << "Vnesi uporabniško ime (pusti prazno za 'Igralec'): ";
std::getline(std::cin, playerName); namespace {
if (playerName.empty()) playerName = "Igralec"; // Create a solid-color texture (e.g., for textbox backgrounds)
std::shared_ptr<Game::Renderer::Texture> 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<Game::Renderer::Texture>("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<Game::Object::UITextBox*>(State::GameState::getInstance().getEntityByName("NameBox"));
std::string playerName = "Igralec";
if (tbEntity) {
auto txt = tbEntity->getText();
if (!txt.empty()) playerName = txt;
}
Game::GameManager::setSharedData<std::string>("playerName", playerName); Game::GameManager::setSharedData<std::string>("playerName", playerName);
Window::Window window = Window::Window(); // Spawn the main game entities now that we have a player name
window.init(1280, 720, "Game Window"); // Background
State::GameState::getInstance().addEntity(std::make_unique<AGame::Background>("BG", std::make_shared<Game::Renderer::Texture>("../resources/bgtest.png", gSDLRenderer), Object::DEFAULT_TRANSFORM));
State::GameState::getInstance().addEntity(std::make_unique<AGame::Background>("BG", std::make_shared<Game::Renderer::Texture>("../resources/bgtest.png", window.getRenderer()->getSDLRenderer()), Object::DEFAULT_TRANSFORM));
// Player
auto* player = dynamic_cast<AGame::Player*>( auto* player = dynamic_cast<AGame::Player*>(
State::GameState::getInstance().addEntity( State::GameState::getInstance().addEntity(
std::make_unique<AGame::Player>( std::make_unique<AGame::Player>(
"Player", "Player",
std::make_shared<Game::Renderer::Texture>( std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", gSDLRenderer),
"../resources/l3ladja.png",
window.getRenderer()->getSDLRenderer()
),
Object::DEFAULT_TRANSFORM Object::DEFAULT_TRANSFORM
) )
) )
); );
if (player) { if (player) {
player->addComponent<Object::Components::BoxCollider>(); player->addComponent<Object::Components::BoxCollider>();
player->setShipTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", window.getRenderer()->getSDLRenderer())); player->setShipTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", gSDLRenderer));
player->setGroundTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3player.png", window.getRenderer()->getSDLRenderer())); player->setGroundTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3player.png", gSDLRenderer));
} }
State::GameState::getInstance().addEntity(std::make_unique<AGame::HUDText>("HUD", std::make_shared<Game::Renderer::Font>("../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<AGame::HUDText>("HUD", std::make_shared<Game::Renderer::Font>("../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");
deactivateAndQueueRemoval("ReplayButton");
}
static void performReplayTransition() {
// Clear all existing entities to start fresh
auto allEntities = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* entity : allEntities) {
if (entity && entity->getName() != "BG" && entity->getName() != "Player" && entity->getName() != "HUD") {
entity->setActive(false);
State::GameState::getInstance().queueEntityRemoval(entity->getName());
}
}
// Spawn minimal game entities for replay (only Background and Player, no HUD)
State::GameState::getInstance().addEntity(std::make_unique<AGame::Background>("BG", std::make_shared<Game::Renderer::Texture>("../resources/bgtest.png", gSDLRenderer), Object::DEFAULT_TRANSFORM));
auto* player = dynamic_cast<AGame::Player*>(
State::GameState::getInstance().addEntity(
std::make_unique<AGame::Player>(
"Player",
std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", gSDLRenderer),
Object::DEFAULT_TRANSFORM
)
)
);
if (player) {
player->addComponent<Object::Components::BoxCollider>();
player->setShipTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", gSDLRenderer));
player->setGroundTexture(std::make_shared<Game::Renderer::Texture>("../resources/l3player.png", gSDLRenderer));
}
// 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<Uint32>(-1)) {
// Fallback if custom events are unavailable.
performStartGameTransition();
return;
}
SDL_Event event{};
event.type = gStartGameEventType;
event.user.data1 = reinterpret_cast<void*>(&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<Uint32>(-1)) {
// Fallback if custom events are unavailable.
performReplayTransition();
return;
}
SDL_Event event{};
event.type = gStartGameEventType;
event.user.data1 = reinterpret_cast<void*>(&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<Game::Renderer::Font>("../resources/roboto.ttf", gSDLRenderer, 72, "TitleFont");
auto nameBoxFont = std::make_shared<Game::Renderer::Font>("../resources/roboto.ttf", gSDLRenderer, 58, "NameBoxFont");
auto startButtonFont = std::make_shared<Game::Renderer::Font>("../resources/roboto.ttf", gSDLRenderer, 72, "StartButtonFont");
auto replayButtonFont = std::make_shared<Game::Renderer::Font>("../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<int>(LOGICAL_WIDTH);
int windowPixelsH = static_cast<int>(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<float>(displayMode->w);
displayH = static_cast<float>(displayMode->h);
} else if (windowPixelsW > 0 && windowPixelsH > 0) {
displayW = static_cast<float>(windowPixelsW);
displayH = static_cast<float>(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<Game::Object::UIText*>(State::GameState::getInstance().addEntity(std::make_unique<Game::Object::UIText>("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<Game::Object::UITextBox*>(State::GameState::getInstance().addEntity(std::make_unique<Game::Object::UITextBox>("NameBox", textboxBg, nameBoxFont, Object::DEFAULT_TRANSFORM, cx, textboxY)));
if (nameBox) nameBox->setPlaceholder("Enter name...");
// Start button (below center)
auto btnTex = std::dynamic_pointer_cast<Game::Renderer::Texture>(startButtonFont);
auto* btnEntity = dynamic_cast<Game::Object::UIButton*>(State::GameState::getInstance().addEntity(std::make_unique<Game::Object::UIButton>("StartButton", btnTex, Object::DEFAULT_TRANSFORM, reinterpret_cast<void*>(&startGameCallback), cx, startButtonY)));
if (btnEntity) btnEntity->setText("Start Game");
// Replay button (below start button)
auto replayBtnTex = std::dynamic_pointer_cast<Game::Renderer::Texture>(replayButtonFont);
auto* replayBtnEntity = dynamic_cast<Game::Object::UIButton*>(State::GameState::getInstance().addEntity(std::make_unique<Game::Object::UIButton>("ReplayButton", replayBtnTex, Object::DEFAULT_TRANSFORM, reinterpret_cast<void*>(&replayGameCallback), cx, replayButtonY)));
if (replayBtnEntity) replayBtnEntity->setText("Replay");
window.run(); window.run();

View File

@@ -1,4 +1,5 @@
#include <object/ui/uibutton.hpp> #include <object/ui/uibutton.hpp>
#include <window/window.hpp>
namespace Game::Object { namespace Game::Object {
UIButton::UIButton(const std::string& name, std::shared_ptr<Renderer::Texture> texture, const Transform& transform, void* clickFunction, float x, float y) UIButton::UIButton(const std::string& name, std::shared_ptr<Renderer::Texture> texture, const Transform& transform, void* clickFunction, float x, float y)
@@ -19,8 +20,21 @@ namespace Game::Object {
float textTop = mTransform.y; float textTop = mTransform.y;
float textBottom = mTransform.y + mTex->getHeight() * mTransform.adjustedScaleY(); float textBottom = mTransform.y + mTex->getHeight() * mTransform.adjustedScaleY();
if (mouseX >= textLeft && mouseX <= textRight && mouseY >= textTop && mouseY <= textBottom) { const bool isInside = mouseX >= textLeft && mouseX <= textRight && mouseY >= textTop && mouseY <= textBottom;
std::dynamic_pointer_cast<Renderer::Font>(mTex)->build({200, 200, 200, 255}, mText); // Darken text when hovered
if (isInside && !mIsHovered) {
mIsHovered = true;
auto fontPtr = std::dynamic_pointer_cast<Renderer::Font>(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<Renderer::Font>(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) { if (Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT) && mClickFunction) {
using ClickFnType = void(*)(); using ClickFnType = void(*)();
ClickFnType clickFn = reinterpret_cast<ClickFnType>(mClickFunction); ClickFnType clickFn = reinterpret_cast<ClickFnType>(mClickFunction);
@@ -32,7 +46,9 @@ namespace Game::Object {
void UIButton::setText(const std::string& text) { void UIButton::setText(const std::string& text) {
mText = text; mText = text;
std::dynamic_pointer_cast<Renderer::Font>(mTex)->build({255, 255, 255, 255}, text); auto fontPtr = std::dynamic_pointer_cast<Renderer::Font>(mTex);
std::string txt = text;
if (fontPtr) Window::Window::postToMainThread([fontPtr, txt]() { fontPtr->build({255,255,255,255}, txt); });
} }
std::string UIButton::getText() const { std::string UIButton::getText() const {

View File

@@ -1,4 +1,5 @@
#include <object/ui/uitext.hpp> #include <object/ui/uitext.hpp>
#include <window/window.hpp>
namespace Game::Object { namespace Game::Object {
UIText::UIText(const std::string& name, std::shared_ptr<Renderer::Font> font, const Transform& transform, float x, float y) UIText::UIText(const std::string& name, std::shared_ptr<Renderer::Font> font, const Transform& transform, float x, float y)
@@ -31,7 +32,15 @@ namespace Game::Object {
void UIText::setText(const std::string& text) { void UIText::setText(const std::string& text) {
mText = text; mText = text;
std::dynamic_pointer_cast<Renderer::Font>(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<Renderer::Font>(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 { std::string UIText::getText() const {

View File

@@ -1,164 +1,278 @@
#include <object/ui/uitextbox.hpp> #include <object/ui/uitextbox.hpp>
#include <renderer/renderer.hpp> #include <renderer/renderer.hpp>
#include <object/camera.hpp> #include <window/window.hpp>
namespace Game::Object { namespace Game::Object {
UITextBox::UITextBox(const std::string& name, std::shared_ptr<Renderer::Texture> background, std::shared_ptr<Renderer::Font> font, const Transform& transform, float x, float y)
UITextBox::UITextBox(const std::string& name, std::shared_ptr<Renderer::Font> font, : Entity(name, background, transform), mBackground(background), mFont(font), mX(x), mY(y) {
const Transform& transform, float x, float y, UITextboxConfig config) if (!mBackground) {
: Entity(name, font, transform), mX(x), mY(y), mConfig(config) { } // Fallback to font texture if background is not provided
mBackground = std::dynamic_pointer_cast<Renderer::Texture>(mFont);
setTexture(mBackground);
}
}
void UITextBox::start() { void UITextBox::start() {
// Compute visual box size first (respecting min sizes and scale), then center the box // Build initial text texture (placeholder if present) so textbox has a
mBoxWidth = static_cast<float>(mTex ? mTex->getWidth() : 0.f) * mTransform.adjustedScaleX(); // visible indicator and stable dimensions for click hit-testing.
mBoxHeight = static_cast<float>(mTex ? mTex->getHeight() : 0.f) * mTransform.adjustedScaleY(); 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; // Center the textbox on the requested position
if (mBoxHeight < mConfig.minHeight) mBoxHeight = mConfig.minHeight; 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 static char scancodeToChar(SDL_Scancode scancode, bool shift) {
mTransform.x = mX - mBoxWidth / 2.f; // Basic mapping for letters, digits and common symbols
mTransform.y = mY - mBoxHeight / 2.f; if (scancode >= SDL_SCANCODE_A && scancode <= SDL_SCANCODE_Z) {
char c = 'a' + (scancode - SDL_SCANCODE_A);
refreshVisualText(); if (shift) c = static_cast<char>(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) { void UITextBox::update(float deltaTime) {
if (!mIsActive) return; if (!mIsActive) return;
if (Input::isMouseButtonJustPressed(SDL_BUTTON_LEFT)) { // Cursor blink
mIsFocused = isMouseInsideBox(); mCursorBlinkTimer += deltaTime;
mNeedsTextRefresh = true; 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<FnType>(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) { if (mIsFocused) {
// Cursor blink // Handle special keys
mCursorTimer += deltaTime; if (Input::isKeyJustPressed(SDL_SCANCODE_BACKSPACE)) {
if (mCursorTimer >= mConfig.cursorBlinkRate) { if (mCursorIndex > 0 && !mText.empty()) {
mCursorTimer = 0.f; mText.erase(mCursorIndex - 1, 1);
mCursorVisible = !mCursorVisible; mCursorIndex = std::max<size_t>(0, mCursorIndex - 1);
mNeedsTextRefresh = true; }
}
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()) { // Character insertion from scancodes
mText.pop_back();
mNeedsTextRefresh = true;
}
if (Input::isKeyJustPressed(SDL_SCANCODE_RETURN) || Input::isKeyJustPressed(SDL_SCANCODE_KP_ENTER)) { bool shift = Input::isKeyPressed(SDL_SCANCODE_LSHIFT) || Input::isKeyPressed(SDL_SCANCODE_RSHIFT);
mIsFocused = false;
mCursorTimer = 0.f;
mCursorVisible = true;
mNeedsTextRefresh = true;
}
for (int key = 0; key < SDL_SCANCODE_COUNT; ++key) { // Letters A-Z
SDL_Scancode scancode = static_cast<SDL_Scancode>(key); for (int sc = SDL_SCANCODE_A; sc <= SDL_SCANCODE_Z; ++sc) {
if (!Input::isKeyJustPressed(scancode)) continue; if (Input::isKeyJustPressed(static_cast<SDL_Scancode>(sc))) {
char c = scancodeToChar(static_cast<SDL_Scancode>(sc), shift);
SDL_Keycode keycode = SDL_GetKeyFromScancode(scancode, SDL_GetModState(), true); if (c && mText.size() < mMaxLength) {
if (keycode >= 32 && keycode <= 126) { mText.insert(mCursorIndex, 1, c);
if (mConfig.maxLength == 0 || static_cast<int>(mText.size()) < mConfig.maxLength) { mCursorIndex++;
mText.push_back(static_cast<char>(keycode));
mNeedsTextRefresh = true;
} }
} }
} }
// Digits 1-9 and 0
for (int sc = SDL_SCANCODE_1; sc <= SDL_SCANCODE_9; ++sc) {
if (Input::isKeyJustPressed(static_cast<SDL_Scancode>(sc))) {
char c = scancodeToChar(static_cast<SDL_Scancode>(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) { // Update rendered text only when needed
refreshVisualText(); 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<float>(fontPtr->getWidth());
{
std::scoped_lock lock(mRenderMutex);
mReservedPlaceholderWidth = w;
}
});
} }
} }
void UITextBox::render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) { 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; SDL_FPoint center;
const float by = mTransform.y - mConfig.paddingY - config.camY + config.screenH / 2.f; center.x = dst.w / 2.f;
const float bw = mBoxWidth + 2.f * mConfig.paddingX; center.y = dst.h / 2.f;
const float bh = mBoxHeight + 2.f * mConfig.paddingY;
const float t = mConfig.borderThickness;
// Background SDL_RenderTextureRotated(
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND); renderer->getSDLRenderer(),
const SDL_Color& bg = mConfig.bgColor; mBackground->getSDLTexture(),
SDL_SetRenderDrawColor(r, bg.r, bg.g, bg.b, bg.a); nullptr,
const SDL_FRect bgRect = {bx, by, bw, bh}; &dst,
SDL_RenderFillRect(r, &bgRect); mTransform.rotation,
&center,
mIsFlipped ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE
);
}
// Border (4 filled rects for configurable thickness) // Render font texture (text) on top of background
const SDL_Color& bc = mIsFocused ? mConfig.focusedBorderColor : mConfig.borderColor; if (mFont && mFont->getSDLTexture()) {
SDL_SetRenderDrawColor(r, bc.r, bc.g, bc.b, bc.a); SDL_FRect dst;
const SDL_FRect borders[4] = { SDL_GetTextureSize(mFont->getSDLTexture(), &dst.w, &dst.h);
{bx, by, bw, t }, // top dst.w *= mTransform.scaleX * UNIVERSAL_SCALE_COEFFICIENT;
{bx, by + bh - t, bw, t }, // bottom dst.h *= mTransform.scaleY * UNIVERSAL_SCALE_COEFFICIENT;
{bx, by, t, bh}, // left dst.x = mTransform.x - config.camX + config.screenW / 2.f;
{bx + bw - t, by, t, bh}, // right dst.y = mTransform.y - config.camY + config.screenH / 2.f;
};
SDL_RenderFillRects(r, borders, 4);
// Text (or placeholder) via base render SDL_FPoint center;
Entity::render(renderer, config); center.x = dst.w / 2.f;
center.y = dst.h / 2.f;
SDL_RenderTextureRotated(
renderer->getSDLRenderer(),
mFont->getSDLTexture(),
nullptr,
&dst,
mTransform.rotation,
&center,
mIsFlipped ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE
);
}
} }
void UITextBox::setText(const std::string& text) { void UITextBox::setText(const std::string& text) {
mText = text; mText = text.substr(0, mMaxLength);
mNeedsTextRefresh = true; mCursorIndex = std::min(mText.size(), mCursorIndex);
refreshVisualText(); mLastRenderedText.clear(); // force rebuild
} }
std::string UITextBox::getText() const { return mText; } void UITextBox::insertText(const std::string& utf8) {
std::string UITextBox::getValue() const { return mText; } // Insert UTF-8 text at cursor position, respecting max length
bool UITextBox::isFocused() const { return mIsFocused; } if (utf8.empty() || mText.size() >= mMaxLength) return;
bool UITextBox::isMouseInsideBox() const { size_t availableSpace = mMaxLength - mText.size();
// Get screen-space mouse coordinates size_t insertLen = std::min(utf8.size(), availableSpace);
const float screenMouseX = Input::getMouseX(); mText.insert(mCursorIndex, utf8.substr(0, insertLen));
const float screenMouseY = Input::getMouseY(); mCursorIndex += insertLen;
mLastRenderedText.clear(); // force rebuild on next update
// Get window dimensions mShowCursor = true; // Show cursor immediately when text inserted
int windowW = 0, windowH = 0; mCursorBlinkTimer = 0.f; // Reset blink timer
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::refreshVisualText() { std::string UITextBox::getText() const {
const bool showPlaceholder = mText.empty() && !mIsFocused && !mConfig.placeholder.empty(); return mText;
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<Renderer::Font>(mTex)->build(color, rendered);
mNeedsTextRefresh = false;
} }
} }

View File

@@ -54,6 +54,12 @@ namespace Game::Renderer {
return; 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. // Fonts look better with linear filtering than with nearest-neighbor scaling.
if (!SDL_SetTextureScaleMode(mTex, SDL_SCALEMODE_LINEAR)) { if (!SDL_SetTextureScaleMode(mTex, SDL_SCALEMODE_LINEAR)) {
WARN("Failed to set texture scale mode to LINEAR for font '" << mId << "': " << SDL_GetError()); WARN("Failed to set texture scale mode to LINEAR for font '" << mId << "': " << SDL_GetError());

View File

@@ -2,9 +2,18 @@
#include <algorithm> #include <algorithm>
#include <renderer/texture.hpp> #include <renderer/texture.hpp>
#include <game/input.hpp>
namespace Game::Window { namespace Game::Window {
std::mutex Window::sMutex; std::mutex Window::sMutex;
// Tasks posted from other threads to run on the window thread
std::mutex sTasksMutex;
std::vector<std::function<void()>> sPostedTasks;
void Window::postToMainThread(std::function<void()> fn) {
std::scoped_lock lock(sTasksMutex);
sPostedTasks.push_back(std::move(fn));
}
Window::Window() : mWindow(nullptr), mRenderer(), mGameManager(), mRunning(false) { } Window::Window() : mWindow(nullptr), mRenderer(), mGameManager(), mRunning(false) { }
@@ -88,9 +97,34 @@ namespace Game::Window {
SDL_Event event; SDL_Event event;
while (mRunning) { while (mRunning) {
const auto frameStart = std::chrono::steady_clock::now(); const auto frameStart = std::chrono::steady_clock::now();
// Execute any tasks posted to the main/window thread
std::vector<std::function<void()>> 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(); SDL_PumpEvents();
while (SDL_PollEvent(&event)) { 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<UserEventFn>(event.user.data1);
fn();
continue;
}
if (event.type == SDL_EVENT_QUIT) { if (event.type == SDL_EVENT_QUIT) {
mRunning = false; mRunning = false;
} }