Compare commits

12 Commits
master ... drug

Author SHA1 Message Date
ee3c263547 leaderboard 2026-05-20 07:32:57 +02:00
66c5e0e710 cmake 2026-05-19 23:02:37 +02:00
6534996a52 replay 2026-05-19 22:49:57 +02:00
0b45643ef2 menu 2026-05-19 22:35:10 +02:00
d93e71e716 1 2026-05-19 17:19:54 +02:00
8ff3e29374 neki 2026-05-13 08:06:15 +02:00
892d8f22eb test 2026-05-13 07:58:59 +02:00
d9769bdbbb cooldown 2026-05-02 22:27:00 +02:00
c46443e2f4 gpu errors 2026-05-02 21:56:33 +02:00
fcc598adb1 prevod 2026-05-02 21:08:47 +02:00
e4389f035d dodatna logika 2026-05-02 18:09:17 +02:00
56d567b77d igra 2026-05-02 15:18:39 +02:00
39 changed files with 2250 additions and 340 deletions

View File

@@ -4,6 +4,21 @@ project(Letnik3Zadnja)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(APPLE AND NOT CMAKE_OSX_SYSROOT)
execute_process(
COMMAND xcrun --sdk macosx --show-sdk-path
OUTPUT_VARIABLE _macos_sdk_path
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(_macos_sdk_path)
set(CMAKE_OSX_SYSROOT "${_macos_sdk_path}" CACHE PATH "macOS SDK path" FORCE)
endif()
endif()
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 +32,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)

5
TODO.txt Normal file
View File

@@ -0,0 +1,5 @@
Smeti random premikanje - ok
vnos imena kot text box
neko sledenje igralcu (nasprotnikov)
zavezniki naj nekaj delajo
fullscreen

View File

@@ -7,16 +7,30 @@
#include <game/gamemanager.hpp>
#include <game/agame/enemy.hpp>
#include <game/agame/trash.hpp>
#include <game/agame/friendly.hpp>
namespace Game::AGame {
GAME_ENTITY(Background)
public:
void render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) override;
void onWindowResized(int newWidth, int newHeight) override;
// Spawn a single trash at the given transform. If seaOnly is true the trash
// will be clamped to the sea side of the land boundary.
void spawnTrashAt(const Object::Transform& tS, bool seaOnly = false);
private:
float mEnemySpawnTimer = 0.f;
float mTimeToSpawn = 5.f;
void spawnLevel(int stage);
void spawnFriendly(int stage, int count);
int mW, mH;
int mMaxLevels = 2;
float mLandBoundaryX = 0.f;
bool mPendingLevelSpawn = false;
int mPendingLevelStage = 0;
// Periodic friendly spawn settings
float mFriendlySpawnAvgInterval = 6.f; // average seconds between spawns
int mMaxAutoFriendlies = 2; // hard cap for total active friendlies
std::shared_ptr<Game::Renderer::Texture> mSeaTex;
std::shared_ptr<Game::Renderer::Texture> mEnemyTex;
std::shared_ptr<Game::Renderer::Texture> mTrashTex;
std::shared_ptr<Game::Renderer::Texture> mFriendlyTex;
END_GAME_ENTITY()
}

View File

@@ -10,5 +10,14 @@ namespace Game::AGame {
GAME_ENTITY(Enemy)
public:
void onCollisionEnter(Object::Entity* other) override;
bool hasAdjacentEnemy();
private:
float mMoveSpeedX = 0.f;
float mMoveSpeedY = 0.f;
float mDirectionChangeTimer = 0.f;
float mShoreSpawnCooldown = 0.f;
bool mFollowingPlayer = false;
static constexpr float FOLLOW_DISTANCE = 300.f;
static constexpr float FOLLOW_SPEED = 35.f;
END_GAME_ENTITY()
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <game/gamemanager.hpp>
#include <object/entity.hpp>
#include <renderer/texture.hpp>
#include <renderer/font.hpp>
#include <object/sound.hpp>
namespace Game::AGame {
GAME_ENTITY(Friendly)
public:
void onCollisionEnter(Object::Entity* other) override;
private:
float mMoveSpeedX = 0.f;
float mMoveSpeedY = 0.f;
float mDirectionChangeTimer = 0.f;
bool mOnSea = false;
static constexpr float CLEANUP_RADIUS = 50.f;
static constexpr int CLEANUP_SCORE_BONUS = 5;
static constexpr float LAND_SPEED_MIN = 20.f;
static constexpr float LAND_SPEED_MAX = 50.f;
static constexpr float SEA_SPEED = 14.f;
END_GAME_ENTITY()
}

View File

@@ -0,0 +1,14 @@
#pragma once
#include <object/ui/uitext.hpp>
namespace Game::AGame {
class HUDText : public Object::UIText {
using Object::UIText::UIText;
public:
~HUDText() override = default;
void start() override;
void update(float deltaTime) override;
};
}

View File

@@ -7,9 +7,22 @@
namespace Game::AGame {
GAME_ENTITY(Player)
private:
Object::Sound mSound;
float mSpeed = 200.f; // Pixels per second
[[maybe_unused]] float mHealth = 100.f;
public:
void setShipTexture(std::shared_ptr<Game::Renderer::Texture> tex);
void setGroundTexture(std::shared_ptr<Game::Renderer::Texture> 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;
float mSpeed = 200.f; // Pixels per second
[[maybe_unused]] float mHealth = 100.f;
std::shared_ptr<Game::Renderer::Texture> mShipTex;
std::shared_ptr<Game::Renderer::Texture> mGroundTex;
bool mIsShipMode = true;
float mShoreMargin = 40.f;
float mStateTransitionCooldown = 1.f;
float mStateTransitionCooldownTimer = 0.f;
END_GAME_ENTITY()
}

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

@@ -6,7 +6,16 @@
#include <renderer/font.hpp>
#include <object/sound.hpp>
namespace Game::AGame {
class Player;
}
namespace Game::AGame {
GAME_ENTITY(Trash)
public:
void onCollisionEnter(Object::Entity* other) override;
void setSeaOnly(bool v) { mSeaOnly = v; }
private:
bool mSeaOnly = false;
END_GAME_ENTITY()
}

View File

@@ -19,7 +19,8 @@ namespace Game {
enum class GameStateEnum {
RUNNING,
PAUSED,
STOPPED
STOPPED,
REPLAY
};
enum class SharedDataType {
@@ -58,6 +59,16 @@ namespace Game {
static void destroyEntity(T* entity);
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<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:
int mTargetUpdatesPerSecond = TARGET_UPDATE_RATE;
clock::time_point mLastUpdate;
@@ -65,8 +76,18 @@ namespace Game {
static std::unordered_map<std::string, int> mSharedInts;
static std::unordered_map<std::string, float> mSharedFloats;
static std::unordered_map<std::string, bool> mSharedBools;
static std::vector<Object::Transform> mPlayerTransformHistory;
static std::vector<bool> mPlayerFormHistory;
static GameStateEnum mCurrentGameState;
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>

View File

@@ -1,6 +1,10 @@
#pragma once
#include <SDL3/SDL.h>
#include <vector>
#include <queue>
#include <mutex>
#include <string>
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<std::string>& out);
private:
static const bool* mCurrentKeyStates;
static const bool* mPreviousKeyStates;
static std::vector<Uint8> 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<std::string> mPendingText;
};
}

View File

@@ -21,10 +21,13 @@ namespace Game::Object::Components {
void start(Object::Entity* thisEntity) override;
void update(float deltaTime, Object::Entity* thisEntity) override;
void setScale(float scale) { mScale = scale; }
float getScale() const { return mScale; }
BoxColliderBounds getBounds() const { return mBounds; }
bool isColliding() const { return !mCollidingWith.empty(); }
private:
float mScale = 1.f;
BoxColliderBounds mBounds{0.f, 0.f, 0.f, 0.f};
std::unordered_set<Object::Entity*> mCollidingWith; // Track collisions per-entity so enter/stay/exit callbacks remain correct with multiple colliders
};

View File

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

View File

@@ -4,62 +4,55 @@
#include <renderer/font.hpp>
#include <renderer/texture.hpp>
#include <utility>
#include <string>
#include <game/input.hpp>
#include <SDL3/SDL.h>
#include <mutex>
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<Renderer::Font> font,
const Transform& transform,
float x = 0.f, float y = 0.f,
UITextboxConfig config = {});
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);
~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<float, float> getPosition() const { return {mX, mY}; }
bool isFocused() const { return mIsFocused; }
private:
bool isMouseInsideBox() const;
void refreshVisualText();
std::shared_ptr<Renderer::Texture> mBackground; // Background texture for the textbox
std::shared_ptr<Renderer::Font> 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
bool mIsHovered = false;
std::mutex mRenderMutex; // Protects mLastRenderedText and mReservedPlaceholderWidth from main-thread updates
};
}

View File

@@ -17,11 +17,16 @@ 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);
SDL_Texture* getSDLTexture();
std::string getId();
private:
TTF_Font* mFont;
SDL_Renderer* mRenderer;
// Remember last build params so we can rebuild fonts on device reset
std::string mLastText;
SDL_Color mLastColor;
};
}

View File

@@ -11,6 +11,8 @@ namespace Game::Renderer {
float camY;
int screenW;
int screenH;
float scaleX; // Scale from logical (1280) to actual screen width
float scaleY; // Scale from logical (720) to actual screen height
} RendererConfig;
class Renderer {

View File

@@ -21,10 +21,16 @@ 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);
protected:
SDL_Texture* mTex;
std::string mId;
// For textures created from disk, store the path so we can reload on device reset
std::string mPath;
bool mIsFromFile = false;
private:
bool mIsTiled = false; // Whether the texture is a tileset that should be rendered as a single tile or not
};

View File

@@ -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<void()> fn);
private:
mutable std::mutex mMutex;

BIN
resources/l3friendly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

BIN
resources/l3player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

BIN
resources/l3sea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

View File

@@ -1,18 +1,212 @@
#include <game/agame/background.hpp>
#include <game/agame/player.hpp>
#include <window/window.hpp>
#include <state/gamestate.hpp>
#include <object/camera.hpp>
#include <object/components/boxcollider.hpp>
#include <algorithm>
#include <chrono>
#include <ctime>
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <string_view>
#include <vector>
#include <utils.hpp>
namespace {
constexpr const char* kLeaderboardFile = "leaderboard.bin";
constexpr std::size_t kLeaderboardNameCapacity = 24;
struct LeaderboardEntry {
char name[kLeaderboardNameCapacity + 1]{};
std::int32_t score = 0;
};
std::string trimLeaderboardName(const std::string& name) {
return name.substr(0, kLeaderboardNameCapacity);
}
LeaderboardEntry makeLeaderboardEntry(const std::string& name, int score) {
LeaderboardEntry entry{};
const std::string truncatedName = trimLeaderboardName(name);
std::copy(truncatedName.begin(), truncatedName.end(), entry.name);
entry.score = static_cast<std::int32_t>(score);
return entry;
}
std::vector<LeaderboardEntry> loadLeaderboardEntries() {
std::vector<LeaderboardEntry> entries;
std::ifstream file(kLeaderboardFile, std::ios::binary);
if (!file.is_open()) {
return entries;
}
while (true) {
LeaderboardEntry entry{};
file.read(entry.name, sizeof(entry.name));
if (!file) {
break;
}
file.read(reinterpret_cast<char*>(&entry.score), sizeof(entry.score));
if (!file) {
break;
}
entries.push_back(entry);
}
return entries;
}
void saveLeaderboardEntries(const std::vector<LeaderboardEntry>& entries) {
std::ofstream file(kLeaderboardFile, std::ios::binary | std::ios::trunc);
if (!file.is_open()) {
WARN("Neuspešno odpiranje leaderboard.bin za pisanje");
return;
}
for (const auto& entry : entries) {
file.write(entry.name, sizeof(entry.name));
file.write(reinterpret_cast<const char*>(&entry.score), sizeof(entry.score));
}
}
void upsertLeaderboardEntry(std::vector<LeaderboardEntry>& entries, const std::string& playerName, int score) {
const std::string truncatedName = trimLeaderboardName(playerName);
entries.erase(
std::remove_if(entries.begin(), entries.end(), [&](const LeaderboardEntry& entry) {
return truncatedName == entry.name;
}),
entries.end()
);
entries.push_back(makeLeaderboardEntry(truncatedName, score));
std::sort(entries.begin(), entries.end(), [](const LeaderboardEntry& left, const LeaderboardEntry& right) {
if (left.score != right.score) {
return left.score > right.score;
}
return std::string_view(left.name) < std::string_view(right.name);
});
}
std::string formatLeaderboardEntries(const std::vector<LeaderboardEntry>& entries) {
std::ostringstream stream;
stream << "Lestvica:";
if (entries.empty()) {
stream << "\n(brez vpisov)";
return stream.str();
}
for (std::size_t index = 0; index < entries.size(); ++index) {
stream << "\n" << (index + 1) << ". " << entries[index].name << " - " << entries[index].score;
}
return stream.str();
}
std::string loadLeaderboardText() {
return formatLeaderboardEntries(loadLeaderboardEntries());
}
void refreshLeaderboardHudText() {
Game::GameManager::setSharedData("leaderboardText", loadLeaderboardText());
}
void writeFinalScoreFile(int score) {
std::vector<LeaderboardEntry> leaderboardEntries = loadLeaderboardEntries();
std::string leaderboardPlayerName = Game::GameManager::getSharedData<std::string>("playerName");
if (leaderboardPlayerName.empty()) {
leaderboardPlayerName = "Player";
}
upsertLeaderboardEntry(leaderboardEntries, leaderboardPlayerName, score);
saveLeaderboardEntries(leaderboardEntries);
std::ofstream file("score.txt", std::ios::trunc);
if (!file.is_open()) {
WARN("Neuspešno odpiranje score.txt za pisanje");
return;
}
const auto now = std::chrono::system_clock::now();
const std::time_t nowTime = std::chrono::system_clock::to_time_t(now);
std::tm localTime{};
#if defined(_WIN32)
localtime_s(&localTime, &nowTime);
#else
localtime_r(&nowTime, &localTime);
#endif
std::string playerName = Game::GameManager::getSharedData<std::string>("playerName");
if (playerName.empty()) playerName = "Player";
file << "Končna statistika igre:\n";
file << "Igralec: " << playerName << "\n";
file << "Točke: " << score << "\n";
file << "Datum: " << std::put_time(&localTime, "%Y-%m-%d %H:%M:%S") << "\n";
// Replay system with form state
std::vector<Game::Object::Transform> playerHistory;
std::vector<bool> 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 (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");
replayFile.close();
file.close();
}
}
namespace Game::AGame {
void Background::start() {
mSeaTex = std::make_shared<Game::Renderer::Texture>("../resources/l3sea.png", SDL_GetRenderer(Window::Window::getSDLWindowBackend()), "seaTex");
mEnemyTex = std::make_shared<Game::Renderer::Texture>("../resources/l3enemy.png", SDL_GetRenderer(Window::Window::getSDLWindowBackend()), "enemyTex");
mTrashTex = std::make_shared<Game::Renderer::Texture>("../resources/l3trash.png", SDL_GetRenderer(Window::Window::getSDLWindowBackend()), "trashTex");
mFriendlyTex = std::make_shared<Game::Renderer::Texture>("../resources/l3friendly.png", SDL_GetRenderer(Window::Window::getSDLWindowBackend()), "friendlyTex");
GameManager::setSharedData("enemyActiveCount", 0);
GameManager::setSharedData("trashActiveCount", 0);
GameManager::setSharedData("friendlyActiveCount", 0);
GameManager::setSharedData("gameStage", 1);
GameManager::setSharedData("gameWon", false);
GameManager::setSharedData("gameLost", false);
GameManager::setSharedData("leaderboardText", loadLeaderboardText());
mZIndex = -1; // Ensure background renders behind other entities
mTex->setTiled(true); // Set the background texture to be tiled
if (mSeaTex) {
mSeaTex->setTiled(true);
}
mTiledScale = 0.5f;
SDL_GetWindowSizeInPixels(Window::Window::getSDLWindowBackend(), &mW, &mH);
// Use logical world dimensions (1280×720) not actual screen size
mW = 1280;
mH = 720;
// Land boundary: left 1/3 of map in centered coordinates
// For 1280px window: -640 (left) + 426.67 (1/3) = -213.33
mLandBoundaryX = -static_cast<float>(mW) / 2.f + static_cast<float>(mW) / 3.f;
GameManager::setSharedData("terrainLandBoundaryX", mLandBoundaryX);
GameManager::setSharedData("enemyRevealRadius", 260.f);
mTransform.scaleX *= 10.f;
mTransform.scaleY *= 10.f;
@@ -21,76 +215,275 @@ namespace Game::AGame {
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
LOG("W: " << mW << " H: " << mH);
// Only spawn level entities during normal gameplay, not during replay
if (GameManager::getCurrentGameState() != GameStateEnum::REPLAY) {
spawnLevel(1);
}
}
mTransform.x = mW / 2.f - (mW / 3.f);
mTransform.y = -mH;
void Background::render(Game::Renderer::Renderer* renderer, Game::Renderer::RendererConfig config) {
if (!renderer || !mTex || !mSeaTex) {
return;
}
const float worldLeft = -static_cast<float>(mW) / 2.f;
const float worldRight = static_cast<float>(mW) / 2.f;
const float worldTop = -static_cast<float>(mH) / 2.f;
const float worldBottom = static_cast<float>(mH) / 2.f;
const float landLeft = worldLeft;
const float landRight = mLandBoundaryX;
const float seaLeft = mLandBoundaryX;
const float seaRight = worldRight;
auto drawSection = [&](const std::shared_ptr<Game::Renderer::Texture>& tex, float startX, float endX) {
if (!tex) {
return;
}
float tileW = 0.f;
float tileH = 0.f;
SDL_GetTextureSize(tex->getSDLTexture(), &tileW, &tileH);
tileW *= mTiledScale;
tileH *= mTiledScale;
if (tileW <= 0.f || tileH <= 0.f) {
return;
}
const float screenStartX = startX - config.camX + config.screenW / 2.f;
const float screenEndX = endX - config.camX + config.screenW / 2.f;
const float screenStartY = worldTop - config.camY + config.screenH / 2.f;
const float screenEndY = worldBottom - config.camY + config.screenH / 2.f;
for (float x = screenStartX; x < screenEndX; x += tileW) {
for (float y = screenStartY; y < screenEndY; y += tileH) {
SDL_FRect dst{ x, y, tileW, tileH };
SDL_RenderTexture(renderer->getSDLRenderer(), tex->getSDLTexture(), nullptr, &dst);
}
}
};
drawSection(mTex, landLeft, landRight);
drawSection(mSeaTex, seaLeft, seaRight);
}
void Background::spawnFriendly(int stage, int count) {
const float viewLeft = -mW / 2.f;
const float viewRight = mW / 2.f;
const float viewTop = -mH / 2.f;
const float viewBottom = mH / 2.f;
Object::Transform tS;
tS.x = 0.f;
tS.y = 0.f;
tS.rotation = 0.f;
tS.scaleX = 4.0f;
tS.scaleY = 4.0f;
const float halfFriendlyW = mFriendlyTex->getWidth() * tS.adjustedScaleX() / 2.f;
const float halfFriendlyH = mFriendlyTex->getHeight() * tS.adjustedScaleY() / 2.f;
// Split friendlies: most on land, a smaller number may appear on sea
// Decide how many friendlies appear on the sea.
// For stage 1 keep them on land; for later stages allow at least one on sea
int seaCount = 0;
if (stage > 1) {
seaCount = std::max(1, count / 3); // roughly one third on sea for later stages
}
int landCount = count - seaCount;
// Spawn land friendlies (left side)
for (int i = 0; i < landCount; ++i) {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewLeft + halfFriendlyW + 25.f), static_cast<int>(mLandBoundaryX - halfFriendlyW - 25.f)));
tS.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewTop + halfFriendlyH + 100.f), static_cast<int>(viewBottom - halfFriendlyH - 100.f)));
auto* friendly = State::GameState::getInstance().addEntity(std::make_unique<AGame::Friendly>("Friendly" + std::to_string(stage) + "_L" + std::to_string(i + 1), mFriendlyTex, tS));
if (friendly) {
if (auto* collider = friendly->getComponent<Object::Components::BoxCollider>()) {
collider->setScale(0.75f);
}
}
}
// Spawn a smaller number of friendlies on the sea (right side)
for (int i = 0; i < seaCount; ++i) {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(mLandBoundaryX + halfFriendlyW + 25.f), static_cast<int>(viewRight - halfFriendlyW - 25.f)));
tS.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewTop + halfFriendlyH + 100.f), static_cast<int>(viewBottom - halfFriendlyH - 100.f)));
auto* friendly = State::GameState::getInstance().addEntity(std::make_unique<AGame::Friendly>("Friendly" + std::to_string(stage) + "_S" + std::to_string(i + 1), mFriendlyTex, tS));
if (friendly) {
if (auto* collider = friendly->getComponent<Object::Components::BoxCollider>()) {
collider->setScale(0.75f);
}
}
}
}
void Background::spawnLevel(int stage) {
const float viewLeft = -mW / 2.f;
const float viewRight = mW / 2.f;
const float viewTop = -mH / 2.f;
const float viewBottom = mH / 2.f;
const int enemyCount = 2 + stage * 2;
const int trashCount = 4 + stage * 3;
const int friendlyCount = 1 + (stage - 1);
GameManager::setSharedData("gameStage", stage);
GameManager::setSharedData("enemyActiveCount", enemyCount);
GameManager::setSharedData("trashActiveCount", trashCount);
GameManager::setSharedData("friendlyActiveCount", friendlyCount);
if (stage > 1) {
auto* player = GameManager::getEntityByName<Player>("Player");
if (player) {
player->respawnRandomSea(mLandBoundaryX);
}
}
Object::Transform tS;
tS.rotation = 0.f;
tS.scaleX = 4.7f;
tS.scaleY = 4.7f;
const float halfEnemyW = mEnemyTex->getWidth() * tS.adjustedScaleX() / 2.f;
const float halfEnemyH = mEnemyTex->getHeight() * tS.adjustedScaleY() / 2.f;
for (int i = 0; i < enemyCount; ++i) {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewLeft + halfEnemyW + 25.f), static_cast<int>(mLandBoundaryX - halfEnemyW - 25.f)));
tS.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewTop + halfEnemyH + 100.f), static_cast<int>(viewBottom - halfEnemyH - 100.f)));
GameManager::instantiateEntity(std::make_unique<AGame::Enemy>("Enemy" + std::to_string(stage) + "_" + std::to_string(i + 1), mEnemyTex, tS));
}
tS.scaleX = 5.5f;
tS.scaleY = 5.5f;
const float halfTrashW = mTrashTex->getWidth() * tS.adjustedScaleX() / 2.f;
const float halfTrashH = mTrashTex->getHeight() * tS.adjustedScaleY() / 2.f;
for (int i = 0; i < trashCount; ++i) {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(mLandBoundaryX + halfTrashW + 25.f), static_cast<int>(viewRight - halfTrashW - 25.f)));
tS.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewTop + halfTrashH + 100.f), static_cast<int>(viewBottom - halfTrashH - 100.f)));
GameManager::instantiateEntity(std::make_unique<AGame::Trash>("Trash" + std::to_string(stage) + "_" + std::to_string(i + 1), mTrashTex, tS));
}
spawnFriendly(stage, friendlyCount);
}
void Background::spawnTrashAt(const Object::Transform& tS, bool seaOnly) {
// Prepare transform and ensure sea-only pop is placed on the sea side
Object::Transform t = tS;
t.rotation = 0.f;
t.scaleX = 5.5f;
t.scaleY = 5.5f;
const float halfTrashW = mTrashTex->getWidth() * t.adjustedScaleX() / 2.f;
if (seaOnly) {
const float minSeaX = mLandBoundaryX + halfTrashW + 25.f;
if (t.x < minSeaX) t.x = minSeaX;
}
// Create a unique-ish name for the auto-spawned trash
const int id = Utils::getUtils().rirng32(0, 1000000);
const std::string name = "Trash_Auto_" + std::to_string(id);
GameManager::instantiateEntity(std::make_unique<AGame::Trash>(name, mTrashTex, t));
// If requested, mark the spawned trash as sea-only
if (seaOnly) {
auto* tr = GameManager::getEntityByName<AGame::Trash>(name);
if (tr) tr->setSeaOnly(true);
}
GameManager::setSharedData("trashActiveCount", GameManager::getSharedData<int>("trashActiveCount") + 1);
}
void Background::update(float deltaTime) {
mEnemySpawnTimer += deltaTime;
(void)deltaTime;
int cnt = GameManager::getSharedData<int>("enemyActiveCount");
if (mEnemySpawnTimer >= mTimeToSpawn && cnt < 5) {
mEnemySpawnTimer = 0.f; // RESET
GameManager::setSharedData("enemyActiveCount", cnt + 1);
// Spawn Enemy on grass
Object::Transform tS;
tS.scaleY = 7.f;
tS.scaleX = 7.f;
tS.rotation = 0.f;
float camX, camY;
Object::Camera::getInstance().getPosition(camX, camY);
const float halfEnemyW = mEnemyTex->getWidth() * tS.adjustedScaleX() / 2.f;
const float halfEnemyH = mEnemyTex->getHeight() * tS.adjustedScaleY() / 2.f;
const float viewLeft = camX - (mW / 2.f);
const float viewRight = camX + (mW / 2.f);
const float viewTop = camY - (mH / 2.f);
const float viewBottom = camY + (mH / 2.f);
// Right 1/3 of the currently visible screen, in world coordinates.
int spawnMinX = static_cast<int>(viewLeft + (2.f * mW / 3.f) + halfEnemyW);
int spawnMaxX = static_cast<int>(viewRight - halfEnemyW - 25.f);
int spawnMinY = static_cast<int>(viewTop + halfEnemyH + 100.f);
int spawnMaxY = static_cast<int>(viewBottom - halfEnemyH - 100.f);
// Safety for tiny windows / huge sprites.
if (spawnMinX > spawnMaxX) spawnMinX = spawnMaxX = static_cast<int>(camX);
if (spawnMinY > spawnMaxY) spawnMinY = spawnMaxY = static_cast<int>(camY);
tS.x = static_cast<float>(Utils::getUtils().rirng32(spawnMinX, spawnMaxX));
tS.y = static_cast<float>(Utils::getUtils().rirng32(spawnMinY, spawnMaxY));
GameManager::instantiateEntity(std::make_unique<AGame::Enemy>("Enemy" + std::to_string(cnt + 1), mEnemyTex, tS));
// Spawn Trash at shoreline
tS.scaleX = 5.5f;
tS.scaleY = 5.5f;
tS.rotation = 0.f;
tS.x = mTransform.x - 75.f;
tS.y = static_cast<float>(Utils::getUtils().rirng32(spawnMinY, spawnMaxY));
GameManager::instantiateEntity(std::make_unique<AGame::Trash>("Trash" + std::to_string(cnt + 1), mTrashTex, tS));
if (GameManager::getSharedData<bool>("gameLost")) {
refreshLeaderboardHudText();
return;
}
/*const bool* state = SDL_GetKeyboardState(nullptr);
if (state[SDL_SCANCODE_P]) {
mTransform.scaleX *= 2.f;
mTransform.scaleY *= 2.f;
if (GameManager::getSharedData<bool>("gameWon")) {
refreshLeaderboardHudText();
return;
}
if (state[SDL_SCANCODE_L]) {
mTransform.scaleX *= 0.5f;
mTransform.scaleY *= 0.5f;
}*/
//mTransform.rotation += 1.f; // Rotate clockwise for testing
//mTransform.scaleX = 1.f + 1.f * std::sin(RUNNING_TIME() / 0.5f); // Pulsate scale for testing
//mTransform.scaleY = 1.f + 0.5f * std::cos(RUNNING_TIME() / 0.5f); // Pulsate scale for testing
//Object::Camera::getInstance().move(1.f, 0.f);
const int enemyCount = GameManager::getSharedData<int>("enemyActiveCount");
const int trashCount = GameManager::getSharedData<int>("trashActiveCount");
const int stage = GameManager::getSharedData<int>("gameStage");
// Periodically spawn a friendly on land or sea with a small probability
// evaluated each update using deltaTime so the average interval is respected.
const int activeFriendlies = GameManager::getSharedData<int>("friendlyActiveCount");
if (activeFriendlies < mMaxAutoFriendlies) {
// Compute chance = deltaTime / avgInterval
const float chance = deltaTime / std::max(0.0001f, mFriendlySpawnAvgInterval);
const int thresh = static_cast<int>(chance * 10000.f);
if (thresh > 0) {
const int roll = Utils::getUtils().rirng32(0, 9999);
if (roll < thresh) {
// Decide side: sea probability increases with stage
float seaProb = (stage > 1) ? 0.3f : 0.1f;
const int sideRoll = Utils::getUtils().rirng32(0, 99);
const bool spawnSea = sideRoll < static_cast<int>(seaProb * 100.f);
Object::Transform tS;
tS.x = 0.f;
tS.y = 0.f;
tS.rotation = 0.f;
tS.scaleX = 4.0f;
tS.scaleY = 4.0f;
const float viewLeft = -mW / 2.f;
const float viewRight = mW / 2.f;
const float viewTop = -mH / 2.f;
const float viewBottom = mH / 2.f;
const float halfFriendlyW = mFriendlyTex->getWidth() * tS.adjustedScaleX() / 2.f;
const float halfFriendlyH = mFriendlyTex->getHeight() * tS.adjustedScaleY() / 2.f;
if (!spawnSea) {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewLeft + halfFriendlyW + 25.f), static_cast<int>(mLandBoundaryX - halfFriendlyW - 25.f)));
} else {
tS.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(mLandBoundaryX + halfFriendlyW + 25.f), static_cast<int>(viewRight - halfFriendlyW - 25.f)));
}
tS.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(viewTop + halfFriendlyH + 100.f), static_cast<int>(viewBottom - halfFriendlyH - 100.f)));
const int id = Utils::getUtils().rirng32(0, 1000000);
const std::string name = std::string("Friendly_Auto_") + std::to_string(id);
auto* friendly = State::GameState::getInstance().addEntity(std::make_unique<AGame::Friendly>(name, mFriendlyTex, tS));
if (friendly) {
if (auto* collider = friendly->getComponent<Object::Components::BoxCollider>()) {
collider->setScale(0.75f);
}
GameManager::setSharedData("friendlyActiveCount", GameManager::getSharedData<int>("friendlyActiveCount") + 1);
}
}
}
}
if (mPendingLevelSpawn) {
if (enemyCount <= 0 && trashCount <= 0) {
GameManager::processPendingEntityRemovals();
mPendingLevelSpawn = false;
spawnLevel(mPendingLevelStage);
}
return;
}
if (enemyCount <= 0 && trashCount <= 0) {
if (stage < mMaxLevels) {
mPendingLevelSpawn = true;
mPendingLevelStage = stage + 1;
} else if (!GameManager::getSharedData<bool>("gameWon")) {
writeFinalScoreFile(GameManager::getSharedData<int>("gameScore"));
refreshLeaderboardHudText();
GameManager::setSharedData("gameWon", true);
LOG("Vsi nivoji so zaključeni");
}
}
}
void Background::onWindowResized(int newWidth, int newHeight) {
mW = newWidth;
mH = newHeight;
// Always maintain logical world dimensions (1280×720)
mW = 1280;
mH = 720;
}
}

View File

@@ -1,22 +1,175 @@
#include <game/agame/enemy.hpp>
#include <game/agame/background.hpp>
#include <object/components/boxcollider.hpp>
#include <game/agame/player.hpp>
#include <state/gamestate.hpp>
#include <algorithm>
#include <cmath>
#include <utils.hpp>
#include <game/gamemanager.hpp>
#include <window/window.hpp>
namespace Game::AGame {
void Enemy::start() {
mZIndex = 20;
addComponent<Object::Components::BoxCollider>();
LOG("Enemy started: " << getName());
LOG("Sovražnik zagnan: " << getName());
// Initialize random movement
const float angle = static_cast<float>(Utils::getUtils().rirng32(0, 360)) * 3.14159f / 180.f;
const float speed = 20.f + static_cast<float>(Utils::getUtils().rirng32(0, 30));
mMoveSpeedX = std::cos(angle) * speed;
mMoveSpeedY = std::sin(angle) * speed;
mDirectionChangeTimer = 0.f;
}
void Enemy::update(float deltaTime) {
return;
(void)deltaTime;
auto* player = GameManager::getEntityByName<Player>("Player");
if (!player) {
mIsVisible = false;
return;
}
// Enemies are visible only within a reveal radius around the player
const float revealRadius = GameManager::getSharedData<float>("enemyRevealRadius");
const float px = player->getTransform()->x + (player->getTexture() ? player->getTexture()->getWidth() * player->getTransform()->adjustedScaleX() / 2.f : 0.f);
const float py = player->getTransform()->y + (player->getTexture() ? player->getTexture()->getHeight() * player->getTransform()->adjustedScaleY() / 2.f : 0.f);
const float ew = getTexture() ? getTexture()->getWidth() * mTransform.adjustedScaleX() : 0.f;
const float eh = getTexture() ? getTexture()->getHeight() * mTransform.adjustedScaleY() : 0.f;
const float ex = mTransform.x + ew / 2.f;
const float ey = mTransform.y + eh / 2.f;
const float dxv = px - ex;
const float dyv = py - ey;
mIsVisible = (dxv * dxv + dyv * dyv) <= (revealRadius * revealRadius);
// Check if player is on land (not in ship mode) and within follow distance
const float distanceToPlayer = std::sqrt(dxv * dxv + dyv * dyv);
const bool playerOnLand = !player->isShipMode();
const bool withinFollowRange = distanceToPlayer <= FOLLOW_DISTANCE;
if (playerOnLand && withinFollowRange) {
// Follow player: calculate direction and move at constant speed
mFollowingPlayer = true;
if (distanceToPlayer > 1.f) { // Avoid division by zero
mMoveSpeedX = (dxv / distanceToPlayer) * FOLLOW_SPEED;
mMoveSpeedY = (dyv / distanceToPlayer) * FOLLOW_SPEED;
}
} else {
// Revert to random movement when player is on sea or out of range
mFollowingPlayer = false;
// Semi-random movement with periodic direction changes
mDirectionChangeTimer += deltaTime;
if (mDirectionChangeTimer > 2.0f) {
const float angle = static_cast<float>(Utils::getUtils().rirng32(0, 360)) * 3.14159f / 180.f;
const float speed = 20.f + static_cast<float>(Utils::getUtils().rirng32(0, 30));
mMoveSpeedX = std::cos(angle) * speed;
mMoveSpeedY = std::sin(angle) * speed;
mDirectionChangeTimer = 0.f;
}
}
// Move enemy
mTransform.x += mMoveSpeedX * deltaTime;
mTransform.y += mMoveSpeedY * deltaTime;
// Decrease shoreline-spawn cooldown
if (mShoreSpawnCooldown > 0.f) mShoreSpawnCooldown = std::max(0.f, mShoreSpawnCooldown - deltaTime);
// Clamp to land section
const float landBoundaryX = GameManager::getSharedData<float>("terrainLandBoundaryX");
const float entityWidth = getTexture() ? getTexture()->getWidth() * mTransform.adjustedScaleX() : 0.f;
const float entityHeight = getTexture() ? getTexture()->getHeight() * mTransform.adjustedScaleY() : 0.f;
const float halfWidth = entityWidth / 2.f;
const float halfHeight = entityHeight / 2.f;
// Use logical world dimensions (1280×720) not actual screen size
constexpr int w = 1280;
constexpr int h = 720;
const float leftEdge = -w / 2.f + 25.f;
if (mTransform.x - halfWidth < leftEdge) {
mTransform.x = leftEdge + halfWidth;
mMoveSpeedX = std::abs(mMoveSpeedX);
}
if (mTransform.x + halfWidth > landBoundaryX - 25.f) {
mTransform.x = landBoundaryX - 25.f - halfWidth;
mMoveSpeedX = -std::abs(mMoveSpeedX);
// Enemy hit the shoreline on the right side; spawn a trash on the sea side
if (mShoreSpawnCooldown <= 0.f) {
auto* bg = GameManager::getEntityByName<AGame::Background>("BG");
if (bg) {
Object::Transform t;
t.rotation = 0.f;
t.scaleX = 5.5f;
t.scaleY = 5.5f;
// Place trash on sea side at same Y
t.y = mTransform.y;
t.x = landBoundaryX + 10.f; // will be adjusted inside spawnTrashAt
bg->spawnTrashAt(t, true);
}
mShoreSpawnCooldown = 3.0f; // 3 second cooldown per enemy
}
}
if (mTransform.y - halfHeight < -h / 2.f + 25.f) {
mTransform.y = -h / 2.f + 25.f + halfHeight;
mMoveSpeedY = std::abs(mMoveSpeedY);
}
if (mTransform.y + halfHeight > h / 2.f - 25.f) {
mTransform.y = h / 2.f - 25.f - halfHeight;
mMoveSpeedY = -std::abs(mMoveSpeedY);
}
}
bool Enemy::hasAdjacentEnemy() {
if (!getTexture()) return false;
const float detectionRadius = 40.f;
const float enemyWidth = getTexture()->getWidth() * mTransform.adjustedScaleX();
const float enemyHeight = getTexture()->getHeight() * mTransform.adjustedScaleY();
const float centerX = mTransform.x + enemyWidth / 2.f;
const float centerY = mTransform.y + enemyHeight / 2.f;
auto entities = GameManager::getEntityByName<Object::Entity>("Dummy");
if (!entities) {
auto snapshot = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* other : snapshot) {
if (!other || other == this || !dynamic_cast<Enemy*>(other)) continue;
auto* otherEnemy = dynamic_cast<Enemy*>(other);
if (!otherEnemy || !otherEnemy->getTexture()) continue;
const float otherWidth = otherEnemy->getTexture()->getWidth() * otherEnemy->getTransform()->adjustedScaleX();
const float otherHeight = otherEnemy->getTexture()->getHeight() * otherEnemy->getTransform()->adjustedScaleY();
const float otherCenterX = otherEnemy->getTransform()->x + otherWidth / 2.f;
const float otherCenterY = otherEnemy->getTransform()->y + otherHeight / 2.f;
const float dx = centerX - otherCenterX;
const float dy = centerY - otherCenterY;
if (dx * dx + dy * dy <= detectionRadius * detectionRadius) {
return true;
}
}
}
return false;
}
void Enemy::onCollisionEnter(Object::Entity* other) {
LOG("Enemy '" << getName() << "' collided with '" << other->getName() << "' (onCollisionEnter); Killing myself now!");
GameManager::setSharedData("enemyActiveCount", GameManager::getSharedData<int>("enemyActiveCount") - 1);
// Find in state
auto* player = dynamic_cast<Player*>(other);
if (!player || !mIsVisible) {
return;
}
if (hasAdjacentEnemy()) {
LOG("Igralec je trčil v močno skupino onesnaževalcev; konec igre!");
GameManager::setSharedData("gameLost", true);
GameManager::destroyEntity(player);
return;
}
LOG("Sovražnik '" << getName() << "' je trčil v igralca; odstranjujem onesnaževalca in dodeljujem točke");
GameManager::setSharedData("enemyActiveCount", std::max(0, GameManager::getSharedData<int>("enemyActiveCount") - 1));
GameManager::setSharedData("gameScore", GameManager::getSharedData<int>("gameScore") + 100);
GameManager::destroyEntity(this);
}
}

164
src/game/agame/friendly.cpp Normal file
View File

@@ -0,0 +1,164 @@
#include <game/agame/friendly.hpp>
#include <game/gamemanager.hpp>
#include <game/agame/player.hpp>
#include <game/agame/trash.hpp>
#include <object/components/boxcollider.hpp>
#include <state/gamestate.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utils.hpp>
#include <window/window.hpp>
namespace Game::AGame {
void Friendly::start() {
mZIndex = 20;
addComponent<Object::Components::BoxCollider>();
LOG("Zaveznik zagnan: " << getName());
const float landBoundaryX = GameManager::getSharedData<float>("terrainLandBoundaryX");
const float entityWidth = getTexture() ? getTexture()->getWidth() * mTransform.adjustedScaleX() : 0.f;
mOnSea = (mTransform.x + entityWidth / 2.f) > landBoundaryX;
if (!mOnSea) {
// Initialize random movement for land friendlies
const float angle = static_cast<float>(Utils::getUtils().rirng32(0, 360)) * 3.14159f / 180.f;
const float speed = LAND_SPEED_MIN + static_cast<float>(Utils::getUtils().rirng32(0, static_cast<int>(LAND_SPEED_MAX - LAND_SPEED_MIN)));
mMoveSpeedX = std::cos(angle) * speed;
mMoveSpeedY = std::sin(angle) * speed;
} else {
mMoveSpeedX = 0.f;
mMoveSpeedY = 0.f;
}
mDirectionChangeTimer = 0.f;
}
void Friendly::update(float deltaTime) {
// Auto-cleanup nearby trash
const float fw = getTexture() ? getTexture()->getWidth() * mTransform.adjustedScaleX() : 0.f;
const float fh = getTexture() ? getTexture()->getHeight() * mTransform.adjustedScaleY() : 0.f;
const float friendlyCenterX = mTransform.x + fw / 2.f;
const float friendlyCenterY = mTransform.y + fh / 2.f;
const float landBoundaryX = GameManager::getSharedData<float>("terrainLandBoundaryX");
mOnSea = friendlyCenterX > landBoundaryX;
if (mOnSea) {
auto snapshot = State::GameState::getInstance().getEntitiesSnapshot();
Trash* nearestTrash = nullptr;
float nearestDistanceSquared = std::numeric_limits<float>::max();
for (auto* entity : snapshot) {
if (!entity) continue;
auto* trash = dynamic_cast<Trash*>(entity);
if (!trash) continue;
const float tw = trash->getTexture() ? trash->getTexture()->getWidth() * trash->getTransform()->adjustedScaleX() : 0.f;
const float th = trash->getTexture() ? trash->getTexture()->getHeight() * trash->getTransform()->adjustedScaleY() : 0.f;
const float trashCenterX = trash->getTransform()->x + tw / 2.f;
const float trashCenterY = trash->getTransform()->y + th / 2.f;
const float dx = trashCenterX - friendlyCenterX;
const float dy = trashCenterY - friendlyCenterY;
const float distanceSquared = dx * dx + dy * dy;
if (distanceSquared < nearestDistanceSquared) {
nearestDistanceSquared = distanceSquared;
nearestTrash = trash;
}
}
if (nearestTrash) {
const float tw = nearestTrash->getTexture() ? nearestTrash->getTexture()->getWidth() * nearestTrash->getTransform()->adjustedScaleX() : 0.f;
const float th = nearestTrash->getTexture() ? nearestTrash->getTexture()->getHeight() * nearestTrash->getTransform()->adjustedScaleY() : 0.f;
const float trashCenterX = nearestTrash->getTransform()->x + tw / 2.f;
const float trashCenterY = nearestTrash->getTransform()->y + th / 2.f;
const float dx = trashCenterX - friendlyCenterX;
const float dy = trashCenterY - friendlyCenterY;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance > 0.0001f) {
mMoveSpeedX = (dx / distance) * SEA_SPEED;
mMoveSpeedY = (dy / distance) * SEA_SPEED;
}
}
} else {
// Semi-random movement with periodic direction changes on land
mDirectionChangeTimer += deltaTime;
if (mDirectionChangeTimer > 2.0f) {
const float angle = static_cast<float>(Utils::getUtils().rirng32(0, 360)) * 3.14159f / 180.f;
const float speed = LAND_SPEED_MIN + static_cast<float>(Utils::getUtils().rirng32(0, static_cast<int>(LAND_SPEED_MAX - LAND_SPEED_MIN)));
mMoveSpeedX = std::cos(angle) * speed;
mMoveSpeedY = std::sin(angle) * speed;
mDirectionChangeTimer = 0.f;
}
}
auto snapshot = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* entity : snapshot) {
if (!entity) continue;
auto* trash = dynamic_cast<Trash*>(entity);
if (!trash) continue;
// Calculate distance to trash
const float tw = trash->getTexture() ? trash->getTexture()->getWidth() * trash->getTransform()->adjustedScaleX() : 0.f;
const float th = trash->getTexture() ? trash->getTexture()->getHeight() * trash->getTransform()->adjustedScaleY() : 0.f;
const float trashCenterX = trash->getTransform()->x + tw / 2.f;
const float trashCenterY = trash->getTransform()->y + th / 2.f;
const float dx = friendlyCenterX - trashCenterX;
const float dy = friendlyCenterY - trashCenterY;
const float distanceSquared = dx * dx + dy * dy;
if (distanceSquared <= CLEANUP_RADIUS * CLEANUP_RADIUS) {
// Clean up this trash: award points and remove it
GameManager::setSharedData("gameScore", GameManager::getSharedData<int>("gameScore") + CLEANUP_SCORE_BONUS);
GameManager::setSharedData("trashActiveCount", std::max(0, GameManager::getSharedData<int>("trashActiveCount") - 1));
GameManager::destroyEntity(trash);
}
}
// Move friendly
mTransform.x += mMoveSpeedX * deltaTime;
mTransform.y += mMoveSpeedY * deltaTime;
// Clamp to land section
const float halfWidth = fw / 2.f;
const float halfHeight = fh / 2.f;
// Use logical world dimensions (1280×720) not actual screen size
constexpr int w = 1280;
constexpr int h = 720;
const float leftEdge = -w / 2.f + 25.f;
if (mTransform.x - halfWidth < leftEdge) {
mTransform.x = leftEdge + halfWidth;
mMoveSpeedX = std::abs(mMoveSpeedX);
}
if (!mOnSea && mTransform.x + halfWidth > landBoundaryX - 25.f) {
mTransform.x = landBoundaryX - 25.f - halfWidth;
mMoveSpeedX = -std::abs(mMoveSpeedX);
}
if (mOnSea && mTransform.x - halfWidth < landBoundaryX + 25.f) {
mTransform.x = landBoundaryX + 25.f + halfWidth;
mMoveSpeedX = std::abs(mMoveSpeedX);
}
if (mTransform.y - halfHeight < -h / 2.f + 25.f) {
mTransform.y = -h / 2.f + 25.f + halfHeight;
mMoveSpeedY = std::abs(mMoveSpeedY);
}
if (mTransform.y + halfHeight > h / 2.f - 25.f) {
mTransform.y = h / 2.f - 25.f - halfHeight;
mMoveSpeedY = -std::abs(mMoveSpeedY);
}
}
void Friendly::onCollisionEnter(Object::Entity* other) {
auto* player = dynamic_cast<Player*>(other);
if (!player) {
return;
}
GameManager::setSharedData("friendlyActiveCount", std::max(0, GameManager::getSharedData<int>("friendlyActiveCount") - 1));
GameManager::setSharedData("gameScore", GameManager::getSharedData<int>("gameScore") - 50);
GameManager::destroyEntity(this);
}
}

View File

@@ -0,0 +1,78 @@
#include <game/agame/hudtext.hpp>
#include <game/gamemanager.hpp>
#include <window/window.hpp>
#include <sstream>
namespace Game::AGame {
void HUDText::start() {
mZIndex = 1000;
Object::UIText::start();
setText("Level 1 | Score 0 | Trash 0 | Polluters 0");
}
void HUDText::update(float deltaTime) {
(void)deltaTime;
// Use logical world dimensions (1280×720) not actual screen size
constexpr int windowW = 1280;
constexpr int windowH = 720;
float camX = 0.f;
float camY = 0.f;
Object::Camera::getInstance().getPosition(camX, camY);
auto anchorTopRight = [&]() {
if (!mTex) {
return;
}
const float marginX = 24.f;
const float marginY = 24.f;
const float textWidth = mTex->getWidth() * mTransform.adjustedScaleX();
mTransform.x = camX + windowW / 2.f - marginX - textWidth;
mTransform.y = camY - windowH / 2.f + marginY;
};
if (GameManager::getSharedData<bool>("gameLost")) {
const std::string leaderboardText = GameManager::getSharedData<std::string>("leaderboardText");
std::string endText = "Umrl si!";
if (!leaderboardText.empty()) {
endText += "\n\n" + leaderboardText;
}
if (getText() != endText) {
Window::Window::postToMainThread([this, endText]() { setText(endText); });
}
anchorTopRight();
return;
}
if (GameManager::getSharedData<bool>("gameWon")) {
const std::string leaderboardText = GameManager::getSharedData<std::string>("leaderboardText");
std::string endText = "Zmagal si!";
if (!leaderboardText.empty()) {
endText += "\n\n" + leaderboardText;
}
if (getText() != endText) {
Window::Window::postToMainThread([this, endText]() { setText(endText); });
}
anchorTopRight();
return;
}
const std::string playerName = GameManager::getSharedData<std::string>("playerName");
std::stringstream stream;
stream << "Igralec: " << (playerName.empty() ? std::string("Anonimni") : playerName)
<< " | Level " << GameManager::getSharedData<int>("gameStage")
<< " | Točke " << GameManager::getSharedData<int>("gameScore")
<< " | Smeti " << GameManager::getSharedData<int>("trashActiveCount")
<< " | Sovražniki " << GameManager::getSharedData<int>("enemyActiveCount");
const std::string newHudText = stream.str();
if (getText() != newHudText) {
Window::Window::postToMainThread([this, newHudText]() { setText(newHudText); });
}
anchorTopRight();
}
}

View File

@@ -3,8 +3,60 @@
#include <cmath>
#include <game/input.hpp>
#include <game/gamemanager.hpp>
#include <utils.hpp>
namespace Game::AGame {
void Player::setShipTexture(std::shared_ptr<Game::Renderer::Texture> tex) {
mShipTex = std::move(tex);
if (mIsShipMode && mShipTex) {
setTexture(mShipTex);
}
}
void Player::setGroundTexture(std::shared_ptr<Game::Renderer::Texture> tex) {
mGroundTex = std::move(tex);
if (!mIsShipMode && mGroundTex) {
setTexture(mGroundTex);
}
}
void Player::respawnRandomSea(float landBoundaryX) {
auto spawnTex = mShipTex ? mShipTex : mTex;
if (!spawnTex) {
return;
}
// Use logical world dimensions (1280×720) not actual screen size
constexpr int w = 1280;
constexpr int h = 720;
const float halfWidth = spawnTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
const float halfHeight = spawnTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
const float minCenterX = landBoundaryX + mShoreMargin + halfWidth;
const float maxCenterX = w / 2.f - halfWidth - 10.f;
const float minCenterY = -h / 2.f + halfHeight;
const float maxCenterY = h / 2.f - halfHeight;
float centerX = minCenterX;
if (maxCenterX > minCenterX) {
centerX = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(minCenterX), static_cast<int>(maxCenterX)));
}
float centerY = 0.f;
if (maxCenterY > minCenterY) {
centerY = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(minCenterY), static_cast<int>(maxCenterY)));
}
mTransform.x = centerX - halfWidth;
mTransform.y = centerY - halfHeight;
mIsShipMode = true;
if (mShipTex) {
setTexture(mShipTex);
}
}
void Player::start() {
//mSound = Object::Sound("../resources/example.wav", Object::Format::WAV);
//mSound.play();
@@ -12,15 +64,41 @@ namespace Game::AGame {
Game::GameManager::setSharedData("gameStage", 1);
Game::GameManager::setSharedData("gameScore", 0);
int w, h;
SDL_GetWindowSizeInPixels(Window::Window::getSDLWindowBackend(), &w, &h);
mTransform.scaleX = 5.3f;
mTransform.scaleY = 5.3f;
mTransform.scaleX = 8.f;
mTransform.scaleY = 8.f;
if (!mShipTex) {
mShipTex = mTex;
}
if (!mGroundTex) {
mGroundTex = mTex;
}
// Use logical world dimensions (1280×720) not actual screen size
constexpr int w = 1280;
constexpr int h = 720;
const float halfWidth = mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
const float halfHeight = mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
const float minX = -w / 2.f + halfWidth;
const float maxX = w / 2.f - halfWidth;
const float minY = -h / 2.f + halfHeight;
const float maxY = h / 2.f - halfHeight;
const float landBoundaryX = Game::GameManager::getSharedData<float>("terrainLandBoundaryX");
mTransform.x = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(minX), static_cast<int>(maxX)));
mTransform.y = static_cast<float>(Utils::getUtils().rirng32(static_cast<int>(minY), static_cast<int>(maxY)));
mIsShipMode = (mTransform.x + halfWidth) >= landBoundaryX;
mTransform.x -= mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
if (mIsShipMode && mShipTex) {
setTexture(mShipTex);
} else if (!mIsShipMode && mGroundTex) {
setTexture(mGroundTex);
}
LOG("W: " << w << " H: " << h);
//mSound.~Sound();
}
@@ -31,11 +109,68 @@ namespace Game::AGame {
//mTransform.scaleY = 1.f + 0.5f * std::cos(RUNNING_TIME() / 0.5f); // Pulsate scale for testing
//Object::Camera::getInstance().move(1.f, 0.f);
if (mStateTransitionCooldownTimer > 0.f) {
mStateTransitionCooldownTimer -= deltaTime;
if (mStateTransitionCooldownTimer < 0.f) {
mStateTransitionCooldownTimer = 0.f;
}
}
const float landBoundaryX = Game::GameManager::getSharedData<float>("terrainLandBoundaryX");
const float halfWidth = mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
if (Input::isKeyPressed(SDL_SCANCODE_E)) {
const bool nearShore = std::abs((mTransform.x + halfWidth) - landBoundaryX) <= mShoreMargin;
if (nearShore && mStateTransitionCooldownTimer <= 0.f) {
mIsShipMode = !mIsShipMode;
mStateTransitionCooldownTimer = mStateTransitionCooldown;
}
}
// Simple movement
if (Input::isKeyPressed(SDL_SCANCODE_W)) { mTransform.y -= mSpeed * deltaTime; }
if (Input::isKeyPressed(SDL_SCANCODE_S)) { mTransform.y += mSpeed * deltaTime; }
if (Input::isKeyPressed(SDL_SCANCODE_A)) { mTransform.x -= mSpeed * deltaTime; mIsFlipped = false; }
if (Input::isKeyPressed(SDL_SCANCODE_D)) { mTransform.x += mSpeed * deltaTime; mIsFlipped = true; }
mSpeed = Input::isKeyPressed(SDL_SCANCODE_LSHIFT) ? 400.f : 200.f;
// Use logical world dimensions (1280×720) not actual screen size
constexpr int w = 1280;
constexpr int h = 720;
const float entityWidth = mTex ? mTex->getWidth() * mTransform.adjustedScaleX() : 0.f;
const float entityHeight = mTex ? mTex->getHeight() * mTransform.adjustedScaleY() : 0.f;
const float minX = -w / 2.f;
const float maxX = w / 2.f - entityWidth;
const float minY = -h / 2.f;
const float maxY = h / 2.f - entityHeight;
if (mTransform.x < minX) mTransform.x = minX;
if (mTransform.x > maxX) mTransform.x = maxX;
if (mTransform.y < minY) mTransform.y = minY;
if (mTransform.y > maxY) mTransform.y = maxY;
if (mIsShipMode && (mTransform.x + halfWidth) < landBoundaryX + mShoreMargin) {
mTransform.x = landBoundaryX + mShoreMargin - halfWidth;
}
if (!mIsShipMode && (mTransform.x + halfWidth) > landBoundaryX - mShoreMargin) {
mTransform.x = landBoundaryX - mShoreMargin - halfWidth;
}
if (mIsShipMode && mShipTex) {
setTexture(mShipTex);
} else if (!mIsShipMode && mGroundTex) {
setTexture(mGroundTex);
}
// Push replay (position and form state)
GameManager::pushPlayerPosition(mTransform);
GameManager::pushPlayerFormState(mIsShipMode);
}
void Player::onCollisionEnter(Object::Entity* other) {
(void)other;
if (GameManager::getSharedData<bool>("gameLost")) {
GameManager::destroyEntity(this);
}
}
}

View File

@@ -1,11 +1,40 @@
#include <game/agame/trash.hpp>
#include <game/agame/player.hpp>
#include <object/components/boxcollider.hpp>
#include <algorithm>
namespace Game::AGame {
void Trash::start() {
mZIndex = 20;
addComponent<Object::Components::BoxCollider>();
}
void Trash::update(float deltaTime) {
return;
(void)deltaTime;
/*if (mSeaOnly) {
const float landBoundaryX = GameManager::getSharedData<float>("terrainLandBoundaryX");
const float margin = 25.f;
const float halfWidth = getTexture() ? getTexture()->getWidth() * mTransform.adjustedScaleX() / 2.f : 0.f;
if (mTransform.x - halfWidth < landBoundaryX + margin) {
mTransform.x = landBoundaryX + margin + halfWidth;
}
}*/
// Naključno premikanje
mTransform.x += static_cast<float>(Utils::getUtils().rirng32(-50, 50)) * deltaTime;
mTransform.y += static_cast<float>(Utils::getUtils().rirng32(-50, 50)) * deltaTime;
//return;
}
void Trash::onCollisionEnter(Object::Entity* other) {
auto* player = dynamic_cast<Player*>(other);
if (!player || !player->isShipMode()) {
return;
}
GameManager::setSharedData("trashActiveCount", std::max(0, GameManager::getSharedData<int>("trashActiveCount") - 1));
GameManager::setSharedData("gameScore", GameManager::getSharedData<int>("gameScore") + 25);
GameManager::destroyEntity(this);
}
}

View File

@@ -1,8 +1,14 @@
#include <game/gamemanager.hpp>
#include <algorithm>
#include <fstream>
#include <sstream>
#include <game/agame/player.hpp>
#include <state/gamestate.hpp>
namespace Game {
GameStateEnum GameManager::mCurrentGameState = GameStateEnum::RUNNING;
std::vector<GameManager::ReplayFrame> 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 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;
}
// 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());
@@ -67,6 +84,8 @@ namespace Game {
std::unordered_map<std::string, int> GameManager::mSharedInts;
std::unordered_map<std::string, float> GameManager::mSharedFloats;
std::unordered_map<std::string, bool> GameManager::mSharedBools;
std::vector<Object::Transform> GameManager::mPlayerTransformHistory;
std::vector<bool> GameManager::mPlayerFormHistory;
void GameManager::removeSharedData(const std::string& key, SharedDataType type) {
if (type == SharedDataType::STRING) {
@@ -83,5 +102,80 @@ namespace Game {
void GameManager::processPendingEntityRemovals() {
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 <window/window.hpp>
#include <object/ui/uitextbox.hpp>
#include <state/gamestate.hpp>
namespace Game {
const bool* Input::mCurrentKeyStates = nullptr;
const bool* Input::mPreviousKeyStates = nullptr;
std::vector<Uint8> 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<std::string> 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<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) {
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<int>(mPreviousKeyStates.size())) ? static_cast<bool>(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<int>(mPreviousKeyStates.size())) ? static_cast<bool>(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<std::string>& out) {
std::scoped_lock lock(mTextMutex);
out.swap(mPendingText);
}
}

View File

@@ -5,33 +5,274 @@
#include <object/transform.hpp>
#include <game/agame/player.hpp>
#include <game/agame/background.hpp>
#include <game/agame/hudtext.hpp>
#include <game/gamemanager.hpp>
#include <renderer/renderer.hpp>
#include <renderer/texture.hpp>
#include <renderer/font.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 <atomic>
using namespace Game;
// Global SDL renderer pointer used by menu callback to create textures/fonts
static SDL_Renderer* gSDLRenderer = nullptr;
static Uint32 gStartGameEventType = static_cast<Uint32>(-1);
static std::atomic_bool gStartGameQueued = false;
namespace {
// 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);
// Spawn the main game entities now that we have a player name
// Background
State::GameState::getInstance().addEntity(std::make_unique<AGame::Background>("BG", std::make_shared<Game::Renderer::Texture>("../resources/bgtest.png", gSDLRenderer), Object::DEFAULT_TRANSFORM));
// Player
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));
}
// 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");
State::GameState::getInstance().addEntity(std::make_unique<AGame::SampleTextBox>("Sample Text Box", std::make_shared<Game::Renderer::Font>("../resources/roboto.ttf", window.getRenderer()->getSDLRenderer(), 48, "Roboto"), Object::DEFAULT_TRANSFORM, 640.f, 360.f));
// Make SDL renderer available to callbacks
gSDLRenderer = window.getRenderer()->getSDLRenderer();
//Object::Transform t1{100.f, 100.f, 0.f, 1.f, 1.f};
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));
Object::Entity* player = State::GameState::getInstance().addEntity(std::make_unique<AGame::Player>("Player", std::make_shared<Game::Renderer::Texture>("../resources/l3ladja.png", window.getRenderer()->getSDLRenderer()), Object::DEFAULT_TRANSFORM));
player->addComponent<Object::Components::BoxCollider>();
//State::GameState::getInstance().addEntity(std::make_unique<AGame::Player>("Player2", std::make_shared<Game::Renderer::Font>("../resources/roboto.ttf", window.getRenderer()->getSDLRenderer(), 128, "Roboto"), t1));
// Register a custom event used to perform menu -> game transition on the
// window/event thread.
gStartGameEventType = SDL_RegisterEvents(1);
// Sample textbox
State::GameState::getInstance().addEntity(std::make_unique<AGame::SampleTextBox>("Sample Text Box", std::make_shared<Game::Renderer::Font>("../resources/roboto.ttf", window.getRenderer()->getSDLRenderer(), 48, "Roboto"), Object::DEFAULT_TRANSFORM, 640.f, 360.f));
// 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("Dol s Plastiko!");
// 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("Vnesi ime...");
// 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("Začni igro");
// 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();
return 0;
}

View File

@@ -6,7 +6,7 @@
namespace Game::Object::Components {
BoxCollider::BoxCollider(const BoxCollider& other) : Component(other) {
LOG("Copied BoxCollider Component: " << mName);
LOG("Kopiran BoxCollider komponenta: " << mName);
}
BoxCollider& BoxCollider::operator=(const BoxCollider& other) {
@@ -17,7 +17,7 @@ namespace Game::Object::Components {
}
BoxCollider::BoxCollider(BoxCollider&& other) noexcept : Component(std::move(other)) {
LOG("Moved BoxCollider Component: " << mName);
LOG("Premaknjena BoxCollider komponenta: " << mName);
}
BoxCollider& BoxCollider::operator=(BoxCollider&& other) noexcept {
@@ -50,16 +50,16 @@ namespace Game::Object::Components {
float width = 1.f;
float height = 1.f;
if (const auto tex = thisEntity->getTexture()) {
width = tex->getWidth() * transform->scaleX * UNIVERSAL_SCALE_COEFFICIENT;
height = tex->getHeight() * transform->scaleY * UNIVERSAL_SCALE_COEFFICIENT;
width = tex->getWidth() * transform->scaleX * UNIVERSAL_SCALE_COEFFICIENT * mScale;
height = tex->getHeight() * transform->scaleY * UNIVERSAL_SCALE_COEFFICIENT * mScale;
} else {
width = transform->scaleX * UNIVERSAL_SCALE_COEFFICIENT;
height = transform->scaleY * UNIVERSAL_SCALE_COEFFICIENT;
width = transform->scaleX * UNIVERSAL_SCALE_COEFFICIENT * mScale;
height = transform->scaleY * UNIVERSAL_SCALE_COEFFICIENT * mScale;
}
width = std::max(1.f, width);
height = std::max(1.f, height);
// Transform position is used as top-left in rendering, so match that convention for collision bounds.
// Entity rendering uses top-left transform coordinates; collider must match that space.
float left = transform->x;
float right = transform->x + width;
float top = transform->y;

View File

@@ -9,7 +9,7 @@ namespace Game::Object {
Entity::~Entity() = default;
Entity::Entity(const Entity& other) : mName(other.mName), mTex(other.mTex), mTransform(other.mTransform), mIsActive(other.mIsActive) {
LOG("Copied Entity: " << mName);
LOG("Kopirana entiteta: " << mName);
}
Entity& Entity::operator=(const Entity& other) {
@@ -24,7 +24,7 @@ namespace Game::Object {
Entity::Entity(Entity&& other) noexcept : mName(std::move(other.mName)), mTex(other.mTex), mTransform(other.mTransform), mIsActive(other.mIsActive) {
other.mTex = nullptr;
LOG("Moved Entity: " << mName);
LOG("Premaknjena entiteta: " << mName);
}
Entity& Entity::operator=(Entity&& other) noexcept {
@@ -46,7 +46,7 @@ namespace Game::Object {
SDL_GetTextureSize(mTex->getSDLTexture(), &w, &h);
SDL_FRect dst;
dst.w = w * mTransform.scaleX * UNIVERSAL_SCALE_COEFFICIENT; // 1.f is HUGE, so this is just a constant to make the default scale more reasonable
dst.w = w * mTransform.scaleX * UNIVERSAL_SCALE_COEFFICIENT;
dst.h = h * mTransform.scaleY * UNIVERSAL_SCALE_COEFFICIENT;
// Top-left origin; Account for camera position (center the camera on the screen)
@@ -73,7 +73,7 @@ namespace Game::Object {
SDL_GetTextureSize(mTex->getSDLTexture(), &tileW, &tileH);
SDL_FRect dst;
dst.w = tileW * mTiledScale; // Tile size is the original texture size multiplied by the universal scale coefficient (ignoring the entity's scale, since that only affects how many times the texture is tiled, not the size of each tile)
dst.w = tileW * mTiledScale;
dst.h = tileH * mTiledScale;
// Top-left origin; Account for camera position (center the camera on the screen)

View File

@@ -1,13 +1,14 @@
#include <object/ui/uibutton.hpp>
#include <window/window.hpp>
namespace Game::Object {
UIButton::UIButton(const std::string& name, std::shared_ptr<Renderer::Texture> texture, const Transform& transform, void* clickFunction, float x, float y)
: Entity(name, texture, transform), mClickFunction(clickFunction), mX(x), mY(y) { }
void UIButton::start() {
// Center the button on the position
mTransform.x -= mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
// Center the button on the requested position
mTransform.x = mX - mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y = mY - mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
}
void UIButton::update(float deltaTime) {
@@ -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<Renderer::Font>(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<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) {
using ClickFnType = void(*)();
ClickFnType clickFn = reinterpret_cast<ClickFnType>(mClickFunction);
@@ -32,7 +46,9 @@ namespace Game::Object {
void UIButton::setText(const std::string& 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 {

View File

@@ -1,13 +1,14 @@
#include <object/ui/uitext.hpp>
#include <window/window.hpp>
namespace Game::Object {
UIText::UIText(const std::string& name, std::shared_ptr<Renderer::Font> font, const Transform& transform, float x, float y)
: Entity(name, font, transform), mX(x), mY(y) { }
void UIText::start() {
// Center the text on the position
mTransform.x -= mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
// Center the text on the requested position
mTransform.x = mX - mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y = mY - mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
}
void UIText::update(float deltaTime) {} /* {
@@ -31,7 +32,15 @@ namespace Game::Object {
void UIText::setText(const std::string& 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 {

View File

@@ -1,147 +1,297 @@
#include <object/ui/uitextbox.hpp>
#include <renderer/renderer.hpp>
#include <window/window.hpp>
namespace Game::Object {
UITextBox::UITextBox(const std::string& name, std::shared_ptr<Renderer::Font> 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<Renderer::Texture> background, std::shared_ptr<Renderer::Font> 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<Renderer::Texture>(mFont);
setTexture(mBackground);
}
}
void UITextBox::start() {
mTransform.x -= mTex->getWidth() * mTransform.adjustedScaleX() / 2.f;
mTransform.y -= mTex->getHeight() * mTransform.adjustedScaleY() / 2.f;
// 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;
}
mBoxWidth = static_cast<float>(mTex->getWidth()) * mTransform.adjustedScaleX();
mBoxHeight = static_cast<float>(mTex->getHeight()) * mTransform.adjustedScaleY();
// 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());
}
if (mBoxWidth < mConfig.minWidth) mBoxWidth = mConfig.minWidth;
if (mBoxHeight < mConfig.minHeight) mBoxHeight = mConfig.minHeight;
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<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) {
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);
mIsHovered = inside;
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) {
// 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<size_t>(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<SDL_Scancode>(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<int>(mText.size()) < mConfig.maxLength) {
mText.push_back(static_cast<char>(keycode));
mNeedsTextRefresh = true;
// Letters A-Z
for (int sc = SDL_SCANCODE_A; sc <= SDL_SCANCODE_Z; ++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++;
}
}
}
// 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) {
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<float>(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,
&center,
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);
if (mIsHovered) {
Uint8 prevR = 0;
Uint8 prevG = 0;
Uint8 prevB = 0;
Uint8 prevA = 0;
SDL_BlendMode prevBlendMode = SDL_BLENDMODE_NONE;
// Text (or placeholder) via base render
Entity::render(renderer, config);
}
SDL_GetRenderDrawColor(renderer->getSDLRenderer(), &prevR, &prevG, &prevB, &prevA);
SDL_GetRenderDrawBlendMode(renderer->getSDLRenderer(), &prevBlendMode);
void UITextBox::setText(const std::string& text) {
mText = text;
mNeedsTextRefresh = true;
refreshVisualText();
}
SDL_SetRenderDrawBlendMode(renderer->getSDLRenderer(), SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer->getSDLRenderer(), 0, 0, 0, 28);
SDL_RenderFillRect(renderer->getSDLRenderer(), &dst);
std::string UITextBox::getText() const { return mText; }
std::string UITextBox::getValue() const { return mText; }
bool UITextBox::isFocused() const { return mIsFocused; }
bool UITextBox::isMouseInsideBox() const {
const float mouseX = Input::getMouseX();
const float mouseY = Input::getMouseY();
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 mouseX >= left && mouseX <= right && mouseY >= top && mouseY <= bottom;
}
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 += "_";
SDL_SetRenderDrawColor(renderer->getSDLRenderer(), prevR, prevG, prevB, prevA);
SDL_SetRenderDrawBlendMode(renderer->getSDLRenderer(), prevBlendMode);
}
}
std::dynamic_pointer_cast<Renderer::Font>(mTex)->build(color, rendered);
mNeedsTextRefresh = false;
// 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;
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,
&center,
mIsFlipped ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE
);
}
}
void UITextBox::setText(const std::string& text) {
mText = text.substr(0, mMaxLength);
mCursorIndex = std::min(mText.size(), mCursorIndex);
mLastRenderedText.clear(); // force rebuild
}
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
}
std::string UITextBox::getText() const {
return mText;
}
}

View File

@@ -1,4 +1,7 @@
#include <renderer/font.hpp>
#include <algorithm>
#include <string_view>
#include <vector>
namespace Game::Renderer {
Font::Font(const std::string& path, SDL_Renderer* renderer, int ptSize, std::string id)
@@ -36,12 +39,110 @@ namespace Game::Renderer {
void Font::build(SDL_Color color, std::string text) {
if (!mFont) { return; }
SDL_Surface* surf = TTF_RenderText_Blended(mFont, text.c_str(), text.size(), color);
// Store last build parameters so we can rebuild after device resets
mLastText = text;
mLastColor = color;
const int fontLineSkip = TTF_GetFontLineSkip(mFont);
std::vector<std::string_view> lines;
std::size_t lineStart = 0;
while (lineStart <= text.size()) {
const std::size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string::npos) {
lines.emplace_back(text.data() + lineStart, text.size() - lineStart);
break;
}
lines.emplace_back(text.data() + lineStart, lineEnd - lineStart);
lineStart = lineEnd + 1;
if (lineStart == text.size()) {
lines.emplace_back(std::string_view{});
break;
}
}
if (lines.empty()) {
lines.emplace_back(std::string_view{});
}
struct LineSurface {
SDL_Surface* surface = nullptr;
int width = 0;
int height = 0;
};
std::vector<LineSurface> lineSurfaces;
lineSurfaces.reserve(lines.size());
int maxWidth = 0;
int totalHeight = 0;
for (const auto line : lines) {
LineSurface lineSurface{};
if (!line.empty()) {
int measuredWidth = 0;
int measuredHeight = 0;
if (!TTF_GetStringSize(mFont, line.data(), line.size(), &measuredWidth, &measuredHeight)) {
for (auto& storedLine : lineSurfaces) {
if (storedLine.surface) {
SDL_DestroySurface(storedLine.surface);
}
}
ERROR("TTF_GetStringSize Error: " << SDL_GetError() << " (This object may be unusuable)");
return;
}
lineSurface.surface = TTF_RenderText_Blended(mFont, line.data(), line.size(), color);
if (!lineSurface.surface) {
for (auto& storedLine : lineSurfaces) {
if (storedLine.surface) {
SDL_DestroySurface(storedLine.surface);
}
}
ERROR("TTF_RenderText_Blended Error: " << SDL_GetError() << " (This object may be unusuable)");
return;
}
lineSurface.width = measuredWidth;
lineSurface.height = measuredHeight;
} else {
lineSurface.width = 0;
lineSurface.height = fontLineSkip;
}
maxWidth = std::max(maxWidth, lineSurface.width);
totalHeight += lineSurface.height;
lineSurfaces.push_back(lineSurface);
}
if (maxWidth <= 0) {
maxWidth = 1;
}
if (totalHeight <= 0) {
totalHeight = std::max(1, fontLineSkip);
}
SDL_Surface* surf = SDL_CreateSurface(maxWidth, totalHeight, SDL_PIXELFORMAT_RGBA8888);
if (!surf) {
ERROR("TTF_RenderText_Blended Error: " << SDL_GetError() << " (This object may be unusuable)");
for (auto& storedLine : lineSurfaces) {
if (storedLine.surface) {
SDL_DestroySurface(storedLine.surface);
}
}
ERROR("SDL_CreateSurface Error: " << SDL_GetError() << " (This object may be unusuable)");
return;
}
int currentY = 0;
for (auto& lineSurface : lineSurfaces) {
if (lineSurface.surface) {
SDL_Rect dstRect{0, currentY, lineSurface.width, lineSurface.height};
SDL_BlitSurface(lineSurface.surface, nullptr, surf, &dstRect);
SDL_DestroySurface(lineSurface.surface);
}
currentY += lineSurface.height;
}
// Convert to texture
mTex = SDL_CreateTextureFromSurface(mRenderer, surf);
SDL_DestroySurface(surf);
@@ -49,9 +150,28 @@ namespace Game::Renderer {
ERROR("SDL_CreateTextureFromSurface Error: " << SDL_GetError() << " (This object may be unusuable)");
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());
}
}
bool Font::reload(SDL_Renderer* renderer) {
mRenderer = renderer;
if (mLastText.empty()) return false;
// Rebuild the texture using the stored last text and color
build(mLastColor, mLastText);
return true;
}
SDL_Texture* Font::getSDLTexture() { return mTex; }
std::string Font::getId() { return mId; }
}

View File

@@ -1,4 +1,5 @@
#include <renderer/renderer.hpp>
#include <algorithm>
#include <utils.hpp>
#include <state/gamestate.hpp>
#include <object/entity.hpp>
@@ -16,32 +17,38 @@ namespace Game::Renderer {
if (mRenderer) {
SDL_DestroyRenderer(mRenderer);
mRenderer = nullptr;
LOG("Destroyed Renderer");
LOG("Renderer uničen");
}
}
bool Renderer::init(SDL_Window* window) {
// Request VSync before/at renderer setup; some backends honor this hint.
SDL_SetHint(SDL_HINT_RENDER_VSYNC, "1");
// Create renderer using the portable API. Some SDL3 backends may not support the old flags,
// so fall back to a software renderer via hint if the first attempt fails.
mRenderer = SDL_CreateRenderer(window, nullptr);
if (!mRenderer) {
std::string errorMsg = "Failed to create renderer: " + std::string(SDL_GetError());
ERROR(errorMsg.c_str());
return false;
WARN("Renderer creation failed, attempting software renderer: " << SDL_GetError());
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
mRenderer = SDL_CreateRenderer(window, nullptr);
if (!mRenderer) {
std::string errorMsg = std::string("Neuspešno ustvarjanje rendererja: ") + std::string(SDL_GetError());
ERROR(errorMsg.c_str());
return false;
}
}
mVSyncEnabled = SDL_SetRenderVSync(mRenderer, 1);
if (!mVSyncEnabled) {
WARN("VSync could not be enabled, using software frame pacing fallback: " << SDL_GetError());
WARN("VSync ni mogoče omogočiti, uporabljam programsko omejitev okvirjev: " << SDL_GetError());
}
if (!SDL_SetRenderDrawColor(mRenderer, 0, 0, 255, 255)) {
ERROR("Failed to set renderer draw color: " << SDL_GetError());
ERROR("Neuspelo nastavitev barve rendererja: " << SDL_GetError());
return false;
}
LOG("Renderer created successfully");
LOG("Renderer uspešno ustvarjen");
return true;
}
@@ -53,21 +60,48 @@ namespace Game::Renderer {
Object::Camera::getInstance().getPosition(camX, camY);
int screenW, screenH;
SDL_GetWindowSizeInPixels(Window::Window::getSDLWindowBackend(), &screenW, &screenH);
// Pass the config to avoid wasting time recalculating it for every entity, since it's not gonna change during the frame
RendererConfig config{ camX, camY, screenW, screenH };
// Logical game world is 1280x720; scale rendering if fullscreen is at different resolution
static constexpr int LOGICAL_WIDTH = 1280;
static constexpr int LOGICAL_HEIGHT = 720;
const float scaleX = static_cast<float>(screenW) / LOGICAL_WIDTH;
const float scaleY = static_cast<float>(screenH) / LOGICAL_HEIGHT;
// Apply SDL render scale to handle fullscreen scaling
SDL_SetRenderScale(mRenderer, scaleX, scaleY);
// Pass the config WITHOUT scaling factors (SDL handles it now)
RendererConfig config{ camX, camY, LOGICAL_WIDTH, LOGICAL_HEIGHT, 1.0f, 1.0f };
try {
auto entities = Game::State::GameState::getInstance().getEntitiesSnapshot(true);
for (auto* entity : entities) {
if (entity) {
entity->render(this, config);
Game::State::GameState::getInstance().withEntitiesLocked([&](auto& entities) {
std::vector<Game::Object::Entity*> renderOrder;
renderOrder.reserve(entities.size());
for (auto& [name, entity] : entities) {
(void)name;
if (entity) {
renderOrder.push_back(entity.get());
}
}
}
std::sort(renderOrder.begin(), renderOrder.end(), [](Game::Object::Entity* a, Game::Object::Entity* b) {
return a->getZIndex() < b->getZIndex();
});
for (auto* entity : renderOrder) {
if (entity && entity->isActive()) {
entity->render(this, config);
}
}
});
} catch (const std::exception& e) {
ERROR("Exception while rendering frame: " << e.what());
}
mPresent();
// Reset render scale for next frame
SDL_SetRenderScale(mRenderer, 1.0f, 1.0f);
}
void Renderer::mClear() {

View File

@@ -7,6 +7,9 @@ namespace Game::Renderer {
Texture::Texture(const std::string& path, SDL_Renderer* renderer, std::string id)
: mTex(nullptr), mId(id) {
mPath = path;
mIsFromFile = true;
SDL_Surface* surf = IMG_Load(path.c_str());
if (!surf) {
ERROR("Failed to load image at " << path);
@@ -41,7 +44,35 @@ namespace Game::Renderer {
Texture::~Texture() {
if (mTex)
SDL_DestroyTexture(mTex);
LOG("Destroyed texture '" << mId << "'")
LOG("Tekstura '" << mId << "' uničena")
}
bool Texture::reload(SDL_Renderer* renderer) {
if (!mIsFromFile || mPath.empty()) return false;
if (mTex) {
SDL_DestroyTexture(mTex);
mTex = nullptr;
}
SDL_Surface* surf = IMG_Load(mPath.c_str());
if (!surf) {
ERROR("Failed to reload image at " << mPath);
return false;
}
mTex = SDL_CreateTextureFromSurface(renderer, surf);
SDL_DestroySurface(surf);
if (!mTex) {
ERROR("Failed to create texture from surface when reloading " << mPath << ": " << SDL_GetError());
return false;
}
// Restore scale mode
if (!SDL_SetTextureScaleMode(mTex, SDL_SCALEMODE_NEAREST)) {
WARN("Failed to set texture scale mode to NEAREST for '" << mId << "' during reload: " << SDL_GetError());
}
return true;
}
SDL_Texture* Texture::getSDLTexture() {

View File

@@ -1,9 +1,19 @@
#include <window/window.hpp>
#include <algorithm>
#include <renderer/texture.hpp>
#include <game/input.hpp>
namespace Game::Window {
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) { }
@@ -14,14 +24,14 @@ namespace Game::Window {
if (mGameThread.joinable()) {
mGameThread.request_stop();
mGameThread.join();
LOG("Game thread stopped successfully");
LOG("Nit igre uspešno ustavljena");
}
if (mWindow) {
SDL_DestroyWindow(mWindow);
mWindow = nullptr;
sWindowBackend = nullptr;
LOG("Window destroyed successfully");
LOG("Okno uspešno uničeno");
}
SDL_Quit();
}
@@ -40,7 +50,7 @@ namespace Game::Window {
Audio::Audio::getInstance().init();
mWindow = SDL_CreateWindow(title.c_str(), width, height, SDL_WINDOW_RESIZABLE);
mWindow = SDL_CreateWindow(title.c_str(), width, height, SDL_WINDOW_FULLSCREEN);
if (!mWindow) {
ERROR("Failed to create window: " << SDL_GetError());
SDL_Quit();
@@ -50,7 +60,7 @@ namespace Game::Window {
mLastWindowHeight = height;
sWindowBackend = mWindow;
LOG("Window created successfully");
LOG("Okno uspešno ustvarjeno");
if (!mRenderer.init(mWindow)) {
SDL_DestroyWindow(mWindow);
@@ -64,17 +74,17 @@ namespace Game::Window {
if (mRenderer.isVSyncEnabled()) {
const int vsyncCap = std::max(1, mTargetFPS - VSYNC_FPS_OFFSET);
mEffectiveFrameCap = vsyncCap;
LOG("Low-latency VSync mode enabled. Target FPS: " << mTargetFPS << ", cap: " << mEffectiveFrameCap);
LOG("Low-latency VSync mode vključen. Target FPS: " << mTargetFPS << ", cap: " << mEffectiveFrameCap);
} else {
mEffectiveFrameCap = std::max(1, mTargetFPS);
LOG("VSync unavailable, using software cap: " << mEffectiveFrameCap);
LOG("VSync ni na voljo, uporabljam programski limit: " << mEffectiveFrameCap);
}
#else
mEffectiveFrameCap = std::max(1, mTargetFPS);
#endif
mGameManager.setTargetUpdatesPerSecond(TARGET_UPDATE_RATE);
LOG("Target updates per second: " << mGameManager.getTargetUpdatesPerSecond());
LOG("Ciljna hitrost posodobitev na sekundo: " << mGameManager.getTargetUpdatesPerSecond());
mGameThread = std::jthread(std::bind_front(&Game::GameManager::run, &mGameManager));
@@ -87,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<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();
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) {
mRunning = false;
}
@@ -97,31 +132,56 @@ namespace Game::Window {
// Window resize event - update the renderer's viewport
if (event.type == SDL_EVENT_WINDOW_RESIZED) {
std::scoped_lock lock(mMutex);
SDL_SetWindowSize(mWindow, mLastWindowWidth, mLastWindowHeight);
SDL_SetRenderViewport(mRenderer.getSDLRenderer(), nullptr);
int newWidth, newHeight;
SDL_GetWindowSizeInPixels(mWindow, &newWidth, &newHeight);
const int oldWidth = mLastWindowWidth;
const int oldHeight = mLastWindowHeight;
const bool canScale = oldWidth > 0 && oldHeight > 0;
const float scaleX = canScale ? static_cast<float>(newWidth) / static_cast<float>(oldWidth) : 1.f;
const float scaleY = canScale ? static_cast<float>(newHeight) / static_cast<float>(oldHeight) : 1.f;
auto entities = State::GameState::getInstance().getEntitiesSnapshot();
for (auto* entity : entities) {
if (entity) {
if (canScale) {
Object::Transform* transform = entity->getTransform();
transform->x *= scaleX;
transform->y *= scaleY;
State::GameState::getInstance().withEntitiesLocked([&](auto& entities) {
for (auto& [name, entity] : entities) {
(void)name;
if (entity) {
entity->onWindowResized(mLastWindowWidth, mLastWindowHeight);
}
entity->onWindowResized(newWidth, newHeight);
}
});
}
// Renderer device/targets reset (GPU device removed) - attempt to re-create renderer and reload textures
if (event.type == SDL_EVENT_RENDER_DEVICE_RESET || event.type == SDL_EVENT_RENDER_TARGETS_RESET) {
LOG("Renderer device reset event received: attempting to reinitialize renderer and reload textures");
// Destroy current renderer and try to re-init
mRenderer.destroy();
bool reinitOk = mRenderer.init(mWindow);
// If reinit failed, try forcing the software renderer
if (!reinitOk) {
WARN("Renderer re-init failed, forcing software renderer fallback");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
reinitOk = mRenderer.init(mWindow);
if (!reinitOk) {
ERROR("Software renderer fallback also failed: " << SDL_GetError());
// Unable to recover; stop running to avoid crashes
mRunning = false;
break;
}
}
mLastWindowWidth = newWidth;
mLastWindowHeight = newHeight;
// Rebuild GPU textures for all entities (fonts and file-based textures)
State::GameState::getInstance().withEntitiesLocked([&](auto& entities) {
for (auto& [name, entity] : entities) {
(void)name;
if (entity) {
auto tex = entity->getTexture();
if (tex) {
try {
tex->reload(mRenderer.getSDLRenderer());
} catch (...) {
WARN("Exception while reloading texture for entity: " << entity->getName());
}
}
entity->onWindowResized(mLastWindowWidth, mLastWindowHeight); // ensure layout is correct
}
}
});
}
}