commit 5a76e602f1642b908a25a2fecd5e6a3b9f7843a7 Author: Игорь Федосеев Date: Mon Oct 5 12:57:38 2026 +0300 Initial commit: Cyberpunk 2.5D Dungeon Crawler DoorNroll diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f9bead --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Build directories +build/ +bin/ +target/ +.vs/ +out/ + +# Visual Studio / MSVC artifacts +*.vcxproj.user +*.suo +*.user +*.pdb +*.obj +*.exe +*.ilk + +# Game runtime data & logs +savegame.dat +settings.cfg +*.log +err.txt +out.txt +run_log.txt + +# OS / Editor files +.DS_Store +Thumbs.db +*.swp +*.bak diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..62f4253 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.20) +project(DoorNroll CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) + +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_GAMES OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + raylib + GIT_REPOSITORY https://github.com/raysan5/raylib.git + GIT_TAG 5.5 + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(raylib) + +add_executable(DoorNroll + src/main.cpp + src/core/Game.cpp + src/core/SoundManager.cpp + src/dungeon/Dungeon.cpp + src/dungeon/DungeonGen.cpp + src/dungeon/Player.cpp + src/combat/CombatSystem.cpp + src/combat/Revolver.cpp + src/combat/Enemy.cpp + src/hacking/HackingGame.cpp + src/hacking/TumblerLock.cpp + src/hacking/DbdSkillCheck.cpp + src/inventory/InventoryUI.cpp + src/render/CyberRenderer.cpp + src/render/UIRenderer.cpp + src/hub/HubRoom.cpp + src/hub/RestRoom.cpp + src/core/SaveSystem.cpp +) + +target_include_directories(DoorNroll PRIVATE src) +target_link_libraries(DoorNroll PRIVATE raylib) + +if (MSVC) + target_compile_options(DoorNroll PRIVATE /W3 /permissive- /utf-8) +endif() + +add_custom_command(TARGET DoorNroll POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/assets" + "$/assets" +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e8b658 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# DoorNroll 🚪🎲 +### Cyberpunk 2.5D Dungeon Crawler with Cyber-Roulette & Lockpicking + +Игра разработана на **C++20** с использованием **Raylib** и системы сборки **CMake**. + +--- + +## 🎮 Управление (Controls) + +### 🏠 Физический Хаб (Реалистичная комната игрока) +- **`W / S`** — Шаг вперед / назад +- **`A / D`** — Поворот камеры на 90° +- **`Q / E`** — Стрейф влево / вправо +- **`Space / E`** — Взаимодействие: + - У северного шлюза: `JACK IN` (погружение в кибер-данж) + - У рабочего стола: `SAVE PROGRESS` (сохранить забег через ПК) +- **`Tab`** — Оружейная / Снаряжение барабана +- **`Esc`** — Меню паузы + +### 🏃 Исследование кибер-данжа (Dungeon Exploration) +- **`W / S`** — Шаг вперед / назад +- **`A / D`** — Поворот налево / направо на 90° +- **`Q / E`** — Стрейф влево / вправо +- **`Space / E`** — Взаимодействие (взлом шлюза ICE, сбор лута, портал выхода) +- **`Tab`** — Оружейная +- **`Esc`** — Меню паузы и сохранения + +### 🔐 Двухфазный взлом замков (Security ICE Bypass) +1. **Фаза 1: Кибер-тумблеры (Pin Alignment)** + - **`A / D`** — Выбор пина/шпильки + - **`W / S`** — Подбор частоты в зону "SWEET" + - **`Space`** — Зафиксировать пин + - **`Enter / Space`** — Подтвердить и перейти к скиллчеку +2. **Фаза 2: Dead by Daylight QTE (Circular Skill Check)** + - Нажмите **`Space`** точно в момент попадания стрелки в зону успеха. + - **При провале:** Срабатывает **ICE Alarm!**, ломается отмычка и призывается охранный дрон. + +### 🔫 Боевая система (Cyber-Roulette Combat) +- **`[1 / SPACE]` — SPIN & SHOOT:** Прокрутить барабан револьвера (звук `roller`) и произвести мощный одиночный выстрел (звук `one_shot`). +- **`[2]` — OVERCLOCK SHIELD:** +25 щита и -50% входящего урона. +- **`[3]` — FAN THE HAMMER (TRIPLE BURST):** Спецспособность! Скорострельный веерный залп из 3 последовательных камор (звук `three_shots`) с перезарядкой 2 хода. + +### 🎒 Инвентарь и снаряжение барабана (`Tab`) +- **`1 - 6` или клик мыши** — Выбрать камору барабана. +- **`W / S` или клик мыши** — Выбрать тип патрона из стака (`[x N]`). +- **`Enter / E / Space`** — Зарядить 1 патрон в камору (уменьшает счётчик). +- **`U / Backspace`** — Разрядить камору обратно в стак. +- **`R`** — Прокрутить барабан для проверки механики и прослушивания вращения. +- **`Tab / Esc`** — Закрыть оружейную. + +### ⏸️ Меню паузы (`Esc`) +- `[1 / Esc]` — Продолжить сессию +- `[2]` — Сохранить игру в `savegame.dat` +- `[3]` — Вернуться в комнату-хаб +- `[4]` — Выход на рабочий стол + +--- + +## ⚡ Типы патронов и стихии (Cartridges) +- **Кинетический (Kinetic Pierce, 35 DMG):** Физический пробивной урон. +- **ЭМИ (EMP Shock, 20 DMG):** Снимает щиты, шанс оглушить врага на 1 ход. +- **Перегрев (Overheat Thermal, 18 DMG):** Поджигает процессор врага (DoT на 3 хода). +- **Кислота (Acid Corrosive, 15 DMG):** Разъедает броню цели (+50% входящего урона). +- **Глитч (Glitch Cryo, 22 DMG):** Снижает точность врага. +- **Наниты (Nanite Siphon, 16 DMG):** Восстанавливает HP и щит игрока. +- **Пустая камора (Blank Shell):** Осечка. + +--- + +## 🚀 Запуск и сборка +- Быстрый запуск: `run.bat` или `build\Release\DoorNroll.exe` +- Сборка через CMake: + ```bash + cmake -B build -G "Visual Studio 18 2026" -A x64 + cmake --build build --config Release + ``` diff --git a/assets/shaders/postprocess.fs b/assets/shaders/postprocess.fs new file mode 100644 index 0000000..a1ec93a --- /dev/null +++ b/assets/shaders/postprocess.fs @@ -0,0 +1,83 @@ +#version 330 + +// Input vertex attributes (from vertex shader) +in vec2 fragTexCoord; +in vec4 fragColor; + +// Input uniform values +uniform sampler2D texture0; +uniform vec4 colDiffuse; + +uniform float u_time; +uniform float u_glitchIntensity; +uniform vec2 u_resolution; + +// Output fragment color +out vec4 finalColor; + +void main() +{ + vec2 uv = fragTexCoord; + + // 1. Digital Glitch / Chromatic Aberration + float glitch = clamp(u_glitchIntensity, 0.0, 1.0); + if (glitch > 0.001) + { + // Horizontal line slice displacement + float slice = step(0.65, sin(uv.y * 35.0 + u_time * 25.0)); + float sliceOffset = (slice * 2.0 - 1.0) * 0.018 * glitch; + uv.x += sliceOffset; + + // Chromatic RGB separation + float split = 0.012 * glitch; + float r = texture(texture0, vec2(uv.x + split, uv.y)).r; + float g = texture(texture0, uv).g; + float b = texture(texture0, vec2(uv.x - split, uv.y)).b; + finalColor = vec4(r, g, b, 1.0); + } + else + { + finalColor = texture(texture0, uv); + } + + // 2. Neon Bloom / Emissive Halo + vec2 texel = 1.0 / u_resolution; + vec3 bloom = vec3(0.0); + float bloomWeight = 0.0; + + // 9-tap bright-pass sample + vec2 offsets[8] = vec2[]( + vec2(-2.0, 0.0), vec2( 2.0, 0.0), + vec2( 0.0, -2.0), vec2( 0.0, 2.0), + vec2(-1.5, -1.5), vec2( 1.5, -1.5), + vec2(-1.5, 1.5), vec2( 1.5, 1.5) + ); + + for (int i = 0; i < 8; ++i) + { + vec3 col = texture(texture0, uv + offsets[i] * texel * 1.8).rgb; + float brightness = dot(col, vec3(0.2126, 0.7152, 0.0722)); + if (brightness > 0.55) + { + bloom += (col - 0.45) * 1.4; + bloomWeight += 1.0; + } + } + + if (bloomWeight > 0.0) + { + bloom /= 8.0; + finalColor.rgb += bloom * 0.75; + } + + // 3. Vignette (Smooth cinematic edge darkening) + float d = distance(uv, vec2(0.5, 0.5)); + float vignette = smoothstep(0.82, 0.38, d); + finalColor.rgb *= mix(0.58, 1.0, vignette); + + // 4. Subtle Cyber Scanlines + float scanline = sin(uv.y * u_resolution.y * 1.8) * 0.035; + finalColor.rgb -= scanline; + + finalColor.a = 1.0; +} diff --git a/assets/shaders/postprocess.vs b/assets/shaders/postprocess.vs new file mode 100644 index 0000000..bb9fa18 --- /dev/null +++ b/assets/shaders/postprocess.vs @@ -0,0 +1,20 @@ +#version 330 + +// Input vertex attributes +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec4 vertexColor; + +// Input uniform values +uniform mat4 mvp; + +// Output vertex attributes (to fragment shader) +out vec2 fragTexCoord; +out vec4 fragColor; + +void main() +{ + fragTexCoord = vertexTexCoord; + fragColor = vertexColor; + gl_Position = mvp * vec4(vertexPosition, 1.0); +} diff --git a/assets/sounds/Revolver/empty.mp3 b/assets/sounds/Revolver/empty.mp3 new file mode 100644 index 0000000..f350e0c Binary files /dev/null and b/assets/sounds/Revolver/empty.mp3 differ diff --git a/assets/sounds/Revolver/empty.wav b/assets/sounds/Revolver/empty.wav new file mode 100644 index 0000000..6bb23bf Binary files /dev/null and b/assets/sounds/Revolver/empty.wav differ diff --git a/assets/sounds/Revolver/loadshell.mp3 b/assets/sounds/Revolver/loadshell.mp3 new file mode 100644 index 0000000..e138e58 Binary files /dev/null and b/assets/sounds/Revolver/loadshell.mp3 differ diff --git a/assets/sounds/Revolver/loadshell.wav b/assets/sounds/Revolver/loadshell.wav new file mode 100644 index 0000000..199d424 Binary files /dev/null and b/assets/sounds/Revolver/loadshell.wav differ diff --git a/assets/sounds/Revolver/one_shot.mp3 b/assets/sounds/Revolver/one_shot.mp3 new file mode 100644 index 0000000..ead55c3 Binary files /dev/null and b/assets/sounds/Revolver/one_shot.mp3 differ diff --git a/assets/sounds/Revolver/one_shot.wav b/assets/sounds/Revolver/one_shot.wav new file mode 100644 index 0000000..a6a51f4 Binary files /dev/null and b/assets/sounds/Revolver/one_shot.wav differ diff --git a/assets/sounds/Revolver/roller.mp3 b/assets/sounds/Revolver/roller.mp3 new file mode 100644 index 0000000..eea4ead Binary files /dev/null and b/assets/sounds/Revolver/roller.mp3 differ diff --git a/assets/sounds/Revolver/roller.wav b/assets/sounds/Revolver/roller.wav new file mode 100644 index 0000000..f856d87 Binary files /dev/null and b/assets/sounds/Revolver/roller.wav differ diff --git a/assets/sounds/Revolver/three_shots.mp3 b/assets/sounds/Revolver/three_shots.mp3 new file mode 100644 index 0000000..a6b4744 Binary files /dev/null and b/assets/sounds/Revolver/three_shots.mp3 differ diff --git a/assets/sounds/Revolver/three_shots.wav b/assets/sounds/Revolver/three_shots.wav new file mode 100644 index 0000000..25718d4 Binary files /dev/null and b/assets/sounds/Revolver/three_shots.wav differ diff --git a/assets/sounds/ost/Kaito Shoma - Rush.m4a b/assets/sounds/ost/Kaito Shoma - Rush.m4a new file mode 100644 index 0000000..2a9ce8f Binary files /dev/null and b/assets/sounds/ost/Kaito Shoma - Rush.m4a differ diff --git a/assets/sounds/ost/Spiral Drive - Space Train.m4a b/assets/sounds/ost/Spiral Drive - Space Train.m4a new file mode 100644 index 0000000..520dd3b Binary files /dev/null and b/assets/sounds/ost/Spiral Drive - Space Train.m4a differ diff --git a/assets/sounds/ost/Tame Impala - Breathe Deeper.mp3 b/assets/sounds/ost/Tame Impala - Breathe Deeper.mp3 new file mode 100644 index 0000000..27b0ee4 Binary files /dev/null and b/assets/sounds/ost/Tame Impala - Breathe Deeper.mp3 differ diff --git a/assets/sounds/ost/breathe_deeper.ogg b/assets/sounds/ost/breathe_deeper.ogg new file mode 100644 index 0000000..61e701b Binary files /dev/null and b/assets/sounds/ost/breathe_deeper.ogg differ diff --git a/assets/sounds/ost/i9incher - Zoom Noir.m4a b/assets/sounds/ost/i9incher - Zoom Noir.m4a new file mode 100644 index 0000000..189d49e Binary files /dev/null and b/assets/sounds/ost/i9incher - Zoom Noir.m4a differ diff --git a/assets/sounds/ost/rush.ogg b/assets/sounds/ost/rush.ogg new file mode 100644 index 0000000..0ec4916 Binary files /dev/null and b/assets/sounds/ost/rush.ogg differ diff --git a/assets/sounds/ost/space_train.ogg b/assets/sounds/ost/space_train.ogg new file mode 100644 index 0000000..15ac99d Binary files /dev/null and b/assets/sounds/ost/space_train.ogg differ diff --git a/assets/sounds/ost/zoom_noir.ogg b/assets/sounds/ost/zoom_noir.ogg new file mode 100644 index 0000000..b979542 Binary files /dev/null and b/assets/sounds/ost/zoom_noir.ogg differ diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..0f0c102 --- /dev/null +++ b/run.bat @@ -0,0 +1,2 @@ +@echo off +start "" "%~dp0build\Release\DoorNroll.exe" diff --git a/src/combat/CombatSystem.cpp b/src/combat/CombatSystem.cpp new file mode 100644 index 0000000..fe70dbd --- /dev/null +++ b/src/combat/CombatSystem.cpp @@ -0,0 +1,301 @@ +#include "CombatSystem.hpp" +#include "core/SoundManager.hpp" +#include + +namespace dnr { + +CombatSystem::CombatSystem() = default; + +void CombatSystem::startCombat(Player& player, std::unique_ptr enemy) { + m_enemy = std::move(enemy); + m_phase = CombatPhase::PLAYER_TURN; + m_phaseTimer = 0.0f; + m_shieldBoosted = false; + m_tripleCooldown = 0; + m_combatLog.clear(); + m_floatingTexts.clear(); + + addLog("--- HOSTILE INTERFACE DETECTED ---"); + addLog(m_enemy->getName() + " attacks with [" + getAmmoInfo(m_enemy->getAttackElement()).shortName + "]!"); + addLog("WEAKNESS: [" + getAmmoInfo(m_enemy->getWeakness()).shortName + "] | Ready your revolver!"); +} + +void CombatSystem::update(float dt, Player& player) { + // ALWAYS update revolver animations during combat! + player.getRevolver().update(dt); + + // Update floating damage numbers + for (auto it = m_floatingTexts.begin(); it != m_floatingTexts.end(); ) { + it->timer -= dt; + it->pos.y -= 25.0f * dt; + if (it->timer <= 0.0f) { + it = m_floatingTexts.erase(it); + } else { + ++it; + } + } + + if (m_phase == CombatPhase::SPINNING_SHOT) { + // Wait until cylinder spin decelerates and lands on a chamber + if (!player.getRevolver().isSpinning()) { + resolveShotImpact(player); + } + } else if (m_phase == CombatPhase::TRIPLE_SHOT_BURST) { + m_burstTimer += dt; + if (m_burstShotsFired == 0 && m_burstTimer >= 0.05f) { + resolveBurstShot(player, 1); + m_burstShotsFired = 1; + } else if (m_burstShotsFired == 1 && m_burstTimer >= 0.45f) { + player.getRevolver().advance(); + resolveBurstShot(player, 2); + m_burstShotsFired = 2; + } else if (m_burstShotsFired == 2 && m_burstTimer >= 0.85f) { + player.getRevolver().advance(); + resolveBurstShot(player, 3); + m_burstShotsFired = 3; + } else if (m_burstTimer >= 1.65f) { + if (!m_enemy->isAlive()) { + m_phase = CombatPhase::VICTORY; + addLog(">>> TARGET TERMINATED! Protocol cleared <<<"); + SoundManager::get().playPickup(); + } else { + m_phase = CombatPhase::ENEMY_TURN; + executeEnemyTurn(player); + } + } + } else if (m_phase == CombatPhase::PLAYER_ACTION_ANIM) { + m_phaseTimer += dt; + if (m_phaseTimer >= 0.75f) { + if (!m_enemy->isAlive()) { + m_phase = CombatPhase::VICTORY; + addLog(">>> TARGET TERMINATED! Protocol cleared <<<"); + SoundManager::get().playPickup(); + } else { + m_phase = CombatPhase::ENEMY_TURN; + executeEnemyTurn(player); + } + } + } else if (m_phase == CombatPhase::ENEMY_ACTION_ANIM) { + m_phaseTimer += dt; + if (m_phaseTimer >= 0.85f) { + if (!player.isAlive()) { + m_phase = CombatPhase::DEFEAT; + addLog("FLATLINE! System destroyed."); + SoundManager::get().playDamage(); + } else { + m_phase = CombatPhase::PLAYER_TURN; + if (m_tripleCooldown > 0) { + --m_tripleCooldown; + } + player.updateTurnStart(m_combatLog); + if (!player.isAlive()) { + m_phase = CombatPhase::DEFEAT; + addLog("FLATLINE! System collapsed from status damage."); + } + } + } + } +} + +void CombatSystem::actionSpinAndShoot(Player& player) { + if (m_phase != CombatPhase::PLAYER_TURN || player.getRevolver().isSpinning()) return; + + addLog("ROULETTE: Spinning cylinder..."); + player.getRevolver().spinToRandom(); + m_phase = CombatPhase::SPINNING_SHOT; +} + +void CombatSystem::actionTripleShot(Player& player) { + if (m_phase != CombatPhase::PLAYER_TURN || player.getRevolver().isSpinning()) return; + if (m_tripleCooldown > 0) return; + + addLog(">>> FAN THE HAMMER // 3-ROUND RAPID BURST! <<<"); + SoundManager::get().playTripleShot(); + + m_phase = CombatPhase::TRIPLE_SHOT_BURST; + m_burstTimer = 0.0f; + m_burstShotsFired = 0; + m_tripleCooldown = 3; +} + +void CombatSystem::applyAmmoStatus(AmmoType chamberAmmo, int dealt, Player& player) { + const auto& info = getAmmoInfo(chamberAmmo); + switch (chamberAmmo) { + case AmmoType::EMP: + if ((std::rand() % 100) < static_cast(info.effectChance * 100)) { + m_enemy->applyStatus(StatusEffect::SHOCKED, 1, 0); + addLog(m_enemy->getName() + " is SHOCKED! Reboot delayed."); + addFloatingText("STUNNED!", { 640, 180 }, CyberColors::NeonCyan); + } + break; + case AmmoType::OVERHEAT: + m_enemy->applyStatus(StatusEffect::BURNING, 3, 10); + addLog(m_enemy->getName() + " ignited with THERMAL BURN!"); + addFloatingText("BURNING!", { 640, 180 }, CyberColors::NeonOrange); + break; + case AmmoType::CORROSIVE: + m_enemy->applyStatus(StatusEffect::CORRODED, 3, 0); + addLog("Armor corroded! Target takes +50% damage."); + addFloatingText("CORROSION", { 640, 180 }, CyberColors::NeonGreen); + break; + case AmmoType::GLITCH: + m_enemy->applyStatus(StatusEffect::GLITCHED, 3, 0); + addLog("Glitch injected! Enemy targeting scrambled."); + addFloatingText("GLITCHED", { 640, 180 }, CyberColors::NeonPurple); + break; + case AmmoType::NANITE: { + int siphon = dealt / 2; + player.heal(siphon); + player.restoreShield(siphon / 2); + addLog("Nanites siphoned +" + std::to_string(siphon) + " HP!"); + addFloatingText("+" + std::to_string(siphon) + " HP", { 300, 480 }, CyberColors::NeonGreen); + break; + } + default: + break; + } +} + +void CombatSystem::resolveBurstShot(Player& player, int shotNum) { + AmmoType chamberAmmo = player.getRevolver().fire(); + const auto& info = getAmmoInfo(chamberAmmo); + + float offsetX = (shotNum - 2) * 45.0f; + + if (chamberAmmo == AmmoType::EMPTY) { + SoundManager::get().playEmptyClick(); + addLog("[SHOT " + std::to_string(shotNum) + "] *CLICK* Empty chamber misfire!"); + addFloatingText("MISFIRE", { 640.0f + offsetX, 240.0f }, GRAY); + } else { + int dmg = info.baseDamage; + bool isWeakness = (chamberAmmo == m_enemy->getWeakness()); + if (isWeakness) { + addFloatingText("WEAKNESS!", { 640.0f + offsetX, 200.0f }, CyberColors::NeonYellow); + } + + int dealt = m_enemy->takeDamage(dmg, chamberAmmo); + SoundManager::get().playEnemyHit(); + + addLog("[SHOT " + std::to_string(shotNum) + "] Hit with [" + info.name + "] for " + std::to_string(dealt) + " damage!"); + addFloatingText("-" + std::to_string(dealt), { 640.0f + offsetX, 240.0f }, info.color); + + applyAmmoStatus(chamberAmmo, dealt, player); + } +} + +void CombatSystem::resolveShotImpact(Player& player) { + AmmoType chamberAmmo = player.getRevolver().fire(); + const auto& info = getAmmoInfo(chamberAmmo); + + if (chamberAmmo == AmmoType::EMPTY) { + SoundManager::get().playEmptyClick(); + addLog("*CLICK!* Blank chamber misfire! No shot."); + addFloatingText("MISFIRE!", { 640, 240 }, GRAY); + } else { + SoundManager::get().playGunshot(); + + int dmg = info.baseDamage; + bool isWeakness = (chamberAmmo == m_enemy->getWeakness()); + if (isWeakness) { + addLog("WEAKNESS EXPLOITED! Critical damage surge!"); + addFloatingText("WEAKNESS!", { 640, 200 }, CyberColors::NeonYellow); + } + + int dealt = m_enemy->takeDamage(dmg, chamberAmmo); + SoundManager::get().playEnemyHit(); + + addLog("Hit with [" + info.name + "] for " + std::to_string(dealt) + " damage!"); + addFloatingText("-" + std::to_string(dealt), { 640, 240 }, info.color); + + applyAmmoStatus(chamberAmmo, dealt, player); + } + + m_phase = CombatPhase::PLAYER_ACTION_ANIM; + m_phaseTimer = 0.0f; +} + +void CombatSystem::actionShield(Player& player) { + if (m_phase != CombatPhase::PLAYER_TURN || player.getRevolver().isSpinning()) return; + + m_shieldBoosted = true; + player.restoreShield(25); + addLog("SHIELD MATRIX OVERCLOCKED (+25 Shield, 50% Damage Dampening)."); + addFloatingText("+25 SHIELD", { 300, 480 }, CyberColors::NeonCyan); + SoundManager::get().playSkillSuccess(); + + m_phase = CombatPhase::ENEMY_TURN; + executeEnemyTurn(player); +} + +void CombatSystem::executeEnemyTurn(Player& player) { + m_phase = CombatPhase::ENEMY_ACTION_ANIM; + m_phaseTimer = 0.0f; + + // Tick enemy statuses (burn DoT) + m_enemy->updateTurnStart(m_combatLog); + if (!m_enemy->isAlive()) { + m_phase = CombatPhase::VICTORY; + addLog(m_enemy->getName() + " burned out from status damage!"); + return; + } + + // Check if enemy stunned + if (m_enemy->isStunned()) { + addLog(m_enemy->getName() + " is STUNNED and skips attack!"); + addFloatingText("STUN SKIP", { 640, 240 }, CyberColors::NeonCyan); + return; + } + + // Enemy attack + int rawDmg = m_enemy->calculateAttackDamage(); + if (rawDmg == 0) { + addLog(m_enemy->getName() + " targeted player but MISSED!"); + addFloatingText("MISSED", { 300, 480 }, GRAY); + return; + } + + if (m_shieldBoosted) { + rawDmg = static_cast(rawDmg * 0.5f); + m_shieldBoosted = false; + } + + AmmoType elem = m_enemy->getAttackElement(); + int dealt = player.takeDamage(rawDmg, elem); + SoundManager::get().playDamage(); + + addLog(m_enemy->getName() + " attacks with [" + getAmmoInfo(elem).shortName + "] for " + std::to_string(dealt) + " dmg!"); + addFloatingText("-" + std::to_string(dealt), { 300, 480 }, RED); + + // Chance to apply enemy elemental debuff to player + if ((std::rand() % 100) < 50) { + switch (elem) { + case AmmoType::OVERHEAT: + player.applyStatus(StatusEffect::BURNING, 3, 8); + addLog("ALERT: Player CPU is OVERHEATING!"); + break; + case AmmoType::EMP: + player.applyStatus(StatusEffect::SHOCKED, 1, 0); + addLog("ALERT: EMP surge scrambled player shield!"); + break; + case AmmoType::CORROSIVE: + player.applyStatus(StatusEffect::CORRODED, 3, 0); + addLog("ALERT: Player armor is CORRODED!"); + break; + default: + break; + } + } +} + +void CombatSystem::addLog(const std::string& msg) { + m_combatLog.push_back(msg); + if (m_combatLog.size() > 8) { + m_combatLog.erase(m_combatLog.begin()); + } +} + +void CombatSystem::addFloatingText(const std::string& text, Vector2 pos, Color color) { + m_floatingTexts.push_back({ text, pos, color, 1.0f }); +} + +} // namespace dnr diff --git a/src/combat/CombatSystem.hpp b/src/combat/CombatSystem.hpp new file mode 100644 index 0000000..3db1475 --- /dev/null +++ b/src/combat/CombatSystem.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Player.hpp" +#include "Enemy.hpp" +#include +#include +#include + +namespace dnr { + +enum class CombatPhase { + PLAYER_TURN, + SPINNING_SHOT, + TRIPLE_SHOT_BURST, + PLAYER_ACTION_ANIM, + ENEMY_TURN, + ENEMY_ACTION_ANIM, + VICTORY, + DEFEAT +}; + +struct FloatingText { + std::string text; + Vector2 pos; + Color color; + float timer = 1.0f; +}; + +class CombatSystem { +public: + CombatSystem(); + + void startCombat(Player& player, std::unique_ptr enemy); + void update(float dt, Player& player); + + // Player Actions + void actionSpinAndShoot(Player& player); + void actionTripleShot(Player& player); + void actionShield(Player& player); + + int getTripleCooldown() const { return m_tripleCooldown; } + + CombatPhase getPhase() const { return m_phase; } + const Enemy* getEnemy() const { return m_enemy.get(); } + const std::vector& getLog() const { return m_combatLog; } + const std::vector& getFloatingTexts() const { return m_floatingTexts; } + + bool isCombatOver() const { + return m_phase == CombatPhase::VICTORY || m_phase == CombatPhase::DEFEAT; + } + bool isVictory() const { return m_phase == CombatPhase::VICTORY; } + + void addLog(const std::string& msg); + void addFloatingText(const std::string& text, Vector2 pos, Color color); + +private: + void resolveShotImpact(Player& player); + void resolveBurstShot(Player& player, int shotNum); + void applyAmmoStatus(AmmoType chamberAmmo, int dealt, Player& player); + void executeEnemyTurn(Player& player); + + std::unique_ptr m_enemy; + CombatPhase m_phase = CombatPhase::PLAYER_TURN; + float m_phaseTimer = 0.0f; + + // Fan the Hammer (3-round burst ability) + int m_tripleCooldown = 0; + float m_burstTimer = 0.0f; + int m_burstShotsFired = 0; + + bool m_shieldBoosted = false; + std::vector m_combatLog; + std::vector m_floatingTexts; +}; + +} // namespace dnr diff --git a/src/combat/Enemy.cpp b/src/combat/Enemy.cpp new file mode 100644 index 0000000..99e3138 --- /dev/null +++ b/src/combat/Enemy.cpp @@ -0,0 +1,140 @@ +#include "Enemy.hpp" +#include + +namespace dnr { + +Enemy::Enemy(EnemyType type, int level) : m_type(type), m_level(level) { + switch (type) { + case EnemyType::CYBER_DRONE: + m_name = "SENTINEL-DRONE v" + std::to_string(level); + m_maxHp = 60 + level * 15; + m_maxShield = 30 + level * 10; + m_baseDamage = 12 + level * 3; + m_attackElement = AmmoType::OVERHEAT; + m_weakness = AmmoType::EMP; + m_color = CyberColors::NeonOrange; + break; + + case EnemyType::ICE_SENTINEL: + m_name = "ICE-GUARDIAN 0x" + std::to_string(level); + m_maxHp = 90 + level * 20; + m_maxShield = 50 + level * 15; + m_baseDamage = 14 + level * 3; + m_attackElement = AmmoType::EMP; + m_weakness = AmmoType::CORROSIVE; + m_color = CyberColors::NeonCyan; + break; + + case EnemyType::GLITCH_WRAITH: + m_name = "WRAITH.EXE"; + m_maxHp = 50 + level * 12; + m_maxShield = 20 + level * 5; + m_baseDamage = 18 + level * 4; + m_attackElement = AmmoType::GLITCH; + m_weakness = AmmoType::KINETIC; + m_color = CyberColors::NeonPurple; + break; + + case EnemyType::VIRUS_CONSTRUCT: + m_name = "BIO-WORM_V" + std::to_string(level); + m_maxHp = 80 + level * 18; + m_maxShield = 15 + level * 5; + m_baseDamage = 15 + level * 3; + m_attackElement = AmmoType::CORROSIVE; + m_weakness = AmmoType::OVERHEAT; + m_color = CyberColors::NeonGreen; + break; + } + + m_hp = m_maxHp; + m_shield = m_maxShield; +} + +int Enemy::takeDamage(int amount, AmmoType element) { + float mult = 1.0f; + + // Check elemental weakness + if (element == m_weakness && element != AmmoType::EMPTY) { + mult *= 1.75f; + } + + // Check corroded status + for (const auto& st : m_statuses) { + if (st.effect == StatusEffect::CORRODED) { + mult *= 1.50f; + break; + } + } + + // Special EMP interaction: bonus against shields + if (element == AmmoType::EMP && m_shield > 0) { + mult *= 2.0f; + } + + int finalDmg = static_cast(amount * mult); + if (finalDmg < 1 && amount > 0) finalDmg = 1; + + // Shields take damage first + if (m_shield > 0) { + if (finalDmg <= m_shield) { + m_shield -= finalDmg; + return finalDmg; + } else { + int remaining = finalDmg - m_shield; + m_shield = 0; + m_hp -= remaining; + } + } else { + m_hp -= finalDmg; + } + + if (m_hp < 0) m_hp = 0; + return finalDmg; +} + +void Enemy::applyStatus(StatusEffect effect, int duration, int power) { + // If status already active, refresh duration + for (auto& st : m_statuses) { + if (st.effect == effect) { + st.durationTurns = std::max(st.durationTurns, duration); + st.power = std::max(st.power, power); + return; + } + } + m_statuses.push_back({ effect, duration, power }); +} + +void Enemy::updateTurnStart(std::vector& combatLog) { + for (auto it = m_statuses.begin(); it != m_statuses.end(); ) { + if (it->effect == StatusEffect::BURNING) { + int burnDmg = it->power; + m_hp = std::max(0, m_hp - burnDmg); + combatLog.push_back(m_name + " takes " + std::to_string(burnDmg) + " BURN damage!"); + } + it->durationTurns--; + if (it->durationTurns <= 0) { + it = m_statuses.erase(it); + } else { + ++it; + } + } +} + +int Enemy::calculateAttackDamage() const { + // If glitched, chance to miss or lower power + for (const auto& st : m_statuses) { + if (st.effect == StatusEffect::GLITCHED) { + if ((std::rand() % 100) < 40) return 0; // Miss! + } + } + return m_baseDamage + (std::rand() % 5 - 2); +} + +bool Enemy::isStunned() const { + for (const auto& st : m_statuses) { + if (st.effect == StatusEffect::SHOCKED) return true; + } + return false; +} + +} // namespace dnr diff --git a/src/combat/Enemy.hpp b/src/combat/Enemy.hpp new file mode 100644 index 0000000..cdccdbc --- /dev/null +++ b/src/combat/Enemy.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "core/Common.hpp" +#include + +namespace dnr { + +enum class EnemyType { + CYBER_DRONE, // Thermal / Overheat laser. Weak to EMP. + ICE_SENTINEL, // EMP shock blaster. Weak to Corrosive. + GLITCH_WRAITH, // Glitch pulse. Weak to Kinetic. + VIRUS_CONSTRUCT // Corrosive bile. Weak to Overheat. +}; + +class Enemy { +public: + Enemy(EnemyType type, int level = 1); + + const std::string& getName() const { return m_name; } + EnemyType getType() const { return m_type; } + + int getHp() const { return m_hp; } + int getMaxHp() const { return m_maxHp; } + int getShield() const { return m_shield; } + int getMaxShield() const { return m_maxShield; } + + AmmoType getAttackElement() const { return m_attackElement; } + AmmoType getWeakness() const { return m_weakness; } + Color getColor() const { return m_color; } + + bool isAlive() const { return m_hp > 0; } + + // Combat interactions + int takeDamage(int amount, AmmoType element); + void applyStatus(StatusEffect effect, int duration, int power); + void updateTurnStart(std::vector& combatLog); + + int calculateAttackDamage() const; + bool isStunned() const; + + const std::vector& getStatuses() const { return m_statuses; } + +private: + EnemyType m_type; + std::string m_name; + int m_level = 1; + int m_hp = 100; + int m_maxHp = 100; + int m_shield = 30; + int m_maxShield = 30; + int m_baseDamage = 15; + + AmmoType m_attackElement = AmmoType::OVERHEAT; + AmmoType m_weakness = AmmoType::EMP; + Color m_color = RED; + + std::vector m_statuses; +}; + +} // namespace dnr diff --git a/src/combat/Revolver.cpp b/src/combat/Revolver.cpp new file mode 100644 index 0000000..dae3361 --- /dev/null +++ b/src/combat/Revolver.cpp @@ -0,0 +1,108 @@ +#include "Revolver.hpp" +#include "core/SoundManager.hpp" +#include +#include + +namespace dnr { + +Revolver::Revolver() { + reset(); +} + +void Revolver::reset() { + // Default loadout: balanced mix of tactical cyber rounds + m_chambers[0] = AmmoType::KINETIC; + m_chambers[1] = AmmoType::EMP; + m_chambers[2] = AmmoType::OVERHEAT; + m_chambers[3] = AmmoType::KINETIC; + m_chambers[4] = AmmoType::CORROSIVE; + m_chambers[5] = AmmoType::GLITCH; + + m_currentIndex = 0; + m_targetIndex = 0; + m_spinning = false; + m_spinTimer = 0.0f; + m_visualAngle = 0.0f; +} + +void Revolver::setChamber(int index, AmmoType ammo) { + if (index >= 0 && index < CHAMBERS_COUNT) { + m_chambers[index] = ammo; + } +} + +AmmoType Revolver::getChamber(int index) const { + if (index >= 0 && index < CHAMBERS_COUNT) { + return m_chambers[index]; + } + return AmmoType::EMPTY; +} + +void Revolver::spinToRandom() { + m_targetIndex = std::rand() % CHAMBERS_COUNT; + m_spinning = true; + m_spinTimer = 0.0f; + m_spinDuration = 1.45f; // Synchronized with roller audio length + + m_startAngle = m_visualAngle; + float step = 360.0f / CHAMBERS_COUNT; + int delta = (m_targetIndex - m_currentIndex + CHAMBERS_COUNT) % CHAMBERS_COUNT; + if (delta == 0) delta = CHAMBERS_COUNT; + m_targetAngle = m_startAngle + 360.0f * 4.0f + delta * step; + + SoundManager::get().playRespin(); +} + +void Revolver::update(float dt) { + if (!m_spinning) { + // Keep angle snapped to current index + float target = m_currentIndex * (360.0f / CHAMBERS_COUNT); + m_visualAngle += (target - m_visualAngle) * 15.0f * dt; + return; + } + + m_spinTimer += dt; + float t = m_spinTimer / m_spinDuration; + if (t >= 1.0f) { + t = 1.0f; + m_spinning = false; + m_currentIndex = m_targetIndex; + m_visualAngle = m_targetIndex * (360.0f / CHAMBERS_COUNT); + SoundManager::get().playTumblerClick(); + } else { + // Ease out cubic + float ease = 1.0f - std::pow(1.0f - t, 3.0f); + m_visualAngle = m_startAngle + (m_targetAngle - m_startAngle) * ease; + } +} + +AmmoType Revolver::getCurrentAmmo() const { + return m_chambers[m_currentIndex]; +} + +AmmoType Revolver::getNextAmmo() const { + int next = (m_currentIndex + 1) % CHAMBERS_COUNT; + return m_chambers[next]; +} + +AmmoType Revolver::fire() { + AmmoType shot = m_chambers[m_currentIndex]; + // Chamber is spent in place: the shell at 12 o'clock under the arrow becomes EMPTY! + m_chambers[m_currentIndex] = AmmoType::EMPTY; + return shot; +} + +void Revolver::advance() { + m_currentIndex = (m_currentIndex + 1) % CHAMBERS_COUNT; + m_visualAngle = m_currentIndex * (360.0f / CHAMBERS_COUNT); + SoundManager::get().playTumblerClick(); +} + +bool Revolver::hasAmmo() const { + for (int i = 0; i < CHAMBERS_COUNT; ++i) { + if (m_chambers[i] != AmmoType::EMPTY) return true; + } + return false; +} + +} // namespace dnr diff --git a/src/combat/Revolver.hpp b/src/combat/Revolver.hpp new file mode 100644 index 0000000..b5d1f5b --- /dev/null +++ b/src/combat/Revolver.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "core/Common.hpp" +#include + +namespace dnr { + +class Revolver { +public: + static constexpr int CHAMBERS_COUNT = 6; + + Revolver(); + + void reset(); + void setChamber(int index, AmmoType ammo); + AmmoType getChamber(int index) const; + + // Spin cylinder to random chamber + void spinToRandom(); + void update(float dt); + + bool isSpinning() const { return m_spinning; } + int getCurrentIndex() const { return m_currentIndex; } + int getNextIndex() const { return (m_currentIndex + 1) % CHAMBERS_COUNT; } + float getVisualAngle() const { return m_visualAngle; } + + AmmoType getCurrentAmmo() const; + AmmoType getNextAmmo() const; + + // Fire round: empties chamber and steps to next + AmmoType fire(); + + // Advance to next chamber without firing (for multi-shot burst) + void advance(); + + // Check if cylinder has any usable ammunition + bool hasAmmo() const; + +private: + std::array m_chambers; + int m_currentIndex = 0; + int m_targetIndex = 0; + + bool m_spinning = false; + float m_spinTimer = 0.0f; + float m_spinDuration = 1.0f; + float m_visualAngle = 0.0f; + float m_startAngle = 0.0f; + float m_targetAngle = 0.0f; +}; + +} // namespace dnr diff --git a/src/core/Common.hpp b/src/core/Common.hpp new file mode 100644 index 0000000..c93ef45 --- /dev/null +++ b/src/core/Common.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include +#include +#include "raylib.h" + +namespace dnr { + +enum class GameState { + MAIN_MENU, + HUB, + EXPLORATION, + HACKING, + COMBAT, + INVENTORY, + PAUSE_MENU, + SETTINGS, + REST_ROOM, + GAME_OVER, + VICTORY_LEVEL +}; + +enum class AmmoType { + EMPTY, + KINETIC, // Pierce / Raw physical + EMP, // Strips shield, chance to stun + OVERHEAT, // Burns target (DoT for 3 turns) + CORROSIVE, // Melts defense (+50% incoming damage) + GLITCH, // Scrambles CPU (slows, reduces hit chance) + NANITE // Siphons health/shield +}; + +struct AmmoInfo { + AmmoType type; + std::string name; + std::string shortName; + std::string description; + Color color; + int baseDamage; + float effectChance; // 0.0 - 1.0 +}; + +inline const AmmoInfo& getAmmoInfo(AmmoType type) { + static const AmmoInfo infoEmpty { AmmoType::EMPTY, "Empty Shell", "BLANK", "Empty chamber - no round.", DARKGRAY, 0, 0.0f }; + static const AmmoInfo infoKinetic { AmmoType::KINETIC, "Kinetic Pierce", "KINET", "Heavy kinetic sabot. High raw impact.", { 240, 240, 240, 255 }, 35, 0.25f }; + static const AmmoInfo infoEMP { AmmoType::EMP, "EMP Shock", "EMP", "Electro-magnetic pulse. Destroys shields, stuns machine.", { 0, 220, 255, 255 }, 20, 0.70f }; + static const AmmoInfo infoOverheat { AmmoType::OVERHEAT, "Overheat Core", "THERM", "Thermal incendiary round. Ignites target CPU for 3 turns.", { 255, 80, 20, 255 }, 18, 1.0f }; + static const AmmoInfo infoCorrosive { AmmoType::CORROSIVE, "Acid Corrosive", "ACID", "Sub-routine dissolver. Destroys armor, targets take +50% dmg.", { 120, 255, 0, 255 }, 15, 1.0f }; + static const AmmoInfo infoGlitch { AmmoType::GLITCH, "Glitch Cryo", "GLITCH", "Corrupting code payload. Drops enemy accuracy drastically.", { 220, 50, 255, 255 }, 22, 0.80f }; + static const AmmoInfo infoNanite { AmmoType::NANITE, "Nanite Siphon", "NANITE", "Vampiric micro-drones. Deals damage & restores Player HP/Shield.", { 0, 255, 180, 255 }, 16, 1.0f }; + + switch (type) { + case AmmoType::KINETIC: return infoKinetic; + case AmmoType::EMP: return infoEMP; + case AmmoType::OVERHEAT: return infoOverheat; + case AmmoType::CORROSIVE: return infoCorrosive; + case AmmoType::GLITCH: return infoGlitch; + case AmmoType::NANITE: return infoNanite; + default: return infoEmpty; + } +} + +enum class Direction { + NORTH = 0, + EAST = 1, + SOUTH = 2, + WEST = 3 +}; + +inline Vector2 getDirectionOffset(Direction dir) { + switch (dir) { + case Direction::NORTH: return { 0.0f, -1.0f }; + case Direction::EAST: return { 1.0f, 0.0f }; + case Direction::SOUTH: return { 0.0f, 1.0f }; + case Direction::WEST: return { -1.0f, 0.0f }; + } + return { 0.0f, 0.0f }; +} + +inline float getDirectionAngle(Direction dir) { + switch (dir) { + case Direction::NORTH: return 0.0f; + case Direction::EAST: return 90.0f; + case Direction::SOUTH: return 180.0f; + case Direction::WEST: return 270.0f; + } + return 0.0f; +} + +enum class TileType { + EMPTY, + FLOOR, + WALL, + DOOR_LOCKED, + DOOR_UNLOCKED, + LOOT_CACHE, + EXIT_PORTAL +}; + +enum class StatusEffect { + NONE, + BURNING, // Thermal DoT + SHOCKED, // EMP stun / glitch + CORRODED, // Takes increased damage + GLITCHED // Accuracy penalty +}; + +struct ActiveStatus { + StatusEffect effect = StatusEffect::NONE; + int durationTurns = 0; + int power = 0; +}; + +// Cyberpunk color palette +namespace CyberColors { + inline const Color NeonCyan = { 0, 240, 255, 255 }; + inline const Color NeonPink = { 255, 0, 128, 255 }; + inline const Color NeonGreen = { 57, 255, 20, 255 }; + inline const Color NeonYellow = { 255, 230, 0, 255 }; + inline const Color NeonOrange = { 255, 120, 0, 255 }; + inline const Color NeonPurple = { 180, 0, 255, 255 }; + inline const Color DarkGrid = { 10, 14, 26, 255 }; + inline const Color TerminalBg = { 6, 8, 15, 240 }; + inline const Color WallBorder = { 30, 70, 120, 255 }; + inline const Color WallGlow = { 0, 180, 255, 80 }; +} + +} // namespace dnr diff --git a/src/core/Game.cpp b/src/core/Game.cpp new file mode 100644 index 0000000..2a4fbb7 --- /dev/null +++ b/src/core/Game.cpp @@ -0,0 +1,795 @@ +#include "Game.hpp" +#include "dungeon/DungeonGen.hpp" +#include "core/SoundManager.hpp" +#include +#include +#include + +namespace dnr { + +Game::Game() = default; +Game::~Game() { + m_renderer.cleanup(); + SoundManager::get().cleanup(); +} + +void Game::init() { + SoundManager::get().init(); + m_renderer.init(); + m_state = GameState::MAIN_MENU; + SoundManager::get().playTrack(MusicTrack::MENU_HUB); +} + +void Game::startNewRun() { + m_dungeon.setFloor(1); + m_player.reset(3, 4, Direction::NORTH); + m_hubRoom.init(m_player); + m_state = GameState::HUB; + SoundManager::get().playTrack(MusicTrack::MENU_HUB); + SoundManager::get().playPickup(); +} + +void Game::enterCyberspace() { + int floor = m_dungeon.getFloor(); + auto res = DungeonGen::generate(m_dungeon, floor); + m_player.setGridPosition(res.startX, res.startY); + m_player.setFacing(Direction::NORTH); + m_dungeon.reveal(res.startX, res.startY, 4); + m_state = GameState::EXPLORATION; + SoundManager::get().playTrack(MusicTrack::EXPLORATION); + SoundManager::get().playDoorOpen(); +} + +void Game::enterRestRoom() { + int completedFloor = m_dungeon.getFloor(); + m_restRoom.init(m_player, completedFloor); + m_state = GameState::REST_ROOM; + SoundManager::get().playTrack(MusicTrack::REST_ROOM); + SoundManager::get().playSkillGreat(); +} + +void Game::nextFloor() { + int nextF = m_dungeon.getFloor() + 1; + m_dungeon.setFloor(nextF); + enterCyberspace(); +} + +void Game::saveCurrentProgress() { + bool inHub = (m_state == GameState::HUB || m_previousState == GameState::HUB); + SaveSystem::saveGame("savegame.dat", m_dungeon.getFloor(), inHub, m_player); + m_saveFeedbackTimer = 2.0f; + SoundManager::get().playSkillSuccess(); +} + +bool Game::loadSavedProgress() { + SaveData data; + if (!SaveSystem::loadGame("savegame.dat", data)) return false; + + m_dungeon.setFloor(data.floor); + m_player.setStats(data.hp, data.maxHp, data.shield, data.maxShield, data.picks); + + // Restore cylinder chambers + auto& rev = m_player.getRevolver(); + for (int i = 0; i < 6; ++i) { + rev.setChamber(i, static_cast(data.chambers[i])); + } + + // Restore stacked ammo counts + for (size_t i = 0; i < data.ammoCounts.size(); ++i) { + m_player.setAmmoCount(static_cast(i), data.ammoCounts[i]); + } + + if (data.inHub) { + m_hubRoom.init(m_player); + m_state = GameState::HUB; + SoundManager::get().playTrack(MusicTrack::MENU_HUB); + } else { + auto res = DungeonGen::generate(m_dungeon, data.floor); + m_player.reset(res.startX, res.startY, Direction::NORTH); + m_dungeon.reveal(res.startX, res.startY, 4); + m_state = GameState::EXPLORATION; + SoundManager::get().playTrack(MusicTrack::EXPLORATION); + } + + SoundManager::get().playPickup(); + return true; +} + +void Game::update(float dt) { + m_animTime += dt; + if (m_saveFeedbackTimer > 0.0f) { + m_saveFeedbackTimer -= dt; + } + + // Update dynamic music streaming engine every frame + SoundManager::get().updateMusic(dt); + + switch (m_state) { + case GameState::MAIN_MENU: + updateMainMenu(dt); + break; + case GameState::HUB: + updateHub(dt); + break; + case GameState::REST_ROOM: + updateRestRoom(dt); + break; + case GameState::EXPLORATION: + updateExploration(dt); + break; + case GameState::HACKING: + updateHacking(dt); + break; + case GameState::COMBAT: + updateCombat(dt); + break; + case GameState::INVENTORY: + updateInventory(dt); + break; + case GameState::PAUSE_MENU: + updatePauseMenu(dt); + break; + case GameState::SETTINGS: + updateSettings(dt); + break; + case GameState::VICTORY_LEVEL: + if (IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_ENTER)) { + enterRestRoom(); + } + break; + case GameState::GAME_OVER: + if (IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_ENTER)) { + startNewRun(); + } + break; + } +} + +void Game::updateMainMenu(float dt) { + int cx = GetScreenWidth() / 2; + int cy = GetScreenHeight() / 2; + Vector2 mouse = GetMousePosition(); + bool mouseClicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + + Rectangle btnContinue = { static_cast(cx - 190), static_cast(cy - 50), 380.0f, 48.0f }; + Rectangle btnNewRun = { static_cast(cx - 190), static_cast(cy + 10), 380.0f, 48.0f }; + Rectangle btnSettings = { static_cast(cx - 190), static_cast(cy + 70), 380.0f, 48.0f }; + Rectangle btnExit = { static_cast(cx - 190), static_cast(cy + 130), 380.0f, 48.0f }; + + bool hasSave = SaveSystem::hasSaveFile(); + + if ((IsKeyPressed(KEY_ONE) || (mouseClicked && CheckCollisionPointRec(mouse, btnContinue))) && hasSave) { + loadSavedProgress(); + } else if (IsKeyPressed(KEY_TWO) || (mouseClicked && CheckCollisionPointRec(mouse, btnNewRun))) { + startNewRun(); + } else if (IsKeyPressed(KEY_THREE) || (mouseClicked && CheckCollisionPointRec(mouse, btnSettings))) { + m_previousState = GameState::MAIN_MENU; + m_state = GameState::SETTINGS; + SoundManager::get().playTumblerClick(); + } else if (IsKeyPressed(KEY_FOUR) || (mouseClicked && CheckCollisionPointRec(mouse, btnExit))) { + m_shouldQuit = true; + } +} + +void Game::updateHub(float dt) { + m_hubRoom.update(dt, m_player); + + Vector2 dirVec = getDirectionOffset(m_player.getFacing()); + + // Check interaction context + m_contextPrompt = ""; + if (m_hubRoom.isFacingPortal(m_player)) { + m_contextPrompt = "[SPACE / E] JACK IN // ENTER CYBERSPACE GATEWAY"; + } else if (m_hubRoom.isFacingTerminal(m_player)) { + m_contextPrompt = "[SPACE / E] DESK TERMINAL // SAVE RUN PROGRESS"; + } + + if (IsKeyPressed(KEY_W) || IsKeyPressed(KEY_UP)) { + tryMoveHub(static_cast(dirVec.x), static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) { + tryMoveHub(-static_cast(dirVec.x), -static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_A) || IsKeyPressed(KEY_LEFT)) { + m_player.turnLeft(); + } + if (IsKeyPressed(KEY_D) || IsKeyPressed(KEY_RIGHT)) { + m_player.turnRight(); + } + if (IsKeyPressed(KEY_Q)) { + Vector2 strafe = { dirVec.y, -dirVec.x }; + tryMoveHub(static_cast(strafe.x), static_cast(strafe.y)); + } + if (IsKeyPressed(KEY_E)) { + if (!m_contextPrompt.empty()) { + tryInteractHub(); + } else { + Vector2 strafe = { -dirVec.y, dirVec.x }; + tryMoveHub(static_cast(strafe.x), static_cast(strafe.y)); + } + } + if (IsKeyPressed(KEY_SPACE) && !m_contextPrompt.empty()) { + tryInteractHub(); + } + + if (IsKeyPressed(KEY_TAB)) { + m_previousState = GameState::HUB; + m_state = GameState::INVENTORY; + SoundManager::get().playTumblerClick(); + } + + if (IsKeyPressed(KEY_ESCAPE)) { + m_previousState = GameState::HUB; + m_state = GameState::PAUSE_MENU; + SoundManager::get().playTumblerClick(); + } +} + +void Game::tryMoveHub(int dx, int dy) { + m_hubRoom.tryMove(dx, dy, m_player); +} + +void Game::tryInteractHub() { + if (m_hubRoom.isFacingPortal(m_player)) { + enterCyberspace(); + } else if (m_hubRoom.isFacingTerminal(m_player)) { + saveCurrentProgress(); + } +} + +void Game::updateRestRoom(float dt) { + m_restRoom.update(dt, m_player); + + Vector2 dirVec = getDirectionOffset(m_player.getFacing()); + + // Check interaction context + m_contextPrompt = ""; + if (m_restRoom.isFacingGateway(m_player)) { + m_contextPrompt = "[SPACE / E] DIVE INTO LAYER 0" + std::to_string(m_restRoom.getNextFloor()) + " // CYBER GATEWAY"; + } else if (m_restRoom.isFacingRechargeMatrix(m_player)) { + if (!m_restRoom.isRecharged()) { + m_contextPrompt = "[SPACE / E] NANITE MATRIX // FULL RESTORATION (HP & SHIELD)"; + } else { + m_contextPrompt = "NANITE MATRIX RECHARGED // SYSTEMS OPTIMAL"; + } + } else if (m_restRoom.isFacingTerminal(m_player)) { + m_contextPrompt = "[SPACE / E] ARCHIVE TERMINAL // SAVE RUN PROGRESS"; + } + + if (IsKeyPressed(KEY_W) || IsKeyPressed(KEY_UP)) { + tryMoveRestRoom(static_cast(dirVec.x), static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) { + tryMoveRestRoom(-static_cast(dirVec.x), -static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_A) || IsKeyPressed(KEY_LEFT)) { + m_player.turnLeft(); + } + if (IsKeyPressed(KEY_D) || IsKeyPressed(KEY_RIGHT)) { + m_player.turnRight(); + } + if (IsKeyPressed(KEY_Q)) { + Vector2 strafe = { dirVec.y, -dirVec.x }; + tryMoveRestRoom(static_cast(strafe.x), static_cast(strafe.y)); + } + if (IsKeyPressed(KEY_E)) { + if (!m_contextPrompt.empty()) { + tryInteractRestRoom(); + } else { + Vector2 strafe = { -dirVec.y, dirVec.x }; + tryMoveRestRoom(static_cast(strafe.x), static_cast(strafe.y)); + } + } + if (IsKeyPressed(KEY_SPACE) && !m_contextPrompt.empty()) { + tryInteractRestRoom(); + } + + if (IsKeyPressed(KEY_TAB)) { + m_previousState = GameState::REST_ROOM; + m_state = GameState::INVENTORY; + SoundManager::get().playTumblerClick(); + } + + if (IsKeyPressed(KEY_ESCAPE)) { + m_previousState = GameState::REST_ROOM; + m_state = GameState::PAUSE_MENU; + SoundManager::get().playTumblerClick(); + } +} + +void Game::tryMoveRestRoom(int dx, int dy) { + m_restRoom.tryMove(dx, dy, m_player); +} + +void Game::tryInteractRestRoom() { + if (m_restRoom.isFacingGateway(m_player)) { + nextFloor(); + } else if (m_restRoom.isFacingRechargeMatrix(m_player)) { + if (!m_restRoom.isRecharged()) { + m_restRoom.triggerRecharge(m_player); + SoundManager::get().playPickup(); + } + } else if (m_restRoom.isFacingTerminal(m_player)) { + saveCurrentProgress(); + } +} + +void Game::updateExploration(float dt) { + m_player.update(dt); + m_dungeon.reveal(m_player.getGridX(), m_player.getGridY(), 3); + + Vector2 dirVec = getDirectionOffset(m_player.getFacing()); + int frontX = m_player.getGridX() + static_cast(dirVec.x); + int frontY = m_player.getGridY() + static_cast(dirVec.y); + + m_contextPrompt = ""; + if (m_dungeon.isDoorLocked(frontX, frontY)) { + m_contextPrompt = "[SPACE / E] Bypass Door ICE [Picks: " + std::to_string(m_player.getCyberPicks()) + "]"; + } else if (m_dungeon.getTile(frontX, frontY) == TileType::LOOT_CACHE || + m_dungeon.getTile(m_player.getGridX(), m_player.getGridY()) == TileType::LOOT_CACHE) { + m_contextPrompt = "[SPACE / E] Extract Data Cache"; + } else if (m_dungeon.getTile(m_player.getGridX(), m_player.getGridY()) == TileType::EXIT_PORTAL || + m_dungeon.getTile(frontX, frontY) == TileType::EXIT_PORTAL) { + m_contextPrompt = "[SPACE / E] Jack Out To Safe Haven Rest Node"; + } + + if (IsKeyPressed(KEY_W) || IsKeyPressed(KEY_UP)) { + tryMoveDungeon(static_cast(dirVec.x), static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) { + tryMoveDungeon(-static_cast(dirVec.x), -static_cast(dirVec.y)); + } + if (IsKeyPressed(KEY_A) || IsKeyPressed(KEY_LEFT)) { + m_player.turnLeft(); + } + if (IsKeyPressed(KEY_D) || IsKeyPressed(KEY_RIGHT)) { + m_player.turnRight(); + } + if (IsKeyPressed(KEY_Q)) { + Vector2 strafe = { dirVec.y, -dirVec.x }; + tryMoveDungeon(static_cast(strafe.x), static_cast(strafe.y)); + } + if (IsKeyPressed(KEY_E)) { + if (!m_contextPrompt.empty()) { + tryInteractDungeon(); + } else { + Vector2 strafe = { -dirVec.y, dirVec.x }; + tryMoveDungeon(static_cast(strafe.x), static_cast(strafe.y)); + } + } + if (IsKeyPressed(KEY_SPACE) && !m_contextPrompt.empty()) { + tryInteractDungeon(); + } + + if (IsKeyPressed(KEY_TAB)) { + m_previousState = GameState::EXPLORATION; + m_state = GameState::INVENTORY; + SoundManager::get().setInventoryDucked(true); + SoundManager::get().playTumblerClick(); + } + + if (IsKeyPressed(KEY_ESCAPE)) { + m_previousState = GameState::EXPLORATION; + m_state = GameState::PAUSE_MENU; + SoundManager::get().playTumblerClick(); + } + + // Step on enemy + MapEnemy* me = m_dungeon.getEnemyAt(m_player.getGridX(), m_player.getGridY()); + if (me && me->alive) { + m_state = GameState::COMBAT; + m_showCombatCodex = false; + m_lastPlayerHp = m_player.getHp(); + SoundManager::get().playTrack(MusicTrack::COMBAT); + m_combat.startCombat(m_player, std::make_unique(me->enemy.getType(), m_dungeon.getFloor())); + m_dungeon.removeEnemyAt(me->x, me->y); + } +} + +void Game::tryMoveDungeon(int dx, int dy) { + int nx = m_player.getGridX() + dx; + int ny = m_player.getGridY() + dy; + + if (m_dungeon.isWalkable(nx, ny)) { + m_player.setGridPosition(nx, ny); + + MapEnemy* me = m_dungeon.getEnemyAt(nx, ny); + if (me && me->alive) { + m_state = GameState::COMBAT; + m_showCombatCodex = false; + m_lastPlayerHp = m_player.getHp(); + SoundManager::get().playTrack(MusicTrack::COMBAT); + m_combat.startCombat(m_player, std::make_unique(me->enemy.getType(), m_dungeon.getFloor())); + m_dungeon.removeEnemyAt(nx, ny); + } + } else if (m_dungeon.isDoorLocked(nx, ny)) { + m_targetDoorX = nx; + m_targetDoorY = ny; + } +} + +void Game::tryInteractDungeon() { + Vector2 dirVec = getDirectionOffset(m_player.getFacing()); + int fx = m_player.getGridX() + static_cast(dirVec.x); + int fy = m_player.getGridY() + static_cast(dirVec.y); + + int px = m_player.getGridX(); + int py = m_player.getGridY(); + + if (m_dungeon.isDoorLocked(fx, fy)) { + if (m_player.getCyberPicks() > 0) { + m_targetDoorX = fx; + m_targetDoorY = fy; + DoorData* dd = m_dungeon.getDoorAt(fx, fy); + int sec = dd ? dd->securityLevel : 1; + m_hacking.start(sec); + m_state = GameState::HACKING; + SoundManager::get().playTumblerClick(); + } else { + SoundManager::get().playDamage(); + } + return; + } + + LootData* ld = m_dungeon.getLootAt(fx, fy); + if (!ld) ld = m_dungeon.getLootAt(px, py); + + if (ld) { + ld->looted = true; + m_dungeon.setTile(ld->x, ld->y, TileType::FLOOR); + for (auto a : ld->ammoRewards) { + m_player.addAmmo(a, 1); + } + m_player.addPicks(ld->picksReward); + SoundManager::get().playPickup(); + return; + } + + if (m_dungeon.getTile(px, py) == TileType::EXIT_PORTAL || + m_dungeon.getTile(fx, fy) == TileType::EXIT_PORTAL) { + enterRestRoom(); + return; + } +} + +void Game::updateHacking(float dt) { + if (IsKeyPressed(KEY_ESCAPE)) { + m_previousState = GameState::HACKING; + m_state = GameState::PAUSE_MENU; + SoundManager::get().playTumblerClick(); + return; + } + + m_hacking.update(dt); + m_hacking.handleInput(); + + if (m_hacking.isFinished() && m_hacking.getResultTimer() > 1.2f) { + if (m_hacking.isSuccess()) { + m_dungeon.unlockDoorAt(m_targetDoorX, m_targetDoorY); + SoundManager::get().playDoorOpen(); + + if (m_hacking.isCritical()) { + m_player.addAmmo(AmmoType::NANITE, 1); + m_player.addPicks(1); + } + m_state = GameState::EXPLORATION; + } else if (m_hacking.isAlarmTriggered()) { + m_player.usePick(); + m_state = GameState::COMBAT; + m_showCombatCodex = false; + m_lastPlayerHp = m_player.getHp(); + SoundManager::get().playTrack(MusicTrack::COMBAT); + m_combat.startCombat(m_player, std::make_unique(EnemyType::ICE_SENTINEL, m_dungeon.getFloor())); + } + } +} + +void Game::updateCombat(float dt) { + Vector2 mouse = GetMousePosition(); + bool mouseClicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + + int screenW = GetScreenWidth(); + int screenH = GetScreenHeight(); + int modalW = 980; + int modalH = 580; + int modalX = (screenW - modalW) / 2; + int modalY = (screenH - modalH) / 2; + Rectangle btnCloseCodex = { static_cast(modalX + modalW / 2 - 120), static_cast(modalY + modalH - 52), 240.0f, 38.0f }; + + int logX = 770; + int pvY = 180 + 210 + 12; + Rectangle btnCodexToggle = { static_cast(logX + 460 - 95), static_cast(pvY + 25), 85.0f, 40.0f }; + + // Toggle Codex Guide Modal + if (m_showCombatCodex) { + if (IsKeyPressed(KEY_H) || IsKeyPressed(KEY_ESCAPE) || (mouseClicked && CheckCollisionPointRec(mouse, btnCloseCodex))) { + m_showCombatCodex = false; + SoundManager::get().playTumblerClick(); + } + return; + } + + if (IsKeyPressed(KEY_H) || (mouseClicked && CheckCollisionPointRec(mouse, btnCodexToggle))) { + m_showCombatCodex = true; + SoundManager::get().playTumblerClick(); + return; + } + + if (IsKeyPressed(KEY_ESCAPE) && m_combat.getPhase() == CombatPhase::PLAYER_TURN) { + m_previousState = GameState::COMBAT; + m_state = GameState::PAUSE_MENU; + SoundManager::get().playTumblerClick(); + return; + } + + m_combat.update(dt, m_player); + + // Trigger glitch post-processing effect whenever player takes damage + int curHp = m_player.getHp(); + if (curHp < m_lastPlayerHp) { + m_renderer.triggerGlitch(0.7f); + } + m_lastPlayerHp = curHp; + + int barX = 50; + int barY = 548; + Rectangle btnShoot = { static_cast(barX + 15), static_cast(barY + 12), 370.0f, 96.0f }; + Rectangle btnShield = { static_cast(barX + 405), static_cast(barY + 12), 370.0f, 96.0f }; + Rectangle btnTriple = { static_cast(barX + 795), static_cast(barY + 12), 370.0f, 96.0f }; + + if (m_combat.getPhase() == CombatPhase::PLAYER_TURN) { + bool doShoot = IsKeyPressed(KEY_ONE) || IsKeyPressed(KEY_KP_1) || + IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_ENTER) || + (mouseClicked && CheckCollisionPointRec(mouse, btnShoot)); + + bool doShield = IsKeyPressed(KEY_TWO) || IsKeyPressed(KEY_KP_2) || + (mouseClicked && CheckCollisionPointRec(mouse, btnShield)); + + bool doTriple = IsKeyPressed(KEY_THREE) || IsKeyPressed(KEY_KP_3) || + (mouseClicked && CheckCollisionPointRec(mouse, btnTriple)); + + if (doShoot) { + m_combat.actionSpinAndShoot(m_player); + } else if (doShield) { + m_combat.actionShield(m_player); + } else if (doTriple && m_combat.getTripleCooldown() == 0) { + m_combat.actionTripleShot(m_player); + } + } else if (m_combat.getPhase() == CombatPhase::VICTORY) { + if (IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_ENTER) || mouseClicked) { + m_player.heal(25); + m_player.addAmmo(AmmoType::KINETIC, 1); + m_player.addAmmo(AmmoType::EMP, 1); + m_state = GameState::EXPLORATION; + SoundManager::get().playTrack(MusicTrack::EXPLORATION, true); + } + } else if (m_combat.getPhase() == CombatPhase::DEFEAT) { + if (IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_ENTER) || mouseClicked) { + m_state = GameState::GAME_OVER; + } + } +} + +void Game::updateInventory(float dt) { + m_player.getRevolver().update(dt); + m_inventoryUI.update(m_player); + + if (IsKeyPressed(KEY_TAB) || IsKeyPressed(KEY_ESCAPE)) { + SoundManager::get().setInventoryDucked(false); + m_state = m_previousState; + SoundManager::get().playTumblerClick(); + } +} + +void Game::updatePauseMenu(float dt) { + int menuW = 480; + int menuH = 430; + int menuX = (GetScreenWidth() - menuW) / 2; + int menuY = (GetScreenHeight() - menuH) / 2; + int startY = menuY + 90; + + Vector2 mouse = GetMousePosition(); + bool mouseClicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + + Rectangle btnResume = { static_cast(menuX + 30), static_cast(startY), static_cast(menuW - 60), 48.0f }; + Rectangle btnSettings = { static_cast(menuX + 30), static_cast(startY + 58), static_cast(menuW - 60), 48.0f }; + Rectangle btnSave = { static_cast(menuX + 30), static_cast(startY + 58 * 2), static_cast(menuW - 60), 48.0f }; + Rectangle btnHub = { static_cast(menuX + 30), static_cast(startY + 58 * 3), static_cast(menuW - 60), 48.0f }; + Rectangle btnQuit = { static_cast(menuX + 30), static_cast(startY + 58 * 4), static_cast(menuW - 60), 48.0f }; + + if (IsKeyPressed(KEY_ONE) || IsKeyPressed(KEY_ESCAPE) || (mouseClicked && CheckCollisionPointRec(mouse, btnResume))) { + m_state = m_previousState; + SoundManager::get().playTumblerClick(); + } else if (IsKeyPressed(KEY_TWO) || (mouseClicked && CheckCollisionPointRec(mouse, btnSettings))) { + m_previousState = GameState::PAUSE_MENU; + m_state = GameState::SETTINGS; + SoundManager::get().playTumblerClick(); + } else if (IsKeyPressed(KEY_THREE) || (mouseClicked && CheckCollisionPointRec(mouse, btnSave))) { + saveCurrentProgress(); + } else if (IsKeyPressed(KEY_FOUR) || (mouseClicked && CheckCollisionPointRec(mouse, btnHub))) { + m_hubRoom.init(m_player); + m_state = GameState::HUB; + SoundManager::get().playTrack(MusicTrack::MENU_HUB); + SoundManager::get().playDoorOpen(); + } else if (IsKeyPressed(KEY_FIVE) || (mouseClicked && CheckCollisionPointRec(mouse, btnQuit))) { + m_shouldQuit = true; + } +} + +void Game::updateSettings(float dt) { + auto& sm = SoundManager::get(); + float mVol = sm.getMasterVolume(); + float muVol = sm.getMusicVolume(); + float sVol = sm.getSfxVolume(); + + // Keyboard selection & adjustment + if (IsKeyPressed(KEY_W) || IsKeyPressed(KEY_UP)) { + m_selectedSlider = (m_selectedSlider + 2) % 3; + sm.playTumblerClick(); + } + if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) { + m_selectedSlider = (m_selectedSlider + 1) % 3; + sm.playTumblerClick(); + } + + if (IsKeyPressed(KEY_A) || IsKeyPressed(KEY_LEFT)) { + if (m_selectedSlider == 0) sm.setMasterVolume(mVol - 0.05f); + else if (m_selectedSlider == 1) sm.setMusicVolume(muVol - 0.05f); + else if (m_selectedSlider == 2) sm.setSfxVolume(sVol - 0.05f); + sm.playTumblerClick(); + } + if (IsKeyPressed(KEY_D) || IsKeyPressed(KEY_RIGHT)) { + if (m_selectedSlider == 0) sm.setMasterVolume(mVol + 0.05f); + else if (m_selectedSlider == 1) sm.setMusicVolume(muVol + 0.05f); + else if (m_selectedSlider == 2) sm.setSfxVolume(sVol + 0.05f); + sm.playTumblerClick(); + } + + // Mouse Interaction + Vector2 mouse = GetMousePosition(); + int menuW = 580; + int menuH = 430; + int menuX = (GetScreenWidth() - menuW) / 2; + int menuY = (GetScreenHeight() - menuH) / 2; + int startY = menuY + 85; + int sliderW = 340; + int sliderH = 26; + int sliderX = menuX + 180; + + Rectangle btnBack = { static_cast(menuX + (menuW - 240) / 2), static_cast(menuY + menuH - 65), 240.0f, 44.0f }; + + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + if (CheckCollisionPointRec(mouse, btnBack)) { + m_state = m_previousState; + sm.playTumblerClick(); + return; + } + + for (int i = 0; i < 3; ++i) { + int itemY = startY + i * 78; + Rectangle sBar = { static_cast(sliderX - 10), static_cast(itemY - 6), static_cast(sliderW + 20), static_cast(sliderH + 12) }; + if (CheckCollisionPointRec(mouse, sBar)) { + m_selectedSlider = i; + m_isDraggingSlider = true; + float ratio = std::clamp((mouse.x - sliderX) / static_cast(sliderW), 0.0f, 1.0f); + if (i == 0) sm.setMasterVolume(ratio); + else if (i == 1) sm.setMusicVolume(ratio); + else if (i == 2) sm.setSfxVolume(ratio); + sm.playTumblerClick(); + break; + } + } + } + + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && m_isDraggingSlider) { + float ratio = std::clamp((mouse.x - sliderX) / static_cast(sliderW), 0.0f, 1.0f); + if (m_selectedSlider == 0) sm.setMasterVolume(ratio); + else if (m_selectedSlider == 1) sm.setMusicVolume(ratio); + else if (m_selectedSlider == 2) sm.setSfxVolume(ratio); + } + + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + m_isDraggingSlider = false; + } + + if (IsKeyPressed(KEY_ESCAPE) || IsKeyPressed(KEY_ENTER)) { + m_state = m_previousState; + sm.playTumblerClick(); + } +} + +void Game::draw() { + BeginDrawing(); + ClearBackground(Color{ 4, 6, 12, 255 }); + + switch (m_state) { + case GameState::MAIN_MENU: + m_uiRenderer.drawMainMenu(SaveSystem::hasSaveFile()); + break; + + case GameState::HUB: + m_renderer.renderHub(m_hubRoom, m_player, m_animTime); + m_uiRenderer.drawHubHUD(m_player, m_contextPrompt); + break; + + case GameState::REST_ROOM: + m_renderer.renderRestRoom(m_restRoom, m_player, m_animTime); + m_uiRenderer.drawRestRoomHUD(m_restRoom.getNextFloor(), m_player, m_contextPrompt); + m_uiRenderer.drawMatrixOverlay(m_animTime); + break; + + case GameState::EXPLORATION: + m_renderer.render3D(m_dungeon, m_player, m_animTime); + m_uiRenderer.drawExplorationHUD(m_dungeon, m_player, m_contextPrompt); + m_uiRenderer.drawMatrixOverlay(m_animTime); + break; + + case GameState::HACKING: + m_renderer.render3D(m_dungeon, m_player, m_animTime); + m_uiRenderer.drawHackingUI(m_hacking); + m_uiRenderer.drawMatrixOverlay(m_animTime); + break; + + case GameState::COMBAT: + m_renderer.renderCombat3D(m_dungeon, m_player, m_combat, m_animTime); + m_uiRenderer.drawCombatUI(m_combat, m_player, m_animTime, m_showCombatCodex); + m_uiRenderer.drawMatrixOverlay(m_animTime); + break; + + case GameState::INVENTORY: + if (m_previousState == GameState::HUB) { + m_renderer.renderHub(m_hubRoom, m_player, m_animTime); + } else if (m_previousState == GameState::REST_ROOM) { + m_renderer.renderRestRoom(m_restRoom, m_player, m_animTime); + } else { + m_renderer.render3D(m_dungeon, m_player, m_animTime); + } + m_inventoryUI.draw(m_player); + break; + + case GameState::PAUSE_MENU: + if (m_previousState == GameState::HUB) { + m_renderer.renderHub(m_hubRoom, m_player, m_animTime); + m_uiRenderer.drawHubHUD(m_player, m_contextPrompt); + } else if (m_previousState == GameState::REST_ROOM) { + m_renderer.renderRestRoom(m_restRoom, m_player, m_animTime); + m_uiRenderer.drawRestRoomHUD(m_restRoom.getNextFloor(), m_player, m_contextPrompt); + } else { + m_renderer.render3D(m_dungeon, m_player, m_animTime); + m_uiRenderer.drawExplorationHUD(m_dungeon, m_player, m_contextPrompt); + } + m_uiRenderer.drawPauseMenu(m_saveFeedbackTimer > 0.0f); + break; + + case GameState::SETTINGS: + if (m_previousState == GameState::MAIN_MENU) { + m_uiRenderer.drawMainMenu(SaveSystem::hasSaveFile()); + } else if (m_previousState == GameState::HUB) { + m_renderer.renderHub(m_hubRoom, m_player, m_animTime); + m_uiRenderer.drawHubHUD(m_player, m_contextPrompt); + } else if (m_previousState == GameState::REST_ROOM) { + m_renderer.renderRestRoom(m_restRoom, m_player, m_animTime); + m_uiRenderer.drawRestRoomHUD(m_restRoom.getNextFloor(), m_player, m_contextPrompt); + } else { + m_renderer.render3D(m_dungeon, m_player, m_animTime); + m_uiRenderer.drawExplorationHUD(m_dungeon, m_player, m_contextPrompt); + } + m_uiRenderer.drawSettingsMenu(SoundManager::get().getMasterVolume(), + SoundManager::get().getMusicVolume(), + SoundManager::get().getSfxVolume(), + m_selectedSlider); + break; + + case GameState::VICTORY_LEVEL: + m_uiRenderer.drawLevelClearScreen(m_dungeon.getFloor()); + break; + + case GameState::GAME_OVER: + m_uiRenderer.drawGameOverScreen(); + break; + } + + EndDrawing(); +} + +} // namespace dnr diff --git a/src/core/Game.hpp b/src/core/Game.hpp new file mode 100644 index 0000000..bc4a56b --- /dev/null +++ b/src/core/Game.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Dungeon.hpp" +#include "dungeon/Player.hpp" +#include "hacking/HackingGame.hpp" +#include "combat/CombatSystem.hpp" +#include "inventory/InventoryUI.hpp" +#include "render/CyberRenderer.hpp" +#include "render/UIRenderer.hpp" +#include "hub/HubRoom.hpp" +#include "hub/RestRoom.hpp" +#include "core/SaveSystem.hpp" + +namespace dnr { + +class Game { +public: + Game(); + ~Game(); + + void init(); + void update(float dt); + void draw(); + + bool shouldClose() const { return m_shouldQuit || WindowShouldClose(); } + +private: + void startNewRun(); + void enterCyberspace(); + void enterRestRoom(); + void nextFloor(); + + void saveCurrentProgress(); + bool loadSavedProgress(); + + void updateMainMenu(float dt); + void updateHub(float dt); + void updateRestRoom(float dt); + void updateExploration(float dt); + void updateHacking(float dt); + void updateCombat(float dt); + void updateInventory(float dt); + void updatePauseMenu(float dt); + void updateSettings(float dt); + + void tryInteractDungeon(); + void tryInteractHub(); + void tryInteractRestRoom(); + void tryMoveDungeon(int dx, int dy); + void tryMoveHub(int dx, int dy); + void tryMoveRestRoom(int dx, int dy); + + GameState m_state = GameState::MAIN_MENU; + GameState m_previousState = GameState::HUB; + + HubRoom m_hubRoom; + RestRoom m_restRoom; + Dungeon m_dungeon; + Player m_player; + HackingGame m_hacking; + CombatSystem m_combat; + InventoryUI m_inventoryUI; + + CyberRenderer m_renderer; + UIRenderer m_uiRenderer; + + float m_animTime = 0.0f; + float m_saveFeedbackTimer = 0.0f; + std::string m_contextPrompt; + + int m_targetDoorX = -1; + int m_targetDoorY = -1; + + int m_selectedSlider = 0; + bool m_isDraggingSlider = false; + + bool m_showCombatCodex = false; + int m_lastPlayerHp = 100; + + bool m_shouldQuit = false; +}; + +} // namespace dnr diff --git a/src/core/SaveSystem.cpp b/src/core/SaveSystem.cpp new file mode 100644 index 0000000..6c87d70 --- /dev/null +++ b/src/core/SaveSystem.cpp @@ -0,0 +1,84 @@ +#include "SaveSystem.hpp" +#include "dungeon/Player.hpp" +#include +#include + +namespace dnr { + +bool SaveSystem::saveGame(const std::string& path, int floor, bool inHub, const Player& player) { + std::ofstream out(path); + if (!out.is_open()) return false; + + out << "DOORNROLL_SAVE_V1\n"; + out << "floor " << floor << "\n"; + out << "inHub " << (inHub ? 1 : 0) << "\n"; + out << "hp " << player.getHp() << " " << player.getMaxHp() << "\n"; + out << "shield " << player.getShield() << " " << player.getMaxShield() << "\n"; + out << "picks " << player.getCyberPicks() << "\n"; + + out << "chambers"; + const auto& rev = player.getRevolver(); + for (int i = 0; i < 6; ++i) { + out << " " << static_cast(rev.getChamber(i)); + } + out << "\n"; + + out << "ammo"; + const auto& counts = player.getAmmoCounts(); + for (size_t i = 0; i < counts.size(); ++i) { + out << " " << counts[i]; + } + out << "\n"; + + return true; +} + +bool SaveSystem::loadGame(const std::string& path, SaveData& outData) { + std::ifstream in(path); + if (!in.is_open()) return false; + + std::string magic; + in >> magic; + if (magic != "DOORNROLL_SAVE_V1") return false; + + std::string key; + while (in >> key) { + if (key == "floor") { + in >> outData.floor; + } else if (key == "inHub") { + int h = 0; + in >> h; + outData.inHub = (h != 0); + } else if (key == "hp") { + in >> outData.hp >> outData.maxHp; + } else if (key == "shield") { + in >> outData.shield >> outData.maxShield; + } else if (key == "picks") { + in >> outData.picks; + } else if (key == "chambers") { + for (int i = 0; i < 6; ++i) { + in >> outData.chambers[i]; + } + } else if (key == "ammo") { + for (size_t i = 0; i < outData.ammoCounts.size(); ++i) { + in >> outData.ammoCounts[i]; + } + } + } + + return true; +} + +bool SaveSystem::hasSaveFile(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) return false; + std::string magic; + in >> magic; + return magic == "DOORNROLL_SAVE_V1"; +} + +void SaveSystem::deleteSaveFile(const std::string& path) { + std::remove(path.c_str()); +} + +} // namespace dnr diff --git a/src/core/SaveSystem.hpp b/src/core/SaveSystem.hpp new file mode 100644 index 0000000..a9d3c7f --- /dev/null +++ b/src/core/SaveSystem.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "core/Common.hpp" +#include +#include + +namespace dnr { + +class Player; + +struct SaveData { + int floor = 1; + bool inHub = false; + int hp = 100; + int maxHp = 100; + int shield = 40; + int maxShield = 40; + int picks = 5; + std::array chambers{}; + std::array ammoCounts{}; +}; + +class SaveSystem { +public: + static bool saveGame(const std::string& path, int floor, bool inHub, const Player& player); + static bool loadGame(const std::string& path, SaveData& outData); + static bool hasSaveFile(const std::string& path = "savegame.dat"); + static void deleteSaveFile(const std::string& path = "savegame.dat"); +}; + +} // namespace dnr diff --git a/src/core/SoundManager.cpp b/src/core/SoundManager.cpp new file mode 100644 index 0000000..dd8116d --- /dev/null +++ b/src/core/SoundManager.cpp @@ -0,0 +1,402 @@ +#include "SoundManager.hpp" +#include +#include +#include +#include +#include +#include + +namespace dnr { + +void SoundManager::init() { + if (m_initialized) return; + + InitAudioDevice(); + + // 1. Procedural Tone Fallbacks + Sound fbShot = createToneSound(440.0f, 60.0f, 0.22f, 0.85f, true); + Sound fbRespin = createToneSound(800.0f, 350.0f, 0.08f, 0.6f, false); + Sound fbTriple = createToneSound(440.0f, 60.0f, 0.50f, 0.9f, true); + Sound fbEmpty = createToneSound(750.0f, 450.0f, 0.05f, 0.65f, false); + Sound fbLoad = createToneSound(520.0f, 780.0f, 0.10f, 0.70f, false); + + // 2. Load Real Audio Assets from assets/sounds/Revolver/ + m_sndShot = loadSoundWithCandidates({ + "assets/sounds/Revolver/one_shot.wav", + "assets/sounds/Revolver/one_shot.mp3", + "assets/sounds/revolver/one_shot.wav", + "assets/sounds/revolver/one_shot.mp3", + "../assets/sounds/Revolver/one_shot.wav", + "../assets/sounds/Revolver/one_shot.mp3" + }, fbShot); + + m_sndRespin = loadSoundWithCandidates({ + "assets/sounds/Revolver/roller.wav", + "assets/sounds/Revolver/roller.mp3", + "assets/sounds/revolver/roller.wav", + "assets/sounds/revolver/roller.mp3", + "../assets/sounds/Revolver/roller.wav", + "../assets/sounds/Revolver/roller.mp3" + }, fbRespin); + + m_sndTripleShot = loadSoundWithCandidates({ + "assets/sounds/Revolver/three_shots.wav", + "assets/sounds/Revolver/three_shots.mp3", + "assets/sounds/revolver/three_shots.wav", + "assets/sounds/revolver/three_shots.mp3", + "../assets/sounds/Revolver/three_shots.wav", + "../assets/sounds/Revolver/three_shots.mp3" + }, fbTriple); + + m_sndEmpty = loadSoundWithCandidates({ + "assets/sounds/Revolver/empty.wav", + "assets/sounds/Revolver/empty.mp3", + "assets/sounds/revolver/empty.wav", + "assets/sounds/revolver/empty.mp3", + "../assets/sounds/Revolver/empty.wav", + "../assets/sounds/Revolver/empty.mp3" + }, fbEmpty); + + m_sndLoadShell = loadSoundWithCandidates({ + "assets/sounds/Revolver/loadshell.wav", + "assets/sounds/Revolver/loadshell.mp3", + "assets/sounds/revolver/loadshell.wav", + "assets/sounds/revolver/loadshell.mp3", + "../assets/sounds/Revolver/loadshell.wav", + "../assets/sounds/Revolver/loadshell.mp3" + }, fbLoad); + + // 3. UI and Lockpicking Synthesized Audio + m_sndTumbler = createToneSound(1200.0f, 900.0f, 0.04f, 0.7f, false); + m_sndTick = createToneSound(880.0f, 880.0f, 0.05f, 0.5f, false); + m_sndSkillSuccess = createToneSound(660.0f, 990.0f, 0.20f, 0.7f, false); + m_sndSkillGreat = createToneSound(990.0f, 1400.0f, 0.35f, 0.8f, false); + m_sndAlarm = createToneSound(400.0f, 900.0f, 0.45f, 0.9f, true); + m_sndDamage = createToneSound(220.0f, 80.0f, 0.25f, 0.8f, true); + m_sndDoor = createToneSound(300.0f, 150.0f, 0.30f, 0.5f, true); + m_sndPickup = createToneSound(523.25f, 1046.5f, 0.18f, 0.6f, false); + m_sndEnemyHit = createToneSound(320.0f, 120.0f, 0.15f, 0.75f, false); + + // 4. Load Dynamic Music Streams (OST) + m_musMenuHub = loadMusicWithCandidates({ + "assets/sounds/ost/breathe_deeper.ogg", + "assets/sounds/ost/Tame Impala - Breathe Deeper.mp3", + "../assets/sounds/ost/breathe_deeper.ogg", + "../assets/sounds/ost/Tame Impala - Breathe Deeper.mp3" + }); + + m_musRestRoom = loadMusicWithCandidates({ + "assets/sounds/ost/space_train.ogg", + "assets/sounds/ost/Spiral Drive - Space Train.ogg", + "../assets/sounds/ost/space_train.ogg" + }); + + m_musExploration = loadMusicWithCandidates({ + "assets/sounds/ost/zoom_noir.ogg", + "assets/sounds/ost/i9incher - Zoom Noir.ogg", + "../assets/sounds/ost/zoom_noir.ogg" + }); + + m_musCombat = loadMusicWithCandidates({ + "assets/sounds/ost/rush.ogg", + "assets/sounds/ost/Kaito Shoma - Rush.ogg", + "../assets/sounds/ost/rush.ogg" + }); + + // Load persisted settings + loadSettings(); + + m_initialized = true; +} + +void SoundManager::cleanup() { + if (!m_initialized) return; + + stopMusic(); + + if (IsMusicValid(m_musMenuHub)) UnloadMusicStream(m_musMenuHub); + if (IsMusicValid(m_musRestRoom)) UnloadMusicStream(m_musRestRoom); + if (IsMusicValid(m_musExploration)) UnloadMusicStream(m_musExploration); + if (IsMusicValid(m_musCombat)) UnloadMusicStream(m_musCombat); + + UnloadSound(m_sndShot); + UnloadSound(m_sndTripleShot); + UnloadSound(m_sndRespin); + UnloadSound(m_sndEmpty); + UnloadSound(m_sndLoadShell); + UnloadSound(m_sndTumbler); + UnloadSound(m_sndTick); + UnloadSound(m_sndSkillSuccess); + UnloadSound(m_sndSkillGreat); + UnloadSound(m_sndAlarm); + UnloadSound(m_sndDamage); + UnloadSound(m_sndDoor); + UnloadSound(m_sndPickup); + UnloadSound(m_sndEnemyHit); + + CloseAudioDevice(); + m_initialized = false; +} + +Music SoundManager::loadMusicWithCandidates(const std::vector& candidates) { + for (const auto& path : candidates) { + if (FileExists(path.c_str())) { + Music m = LoadMusicStream(path.c_str()); + if (IsMusicValid(m)) { + m.looping = true; + return m; + } + } + } + return { 0 }; +} + +Sound SoundManager::loadSoundWithCandidates(const std::vector& candidates, Sound fallback) { + for (const auto& path : candidates) { + if (FileExists(path.c_str())) { + Sound snd = LoadSound(path.c_str()); + if (snd.frameCount > 0) { + UnloadSound(fallback); + return snd; + } + } + } + return fallback; +} + +Sound SoundManager::createToneSound(float freqStart, float freqEnd, float duration, float volume, bool noise) { + const unsigned int sampleRate = 44100; + const unsigned int totalFrames = static_cast(duration * sampleRate); + + std::vector buffer(totalFrames); + float phase = 0.0f; + const float twoPi = 6.2831853f; + + for (unsigned int i = 0; i < totalFrames; ++i) { + float t = static_cast(i) / static_cast(totalFrames); + float currentFreq = freqStart + (freqEnd - freqStart) * t; + + phase += (twoPi * currentFreq) / static_cast(sampleRate); + if (phase > twoPi) phase -= twoPi; + + float sample = std::sin(phase); + + if (noise) { + float n = (static_cast(std::rand()) / RAND_MAX) * 2.0f - 1.0f; + sample = sample * 0.4f + n * 0.6f; + } + + // Exponential decay envelope + float envelope = std::exp(-3.5f * t) * (1.0f - t * 0.2f); + float finalVal = sample * envelope * volume; + + if (finalVal > 1.0f) finalVal = 1.0f; + if (finalVal < -1.0f) finalVal = -1.0f; + + buffer[i] = static_cast(finalVal * 32767.0f); + } + + Wave wave = { 0 }; + wave.frameCount = totalFrames; + wave.sampleRate = sampleRate; + wave.sampleSize = 16; + wave.channels = 1; + wave.data = malloc(totalFrames * sizeof(short)); + std::memcpy(wave.data, buffer.data(), totalFrames * sizeof(short)); + + Sound snd = LoadSoundFromWave(wave); + UnloadWave(wave); + return snd; +} + +void SoundManager::setMasterVolume(float vol) { + m_volMaster = std::clamp(vol, 0.0f, 1.0f); + saveSettings(); +} + +void SoundManager::setMusicVolume(float vol) { + m_volMusic = std::clamp(vol, 0.0f, 1.0f); + saveSettings(); +} + +void SoundManager::setSfxVolume(float vol) { + m_volSfx = std::clamp(vol, 0.0f, 1.0f); + saveSettings(); +} + +void SoundManager::saveSettings(const std::string& path) { + std::ofstream out(path); + if (!out.is_open()) return; + out << "DOORNROLL_SETTINGS_V1\n"; + out << "master " << m_volMaster << "\n"; + out << "music " << m_volMusic << "\n"; + out << "sfx " << m_volSfx << "\n"; +} + +void SoundManager::loadSettings(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) { + m_volMaster = 1.0f; + m_volMusic = 0.7f; + m_volSfx = 0.9f; + return; + } + + std::string magic; + in >> magic; + if (magic != "DOORNROLL_SETTINGS_V1") return; + + std::string key; + while (in >> key) { + if (key == "master") in >> m_volMaster; + else if (key == "music") in >> m_volMusic; + else if (key == "sfx") in >> m_volSfx; + } + + m_volMaster = std::clamp(m_volMaster, 0.0f, 1.0f); + m_volMusic = std::clamp(m_volMusic, 0.0f, 1.0f); + m_volSfx = std::clamp(m_volSfx, 0.0f, 1.0f); +} + +Music* SoundManager::getMusicHandle(MusicTrack track) { + switch (track) { + case MusicTrack::MENU_HUB: return &m_musMenuHub; + case MusicTrack::REST_ROOM: return &m_musRestRoom; + case MusicTrack::EXPLORATION: return &m_musExploration; + case MusicTrack::COMBAT: return &m_musCombat; + default: return nullptr; + } +} + +void SoundManager::playTrack(MusicTrack track, bool resume) { + if (!m_initialized) return; + if (track == m_currentTrack && !m_isCrossfading) return; + if (track == m_targetTrack && m_isCrossfading) return; + + Music* newMus = getMusicHandle(track); + if (!newMus || !IsMusicValid(*newMus)) { + Music* oldMus = getMusicHandle(m_currentTrack); + if (oldMus && IsMusicValid(*oldMus)) StopMusicStream(*oldMus); + m_currentTrack = track; + m_targetTrack = track; + m_isCrossfading = false; + return; + } + + if (m_currentTrack == MusicTrack::NONE) { + m_currentTrack = track; + m_targetTrack = track; + m_isCrossfading = false; + if (resume && IsMusicStreamPlaying(*newMus)) { + ResumeMusicStream(*newMus); + } else { + PlayMusicStream(*newMus); + } + SetMusicVolume(*newMus, m_volMaster * m_volMusic * m_currentDuck); + return; + } + + // Begin Crossfade + m_targetTrack = track; + m_fadeTimer = m_fadeDuration; + m_isCrossfading = true; + m_resumeOnCrossfade = resume; + + if (resume && IsMusicStreamPlaying(*newMus)) { + ResumeMusicStream(*newMus); + } else { + PlayMusicStream(*newMus); + } + SetMusicVolume(*newMus, 0.0f); +} + +void SoundManager::stopMusic() { + Music* curr = getMusicHandle(m_currentTrack); + if (curr && IsMusicValid(*curr)) StopMusicStream(*curr); + Music* targ = getMusicHandle(m_targetTrack); + if (targ && IsMusicValid(*targ) && targ != curr) StopMusicStream(*targ); + m_currentTrack = MusicTrack::NONE; + m_targetTrack = MusicTrack::NONE; + m_isCrossfading = false; +} + +void SoundManager::updateMusic(float dt) { + if (!m_initialized) return; + + // Smooth ducking interpolation (target 0.25f when inventory open, 1.0f normally) + float targetDuck = m_inventoryDucked ? 0.25f : 1.0f; + m_currentDuck += (targetDuck - m_currentDuck) * std::min(dt * 5.0f, 1.0f); + + float effectiveBaseVol = m_volMaster * m_volMusic * m_currentDuck; + + Music* currMus = getMusicHandle(m_currentTrack); + Music* targMus = getMusicHandle(m_targetTrack); + + if (currMus && IsMusicValid(*currMus) && IsMusicStreamPlaying(*currMus)) { + UpdateMusicStream(*currMus); + } + if (targMus && targMus != currMus && IsMusicValid(*targMus) && IsMusicStreamPlaying(*targMus)) { + UpdateMusicStream(*targMus); + } + + if (m_isCrossfading) { + m_fadeTimer -= dt; + float progress = 1.0f - std::clamp(m_fadeTimer / m_fadeDuration, 0.0f, 1.0f); + + if (currMus && IsMusicValid(*currMus)) { + SetMusicVolume(*currMus, effectiveBaseVol * (1.0f - progress)); + } + if (targMus && IsMusicValid(*targMus)) { + SetMusicVolume(*targMus, effectiveBaseVol * progress); + } + + if (m_fadeTimer <= 0.0f) { + // Finished crossfade + if (currMus && IsMusicValid(*currMus)) { + if (m_currentTrack == MusicTrack::EXPLORATION && m_targetTrack == MusicTrack::COMBAT) { + PauseMusicStream(*currMus); + } else { + StopMusicStream(*currMus); + } + } + m_previousTrack = m_currentTrack; + m_currentTrack = m_targetTrack; + m_isCrossfading = false; + + if (targMus && IsMusicValid(*targMus)) { + SetMusicVolume(*targMus, effectiveBaseVol); + } + } + } else { + if (currMus && IsMusicValid(*currMus)) { + SetMusicVolume(*currMus, effectiveBaseVol); + } + } +} + +void SoundManager::setInventoryDucked(bool ducked) { + m_inventoryDucked = ducked; +} + +void SoundManager::playSfx(Sound snd, float baseVolume) { + if (!m_initialized) return; + SetSoundVolume(snd, std::clamp(baseVolume * m_volMaster * m_volSfx, 0.0f, 1.0f)); + PlaySound(snd); +} + +void SoundManager::playGunshot() { playSfx(m_sndShot, 1.0f); } +void SoundManager::playTripleShot() { playSfx(m_sndTripleShot, 1.0f); } +void SoundManager::playRespin() { playSfx(m_sndRespin, 0.85f); } +void SoundManager::playEmptyClick() { playSfx(m_sndEmpty, 0.9f); } +void SoundManager::playLoadShell() { playSfx(m_sndLoadShell, 0.95f); } +void SoundManager::playTumblerClick() { playSfx(m_sndTumbler, 0.7f); } +void SoundManager::playSkillCheckTick() { playSfx(m_sndTick, 0.6f); } +void SoundManager::playSkillSuccess() { playSfx(m_sndSkillSuccess, 0.8f); } +void SoundManager::playSkillGreat() { playSfx(m_sndSkillGreat, 0.9f); } +void SoundManager::playAlarm() { playSfx(m_sndAlarm, 1.0f); } +void SoundManager::playDamage() { playSfx(m_sndDamage, 0.9f); } +void SoundManager::playDoorOpen() { playSfx(m_sndDoor, 0.75f); } +void SoundManager::playPickup() { playSfx(m_sndPickup, 0.75f); } +void SoundManager::playEnemyHit() { playSfx(m_sndEnemyHit, 0.85f); } + +} // namespace dnr diff --git a/src/core/SoundManager.hpp b/src/core/SoundManager.hpp new file mode 100644 index 0000000..6930a71 --- /dev/null +++ b/src/core/SoundManager.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include "raylib.h" +#include +#include + +namespace dnr { + +enum class MusicTrack { + NONE, + MENU_HUB, // Tame Impala - Breathe Deeper (Main Menu & Reality Apartment) + REST_ROOM, // Spiral Drive - Space Train (Safe Haven Rest Node between floors) + EXPLORATION, // i9incher - Zoom Noir (Cyberspace dungeon exploration & hacking) + COMBAT // Kaito Shoma - Rush (Combat battles) +}; + +class SoundManager { +public: + static SoundManager& get() { + static SoundManager instance; + return instance; + } + + void init(); + void cleanup(); + + // Volume & Settings + float getMasterVolume() const { return m_volMaster; } + float getMusicVolume() const { return m_volMusic; } + float getSfxVolume() const { return m_volSfx; } + + void setMasterVolume(float vol); + void setMusicVolume(float vol); + void setSfxVolume(float vol); + + void saveSettings(const std::string& path = "settings.cfg"); + void loadSettings(const std::string& path = "settings.cfg"); + + // Dynamic Music Streaming + void playTrack(MusicTrack track, bool resume = false); + void stopMusic(); + void updateMusic(float dt); + void setInventoryDucked(bool ducked); + MusicTrack getCurrentTrack() const { return m_currentTrack; } + + // Sound Effects + void playGunshot(); + void playTripleShot(); + void playRespin(); + void playEmptyClick(); + void playLoadShell(); + void playTumblerClick(); + void playSkillCheckTick(); + void playSkillSuccess(); + void playSkillGreat(); + void playAlarm(); + void playDamage(); + void playDoorOpen(); + void playPickup(); + void playEnemyHit(); + +private: + SoundManager() = default; + ~SoundManager() = default; + + void playSfx(Sound snd, float baseVolume = 1.0f); + Music* getMusicHandle(MusicTrack track); + + bool m_initialized = false; + + // Volume levels (0.0f - 1.0f) + float m_volMaster = 1.0f; + float m_volMusic = 0.7f; + float m_volSfx = 0.9f; + + // Music streaming state + MusicTrack m_currentTrack = MusicTrack::NONE; + MusicTrack m_previousTrack = MusicTrack::NONE; + MusicTrack m_targetTrack = MusicTrack::NONE; + + float m_fadeTimer = 0.0f; + float m_fadeDuration = 0.8f; + bool m_isCrossfading = false; + bool m_resumeOnCrossfade = false; + + bool m_inventoryDucked = false; + float m_currentDuck = 1.0f; + + Music m_musMenuHub{}; + Music m_musRestRoom{}; + Music m_musExploration{}; + Music m_musCombat{}; + + // Sound effects + Sound m_sndShot{}; + Sound m_sndTripleShot{}; + Sound m_sndRespin{}; + Sound m_sndEmpty{}; + Sound m_sndLoadShell{}; + Sound m_sndTumbler{}; + Sound m_sndTick{}; + Sound m_sndSkillSuccess{}; + Sound m_sndSkillGreat{}; + Sound m_sndAlarm{}; + Sound m_sndDamage{}; + Sound m_sndDoor{}; + Sound m_sndPickup{}; + Sound m_sndEnemyHit{}; + + Music loadMusicWithCandidates(const std::vector& candidates); + Sound loadSoundWithCandidates(const std::vector& candidates, Sound fallback); + Sound createToneSound(float freqStart, float freqEnd, float duration, float volume, bool noise = false); +}; + +} // namespace dnr diff --git a/src/dungeon/Dungeon.cpp b/src/dungeon/Dungeon.cpp new file mode 100644 index 0000000..316ef1c --- /dev/null +++ b/src/dungeon/Dungeon.cpp @@ -0,0 +1,120 @@ +#include "Dungeon.hpp" +#include + +namespace dnr { + +Dungeon::Dungeon(int width, int height) : m_width(width), m_height(height) { + clear(); +} + +void Dungeon::clear() { + m_tiles.assign(m_width * m_height, TileType::WALL); + m_explored.assign(m_width * m_height, false); + m_doors.clear(); + m_loots.clear(); + m_enemies.clear(); +} + +void Dungeon::resize(int width, int height) { + m_width = width; + m_height = height; + clear(); +} + +TileType Dungeon::getTile(int x, int y) const { + if (x < 0 || x >= m_width || y < 0 || y >= m_height) return TileType::WALL; + return m_tiles[y * m_width + x]; +} + +void Dungeon::setTile(int x, int y, TileType type) { + if (x >= 0 && x < m_width && y >= 0 && y < m_height) { + m_tiles[y * m_width + x] = type; + } +} + +bool Dungeon::isWalkable(int x, int y) const { + TileType t = getTile(x, y); + return t == TileType::FLOOR || t == TileType::DOOR_UNLOCKED || t == TileType::LOOT_CACHE || t == TileType::EXIT_PORTAL; +} + +bool Dungeon::isDoor(int x, int y) const { + TileType t = getTile(x, y); + return t == TileType::DOOR_LOCKED || t == TileType::DOOR_UNLOCKED; +} + +bool Dungeon::isDoorLocked(int x, int y) const { + return getTile(x, y) == TileType::DOOR_LOCKED; +} + +DoorData* Dungeon::getDoorAt(int x, int y) { + for (auto& d : m_doors) { + if (d.x == x && d.y == y) return &d; + } + return nullptr; +} + +LootData* Dungeon::getLootAt(int x, int y) { + for (auto& l : m_loots) { + if (l.x == x && l.y == y && !l.looted) return &l; + } + return nullptr; +} + +MapEnemy* Dungeon::getEnemyAt(int x, int y) { + for (auto& e : m_enemies) { + if (e.x == x && e.y == y && e.alive) return &e; + } + return nullptr; +} + +void Dungeon::addDoor(int x, int y, int secLevel) { + m_doors.push_back({ x, y, secLevel, false }); + setTile(x, y, TileType::DOOR_LOCKED); +} + +void Dungeon::addLoot(int x, int y, const std::vector& ammo, int picks) { + m_loots.push_back({ x, y, ammo, picks, false }); + setTile(x, y, TileType::LOOT_CACHE); +} + +void Dungeon::addEnemy(int x, int y, EnemyType type, int level) { + m_enemies.push_back({ x, y, Enemy(type, level), true }); +} + +void Dungeon::removeEnemyAt(int x, int y) { + for (auto& e : m_enemies) { + if (e.x == x && e.y == y) { + e.alive = false; + break; + } + } +} + +void Dungeon::unlockDoorAt(int x, int y) { + DoorData* d = getDoorAt(x, y); + if (d) { + d->unlocked = true; + setTile(x, y, TileType::DOOR_UNLOCKED); + } +} + +bool Dungeon::isExplored(int x, int y) const { + if (x < 0 || x >= m_width || y < 0 || y >= m_height) return false; + return m_explored[y * m_width + x]; +} + +void Dungeon::reveal(int centerX, int centerY, int radius) { + for (int dy = -radius; dy <= radius; ++dy) { + for (int dx = -radius; dx <= radius; ++dx) { + int nx = centerX + dx; + int ny = centerY + dy; + if (nx >= 0 && nx < m_width && ny >= 0 && ny < m_height) { + if (dx * dx + dy * dy <= radius * radius + 1) { + m_explored[ny * m_width + nx] = true; + } + } + } + } +} + +} // namespace dnr diff --git a/src/dungeon/Dungeon.hpp b/src/dungeon/Dungeon.hpp new file mode 100644 index 0000000..9087af5 --- /dev/null +++ b/src/dungeon/Dungeon.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "core/Common.hpp" +#include "combat/Enemy.hpp" +#include +#include + +namespace dnr { + +struct DoorData { + int x; + int y; + int securityLevel; // 1 to 3: controls number of pins and skill check speed + bool unlocked; +}; + +struct LootData { + int x; + int y; + std::vector ammoRewards; + int picksReward; + bool looted; +}; + +struct MapEnemy { + int x; + int y; + Enemy enemy; + bool alive = true; +}; + +class Dungeon { +public: + Dungeon(int width = 21, int height = 21); + + void clear(); + void resize(int width, int height); + + int getWidth() const { return m_width; } + int getHeight() const { return m_height; } + int getFloor() const { return m_floor; } + void setFloor(int floor) { m_floor = floor; } + + TileType getTile(int x, int y) const; + void setTile(int x, int y, TileType type); + + bool isWalkable(int x, int y) const; + bool isDoor(int x, int y) const; + bool isDoorLocked(int x, int y) const; + + DoorData* getDoorAt(int x, int y); + LootData* getLootAt(int x, int y); + MapEnemy* getEnemyAt(int x, int y); + + void addDoor(int x, int y, int secLevel); + void addLoot(int x, int y, const std::vector& ammo, int picks); + void addEnemy(int x, int y, EnemyType type, int level); + + void removeEnemyAt(int x, int y); + void unlockDoorAt(int x, int y); + + bool isExplored(int x, int y) const; + void reveal(int centerX, int centerY, int radius = 3); + + const std::vector& getDoors() const { return m_doors; } + const std::vector& getLoots() const { return m_loots; } + std::vector& getEnemies() { return m_enemies; } + +private: + int m_width; + int m_height; + int m_floor = 1; + std::vector m_tiles; + std::vector m_explored; + + std::vector m_doors; + std::vector m_loots; + std::vector m_enemies; +}; + +} // namespace dnr diff --git a/src/dungeon/DungeonGen.cpp b/src/dungeon/DungeonGen.cpp new file mode 100644 index 0000000..b359f29 --- /dev/null +++ b/src/dungeon/DungeonGen.cpp @@ -0,0 +1,239 @@ +#include "DungeonGen.hpp" +#include +#include +#include +#include +#include + +namespace dnr { + +struct Room { + int x; + int y; + int w; + int h; + + int centerX() const { return x + w / 2; } + int centerY() const { return y + h / 2; } + + bool intersects(const Room& o, int padding = 2) const { + return (x - padding <= o.x + o.w) && (x + w + padding >= o.x) && + (y - padding <= o.y + o.h) && (y + h + padding >= o.y); + } +}; + +DungeonGenResult DungeonGen::generate(Dungeon& dungeon, int floor) { + const int W = 27; + const int H = 27; + dungeon.resize(W, H); + dungeon.setFloor(floor); + + std::vector rooms; + const int roomAttempts = 40; + + for (int i = 0; i < roomAttempts; ++i) { + int rw = 3 + (std::rand() % 3) * 2; // 3, 5, 7 + int rh = 3 + (std::rand() % 3) * 2; + int rx = 1 + (std::rand() % (W - rw - 2)); + int ry = 1 + (std::rand() % (H - rh - 2)); + + Room newRoom{ rx, ry, rw, rh }; + bool collides = false; + for (const auto& r : rooms) { + if (newRoom.intersects(r, 2)) { + collides = true; + break; + } + } + + if (!collides) { + rooms.push_back(newRoom); + for (int y = ry; y < ry + rh; ++y) { + for (int x = rx; x < rx + rw; ++x) { + dungeon.setTile(x, y, TileType::FLOOR); + } + } + } + + if (rooms.size() >= 8) break; + } + + if (rooms.empty()) { + rooms.push_back({ 2, 2, 5, 5 }); + for (int y = 2; y < 7; ++y) { + for (int x = 2; x < 7; ++x) { + dungeon.setTile(x, y, TileType::FLOOR); + } + } + } + + // Connect rooms with corridors + for (size_t i = 1; i < rooms.size(); ++i) { + int x1 = rooms[i - 1].centerX(); + int y1 = rooms[i - 1].centerY(); + int x2 = rooms[i].centerX(); + int y2 = rooms[i].centerY(); + + if (std::rand() % 2 == 0) { + int startX = std::min(x1, x2); + int endX = std::max(x1, x2); + for (int x = startX; x <= endX; ++x) { + dungeon.setTile(x, y1, TileType::FLOOR); + } + int startY = std::min(y1, y2); + int endY = std::max(y1, y2); + for (int y = startY; y <= endY; ++y) { + dungeon.setTile(x2, y, TileType::FLOOR); + } + } else { + int startY = std::min(y1, y2); + int endY = std::max(y1, y2); + for (int y = startY; y <= endY; ++y) { + dungeon.setTile(x1, y, TileType::FLOOR); + } + int startX = std::min(x1, x2); + int endX = std::max(x1, x2); + for (int x = startX; x <= endX; ++x) { + dungeon.setTile(x, y2, TileType::FLOOR); + } + } + } + + // Start in room 0 + DungeonGenResult res; + res.startX = rooms[0].centerX(); + res.startY = rooms[0].centerY(); + + // STRICT BOTTLENECK CHOKE POINT DETECTION FOR DOORS: + // A door can ONLY be placed where it is flanked by solid walls on both sides! + std::vector> bottleneckDoors; + for (int y = 2; y < H - 2; ++y) { + for (int x = 2; x < W - 2; ++x) { + if (dungeon.getTile(x, y) != TileType::FLOOR) continue; + if (x == res.startX && y == res.startY) continue; + + bool isHorizontalBottleneck = + (dungeon.getTile(x, y - 1) == TileType::WALL) && + (dungeon.getTile(x, y + 1) == TileType::WALL) && + (dungeon.getTile(x - 1, y) == TileType::FLOOR) && + (dungeon.getTile(x + 1, y) == TileType::FLOOR); + + bool isVerticalBottleneck = + (dungeon.getTile(x - 1, y) == TileType::WALL) && + (dungeon.getTile(x + 1, y) == TileType::WALL) && + (dungeon.getTile(x, y - 1) == TileType::FLOOR) && + (dungeon.getTile(x, y + 1) == TileType::FLOOR); + + if (isHorizontalBottleneck || isVerticalBottleneck) { + // Ensure no existing door within 2 tiles + bool tooClose = false; + for (const auto& d : bottleneckDoors) { + if (std::abs(d.first - x) + std::abs(d.second - y) <= 2) { + tooClose = true; + break; + } + } + if (!tooClose) { + bottleneckDoors.push_back({ x, y }); + } + } + } + } + + // Place 3 to 5 doors at bottleneck locations + int doorsToPlace = std::min(static_cast(bottleneckDoors.size()), 4 + (floor / 2)); + for (int i = 0; i < doorsToPlace; ++i) { + int idx = std::rand() % bottleneckDoors.size(); + auto pt = bottleneckDoors[idx]; + bottleneckDoors.erase(bottleneckDoors.begin() + idx); + + int secLevel = 1 + (floor - 1) / 2 + (std::rand() % 2); + if (secLevel > 3) secLevel = 3; + dungeon.addDoor(pt.first, pt.second, secLevel); + } + + // BFS DISTANCE SEARCH FOR MAXIMUM DISTANCE EXIT PORTAL + std::vector dist(W * H, -1); + std::queue> q; + + dist[res.startY * W + res.startX] = 0; + q.push({ res.startX, res.startY }); + + int maxDist = 0; + int bestExitX = res.startX; + int bestExitY = res.startY; + + int dx[] = { 1, -1, 0, 0 }; + int dy[] = { 0, 0, 1, -1 }; + + while (!q.empty()) { + auto [cx, cy] = q.front(); + q.pop(); + + int curD = dist[cy * W + cx]; + if (curD > maxDist) { + maxDist = curD; + bestExitX = cx; + bestExitY = cy; + } + + for (int dir = 0; dir < 4; ++dir) { + int nx = cx + dx[dir]; + int ny = cy + dy[dir]; + + if (nx >= 0 && nx < W && ny >= 0 && ny < H) { + TileType t = dungeon.getTile(nx, ny); + if (t != TileType::WALL) { // Walkable or door + if (dist[ny * W + nx] == -1) { + dist[ny * W + nx] = curD + 1; + q.push({ nx, ny }); + } + } + } + } + } + + res.exitX = bestExitX; + res.exitY = bestExitY; + dungeon.setTile(res.exitX, res.exitY, TileType::EXIT_PORTAL); + + // Place Loot caches and Enemies in rooms away from start + for (size_t i = 1; i < rooms.size(); ++i) { + const auto& r = rooms[i]; + int cx = r.centerX(); + int cy = r.centerY(); + + // Place loot cache + int lx = r.x + 1 + (std::rand() % std::max(1, r.w - 2)); + int ly = r.y + 1 + (std::rand() % std::max(1, r.h - 2)); + if (dungeon.getTile(lx, ly) == TileType::FLOOR && (lx != res.exitX || ly != res.exitY)) { + std::vector rewards; + int ammoCount = 2 + (std::rand() % 3); + for (int a = 0; a < ammoCount; ++a) { + int roll = std::rand() % 6; + switch (roll) { + case 0: rewards.push_back(AmmoType::KINETIC); break; + case 1: rewards.push_back(AmmoType::EMP); break; + case 2: rewards.push_back(AmmoType::OVERHEAT); break; + case 3: rewards.push_back(AmmoType::CORROSIVE); break; + case 4: rewards.push_back(AmmoType::GLITCH); break; + case 5: rewards.push_back(AmmoType::NANITE); break; + } + } + int picks = (std::rand() % 100 < 60) ? 2 : 1; + dungeon.addLoot(lx, ly, rewards, picks); + } + + // Place enemy + EnemyType eType = static_cast(std::rand() % 4); + int ex = cx; + int ey = cy; + if (dungeon.getTile(ex, ey) == TileType::FLOOR && (ex != res.exitX || ey != res.exitY) && (ex != res.startX || ey != res.startY)) { + dungeon.addEnemy(ex, ey, eType, floor); + } + } + + return res; +} + +} // namespace dnr diff --git a/src/dungeon/DungeonGen.hpp b/src/dungeon/DungeonGen.hpp new file mode 100644 index 0000000..22b884b --- /dev/null +++ b/src/dungeon/DungeonGen.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "Dungeon.hpp" + +namespace dnr { + +struct DungeonGenResult { + int startX = 1; + int startY = 1; + int exitX = 1; + int exitY = 1; +}; + +class DungeonGen { +public: + static DungeonGenResult generate(Dungeon& dungeon, int floor = 1); +}; + +} // namespace dnr diff --git a/src/dungeon/Player.cpp b/src/dungeon/Player.cpp new file mode 100644 index 0000000..03cc6b8 --- /dev/null +++ b/src/dungeon/Player.cpp @@ -0,0 +1,195 @@ +#include "Player.hpp" +#include +#include + +namespace dnr { + +Player::Player() { + reset(1, 1, Direction::NORTH); +} + +void Player::reset(int startX, int startY, Direction startDir) { + m_gridX = startX; + m_gridY = startY; + m_facing = startDir; + + m_visualPos = { static_cast(m_gridX) * 2.0f, 0.8f, static_cast(m_gridY) * 2.0f }; + m_targetYaw = getDirectionAngle(m_facing); + m_visualYaw = m_targetYaw; + + m_hp = 100; + m_maxHp = 100; + m_shield = 40; + m_maxShield = 40; + m_cyberPicks = 5; + + m_revolver.reset(); + m_statuses.clear(); + + // Reset ammo pouch counts with a starter kit + m_ammoCounts.fill(0); + m_ammoCounts[static_cast(AmmoType::KINETIC)] = 6; + m_ammoCounts[static_cast(AmmoType::EMP)] = 3; + m_ammoCounts[static_cast(AmmoType::OVERHEAT)] = 3; + m_ammoCounts[static_cast(AmmoType::CORROSIVE)] = 2; + m_ammoCounts[static_cast(AmmoType::GLITCH)] = 2; + m_ammoCounts[static_cast(AmmoType::NANITE)] = 2; +} + +void Player::setStats(int hp, int maxHp, int shield, int maxShield, int picks) { + m_hp = hp; + m_maxHp = maxHp; + m_shield = shield; + m_maxShield = maxShield; + m_cyberPicks = picks; +} + +void Player::setGridPosition(int x, int y) { + m_gridX = x; + m_gridY = y; + Vector3 targetPos = { static_cast(m_gridX) * 2.0f, 0.8f, static_cast(m_gridY) * 2.0f }; + m_visualPos = targetPos; +} + +void Player::setFacing(Direction dir) { + m_facing = dir; + m_targetYaw = getDirectionAngle(m_facing); + m_visualYaw = m_targetYaw; +} + +void Player::turnLeft() { + int dirInt = (static_cast(m_facing) + 3) % 4; + m_facing = static_cast(dirInt); + m_targetYaw -= 90.0f; +} + +void Player::turnRight() { + int dirInt = (static_cast(m_facing) + 1) % 4; + m_facing = static_cast(dirInt); + m_targetYaw += 90.0f; +} + +void Player::update(float dt) { + // Smooth translation + Vector3 targetPos = { static_cast(m_gridX) * 2.0f, 0.8f, static_cast(m_gridY) * 2.0f }; + m_visualPos.x += (targetPos.x - m_visualPos.x) * 12.0f * dt; + m_visualPos.y += (targetPos.y - m_visualPos.y) * 12.0f * dt; + m_visualPos.z += (targetPos.z - m_visualPos.z) * 12.0f * dt; + + // Smooth yaw rotation + m_visualYaw += (m_targetYaw - m_visualYaw) * 14.0f * dt; + + m_revolver.update(dt); +} + +void Player::heal(int amount) { + m_hp = std::min(m_maxHp, m_hp + amount); +} + +void Player::restoreShield(int amount) { + m_shield = std::min(m_maxShield, m_shield + amount); +} + +bool Player::usePick() { + if (m_cyberPicks > 0) { + m_cyberPicks--; + return true; + } + return false; +} + +int Player::takeDamage(int amount, AmmoType element) { + float mult = 1.0f; + + for (const auto& st : m_statuses) { + if (st.effect == StatusEffect::CORRODED) { + mult *= 1.4f; + break; + } + } + + if (element == AmmoType::EMP && m_shield > 0) { + mult *= 1.8f; + } + + int finalDmg = static_cast(amount * mult); + if (finalDmg < 1 && amount > 0) finalDmg = 1; + + if (m_shield > 0) { + if (finalDmg <= m_shield) { + m_shield -= finalDmg; + return finalDmg; + } else { + int rem = finalDmg - m_shield; + m_shield = 0; + m_hp -= rem; + } + } else { + m_hp -= finalDmg; + } + + if (m_hp < 0) m_hp = 0; + return finalDmg; +} + +void Player::applyStatus(StatusEffect effect, int duration, int power) { + for (auto& st : m_statuses) { + if (st.effect == effect) { + st.durationTurns = std::max(st.durationTurns, duration); + st.power = std::max(st.power, power); + return; + } + } + m_statuses.push_back({ effect, duration, power }); +} + +void Player::updateTurnStart(std::vector& combatLog) { + for (auto it = m_statuses.begin(); it != m_statuses.end(); ) { + if (it->effect == StatusEffect::BURNING) { + int burn = it->power; + m_hp = std::max(0, m_hp - burn); + combatLog.push_back("PLAYER suffered " + std::to_string(burn) + " OVERHEAT thermal damage!"); + } + it->durationTurns--; + if (it->durationTurns <= 0) { + it = m_statuses.erase(it); + } else { + ++it; + } + } +} + +int Player::getAmmoCount(AmmoType type) const { + int idx = static_cast(type); + if (idx >= 0 && idx < static_cast(m_ammoCounts.size())) { + return m_ammoCounts[idx]; + } + return 0; +} + +void Player::addAmmo(AmmoType type, int count) { + int idx = static_cast(type); + if (idx >= 0 && idx < static_cast(m_ammoCounts.size()) && type != AmmoType::EMPTY) { + m_ammoCounts[idx] += count; + } +} + +bool Player::consumeAmmo(AmmoType type) { + int idx = static_cast(type); + if (idx >= 0 && idx < static_cast(m_ammoCounts.size())) { + if (m_ammoCounts[idx] > 0) { + m_ammoCounts[idx]--; + return true; + } + } + return false; +} + +void Player::setAmmoCount(AmmoType type, int count) { + int idx = static_cast(type); + if (idx >= 0 && idx < static_cast(m_ammoCounts.size())) { + m_ammoCounts[idx] = count; + } +} + +} // namespace dnr diff --git a/src/dungeon/Player.hpp b/src/dungeon/Player.hpp new file mode 100644 index 0000000..eae8cee --- /dev/null +++ b/src/dungeon/Player.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "core/Common.hpp" +#include "combat/Revolver.hpp" +#include +#include + +namespace dnr { + +class Player { +public: + Player(); + + void reset(int startX, int startY, Direction startDir = Direction::NORTH); + void update(float dt); + + // Position and orientation + int getGridX() const { return m_gridX; } + int getGridY() const { return m_gridY; } + Direction getFacing() const { return m_facing; } + + Vector3 getVisualPosition() const { return m_visualPos; } + float getVisualYaw() const { return m_visualYaw; } + + void setGridPosition(int x, int y); + void setFacing(Direction dir); + + void turnLeft(); + void turnRight(); + + // Stats + int getHp() const { return m_hp; } + int getMaxHp() const { return m_maxHp; } + int getShield() const { return m_shield; } + int getMaxShield() const { return m_maxShield; } + int getCyberPicks() const { return m_cyberPicks; } + + void setStats(int hp, int maxHp, int shield, int maxShield, int picks); + void heal(int amount); + void restoreShield(int amount); + void addPicks(int amount) { m_cyberPicks += amount; } + bool usePick(); + + int takeDamage(int amount, AmmoType element); + void applyStatus(StatusEffect effect, int duration, int power); + void updateTurnStart(std::vector& combatLog); + bool isAlive() const { return m_hp > 0; } + + // Weapon & Ammo + Revolver& getRevolver() { return m_revolver; } + const Revolver& getRevolver() const { return m_revolver; } + + // Stacked Ammo Inventory + int getAmmoCount(AmmoType type) const; + void addAmmo(AmmoType type, int count = 1); + bool consumeAmmo(AmmoType type); + void setAmmoCount(AmmoType type, int count); + const std::array& getAmmoCounts() const { return m_ammoCounts; } + + // Compatibility wrappers + void addAmmoToPouch(AmmoType type) { addAmmo(type, 1); } + bool removeAmmoFromPouch(AmmoType type) { return consumeAmmo(type); } + + const std::vector& getStatuses() const { return m_statuses; } + +private: + int m_gridX = 1; + int m_gridY = 1; + Direction m_facing = Direction::NORTH; + + Vector3 m_visualPos{ 1.0f, 0.5f, 1.0f }; + float m_visualYaw = 0.0f; + float m_targetYaw = 0.0f; + + int m_hp = 100; + int m_maxHp = 100; + int m_shield = 40; + int m_maxShield = 40; + int m_cyberPicks = 5; + + std::array m_ammoCounts{ 0 }; + Revolver m_revolver; + std::vector m_statuses; +}; + +} // namespace dnr diff --git a/src/hacking/DbdSkillCheck.cpp b/src/hacking/DbdSkillCheck.cpp new file mode 100644 index 0000000..3840791 --- /dev/null +++ b/src/hacking/DbdSkillCheck.cpp @@ -0,0 +1,68 @@ +#include "DbdSkillCheck.hpp" +#include "core/SoundManager.hpp" +#include +#include + +namespace dnr { + +DbdSkillCheck::DbdSkillCheck() { + init(1.0f); +} + +void DbdSkillCheck::init(float speedMultiplier) { + m_needleAngle = 0.0f; + m_speed = (180.0f + static_cast(std::rand() % 40)) * speedMultiplier; + + // Randomize success zone between 120 and 260 degrees + m_successStart = 120.0f + static_cast(std::rand() % 100); + m_successSpan = std::max(25.0f, 48.0f - (speedMultiplier - 1.0f) * 12.0f); + + // Great success is at the end or center of the success span + m_greatSpan = std::max(10.0f, m_successSpan * 0.32f); + m_greatStart = m_successStart + m_successSpan - m_greatSpan; + + m_warningPlayed = false; + m_timer = 0.0f; + m_result = SkillCheckResult::PENDING; +} + +void DbdSkillCheck::update(float dt) { + if (m_result != SkillCheckResult::PENDING) return; + + m_timer += dt; + + // Play DbD warning tick right before zone or at start + if (!m_warningPlayed && m_needleAngle > (m_successStart - 50.0f)) { + SoundManager::get().playSkillCheckTick(); + m_warningPlayed = true; + } + + m_needleAngle += m_speed * dt; + + // Past 360 degrees or past the zone without input -> FAIL! + if (m_needleAngle > 360.0f || m_needleAngle > (m_successStart + m_successSpan + 25.0f)) { + m_result = SkillCheckResult::FAIL; + SoundManager::get().playAlarm(); + } +} + +SkillCheckResult DbdSkillCheck::strike() { + if (m_result != SkillCheckResult::PENDING) return m_result; + + float angle = m_needleAngle; + + if (angle >= m_greatStart && angle <= (m_greatStart + m_greatSpan)) { + m_result = SkillCheckResult::GREAT_SUCCESS; + SoundManager::get().playSkillGreat(); + } else if (angle >= m_successStart && angle <= (m_successStart + m_successSpan)) { + m_result = SkillCheckResult::SUCCESS; + SoundManager::get().playSkillSuccess(); + } else { + m_result = SkillCheckResult::FAIL; + SoundManager::get().playAlarm(); + } + + return m_result; +} + +} // namespace dnr diff --git a/src/hacking/DbdSkillCheck.hpp b/src/hacking/DbdSkillCheck.hpp new file mode 100644 index 0000000..d78e461 --- /dev/null +++ b/src/hacking/DbdSkillCheck.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "core/Common.hpp" + +namespace dnr { + +enum class SkillCheckResult { + PENDING, + GREAT_SUCCESS, + SUCCESS, + FAIL +}; + +class DbdSkillCheck { +public: + DbdSkillCheck(); + + void init(float speedMultiplier = 1.0f); + void update(float dt); + + SkillCheckResult strike(); // Triggered on Space press + SkillCheckResult getResult() const { return m_result; } + + float getNeedleAngle() const { return m_needleAngle; } + float getSuccessStart() const { return m_successStart; } + float getSuccessEnd() const { return m_successStart + m_successSpan; } + float getGreatStart() const { return m_greatStart; } + float getGreatEnd() const { return m_greatStart + m_greatSpan; } + + bool isActive() const { return m_result == SkillCheckResult::PENDING; } + +private: + float m_needleAngle = 0.0f; // 0 to 360 degrees + float m_speed = 220.0f; // deg/sec + float m_successStart = 160.0f; + float m_successSpan = 50.0f; + float m_greatStart = 195.0f; + float m_greatSpan = 15.0f; + + bool m_warningPlayed = false; + float m_timer = 0.0f; + SkillCheckResult m_result = SkillCheckResult::PENDING; +}; + +} // namespace dnr diff --git a/src/hacking/HackingGame.cpp b/src/hacking/HackingGame.cpp new file mode 100644 index 0000000..575ade1 --- /dev/null +++ b/src/hacking/HackingGame.cpp @@ -0,0 +1,80 @@ +#include "HackingGame.hpp" +#include "core/SoundManager.hpp" + +namespace dnr { + +HackingGame::HackingGame() = default; + +void HackingGame::start(int securityLevel) { + m_securityLevel = securityLevel; + m_phase = HackingPhase::PHASE_1_TUMBLER; + m_resultTimer = 0.0f; + + // Number of pins: level 1 -> 3 pins, level 2 -> 4 pins, level 3 -> 5 pins + int pinCount = 2 + securityLevel; + float difficulty = 0.8f + securityLevel * 0.35f; + m_tumbler.init(pinCount, difficulty); + + float skillSpeed = 0.9f + securityLevel * 0.25f; + m_skillCheck.init(skillSpeed); +} + +void HackingGame::update(float dt) { + if (m_phase == HackingPhase::PHASE_1_TUMBLER) { + m_tumbler.update(dt); + } else if (m_phase == HackingPhase::PHASE_2_SKILLCHECK) { + m_skillCheck.update(dt); + if (m_skillCheck.getResult() == SkillCheckResult::FAIL) { + m_phase = HackingPhase::RESULT_ALARM; + m_resultTimer = 0.0f; + } + } else { + m_resultTimer += dt; + } +} + +void HackingGame::handleInput() { + if (m_phase == HackingPhase::PHASE_1_TUMBLER) { + // Move between pins + if (IsKeyPressed(KEY_LEFT) || IsKeyPressed(KEY_A)) { + m_tumbler.moveLeft(); + } + if (IsKeyPressed(KEY_RIGHT) || IsKeyPressed(KEY_D)) { + m_tumbler.moveRight(); + } + + // Lift/lower pin + if (IsKeyDown(KEY_UP) || IsKeyDown(KEY_W)) { + m_tumbler.adjustHeight(0.75f * GetFrameTime()); + } + if (IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_S)) { + m_tumbler.adjustHeight(-0.75f * GetFrameTime()); + } + + // Try lock in pin + if (IsKeyPressed(KEY_SPACE)) { + m_tumbler.tryLockCurrentPin(); + } + + // If all pins locked and player presses ENTER or confirms: + if (m_tumbler.isAllPinsLocked() && (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_SPACE))) { + m_phase = HackingPhase::PHASE_2_SKILLCHECK; + float skillSpeed = 0.9f + m_securityLevel * 0.25f; + m_skillCheck.init(skillSpeed); + } + } else if (m_phase == HackingPhase::PHASE_2_SKILLCHECK) { + if (IsKeyPressed(KEY_SPACE)) { + auto res = m_skillCheck.strike(); + if (res == SkillCheckResult::GREAT_SUCCESS) { + m_phase = HackingPhase::RESULT_CRITICAL; + } else if (res == SkillCheckResult::SUCCESS) { + m_phase = HackingPhase::RESULT_SUCCESS; + } else { + m_phase = HackingPhase::RESULT_ALARM; + } + m_resultTimer = 0.0f; + } + } +} + +} // namespace dnr diff --git a/src/hacking/HackingGame.hpp b/src/hacking/HackingGame.hpp new file mode 100644 index 0000000..ba90e76 --- /dev/null +++ b/src/hacking/HackingGame.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "core/Common.hpp" +#include "TumblerLock.hpp" +#include "DbdSkillCheck.hpp" + +namespace dnr { + +enum class HackingPhase { + PHASE_1_TUMBLER, + PHASE_2_SKILLCHECK, + RESULT_SUCCESS, + RESULT_CRITICAL, + RESULT_ALARM +}; + +class HackingGame { +public: + HackingGame(); + + void start(int securityLevel); + void update(float dt); + void handleInput(); + + HackingPhase getPhase() const { return m_phase; } + bool isFinished() const { + return m_phase == HackingPhase::RESULT_SUCCESS || + m_phase == HackingPhase::RESULT_CRITICAL || + m_phase == HackingPhase::RESULT_ALARM; + } + + bool isAlarmTriggered() const { return m_phase == HackingPhase::RESULT_ALARM; } + bool isSuccess() const { + return m_phase == HackingPhase::RESULT_SUCCESS || + m_phase == HackingPhase::RESULT_CRITICAL; + } + bool isCritical() const { return m_phase == HackingPhase::RESULT_CRITICAL; } + + const TumblerLock& getTumbler() const { return m_tumbler; } + const DbdSkillCheck& getSkillCheck() const { return m_skillCheck; } + int getSecurityLevel() const { return m_securityLevel; } + float getResultTimer() const { return m_resultTimer; } + +private: + HackingPhase m_phase = HackingPhase::PHASE_1_TUMBLER; + TumblerLock m_tumbler; + DbdSkillCheck m_skillCheck; + int m_securityLevel = 1; + float m_resultTimer = 0.0f; +}; + +} // namespace dnr diff --git a/src/hacking/TumblerLock.cpp b/src/hacking/TumblerLock.cpp new file mode 100644 index 0000000..0050596 --- /dev/null +++ b/src/hacking/TumblerLock.cpp @@ -0,0 +1,90 @@ +#include "TumblerLock.hpp" +#include "core/SoundManager.hpp" +#include +#include + +namespace dnr { + +TumblerLock::TumblerLock() { + init(4, 1.0f); +} + +void TumblerLock::init(int pinCount, float difficulty) { + m_pins.clear(); + m_selectedPin = 0; + m_decayRate = 0.25f + difficulty * 0.15f; + + for (int i = 0; i < pinCount; ++i) { + TumblerPin pin; + pin.currentHeight = 0.1f; + // Target sweet spot between 0.3 and 0.85 + pin.targetHeight = 0.30f + static_cast(std::rand() % 55) / 100.0f; + pin.tolerance = std::max(0.07f, 0.14f - difficulty * 0.02f); + pin.locked = false; + m_pins.push_back(pin); + } +} + +void TumblerLock::update(float dt) { + for (size_t i = 0; i < m_pins.size(); ++i) { + if (!m_pins[i].locked) { + // Pin naturally falls back down if not actively lifted + m_pins[i].currentHeight = std::max(0.0f, m_pins[i].currentHeight - m_decayRate * dt); + } + } +} + +void TumblerLock::moveLeft() { + if (m_selectedPin > 0) { + m_selectedPin--; + SoundManager::get().playTumblerClick(); + } +} + +void TumblerLock::moveRight() { + if (m_selectedPin < static_cast(m_pins.size()) - 1) { + m_selectedPin++; + SoundManager::get().playTumblerClick(); + } +} + +void TumblerLock::adjustHeight(float delta) { + if (m_pins.empty()) return; + auto& pin = m_pins[m_selectedPin]; + if (pin.locked) return; + + pin.currentHeight = std::clamp(pin.currentHeight + delta, 0.0f, 1.0f); +} + +bool TumblerLock::tryLockCurrentPin() { + if (m_pins.empty()) return false; + auto& pin = m_pins[m_selectedPin]; + if (pin.locked) return false; + + if (pin.isInSweetSpot()) { + pin.locked = true; + SoundManager::get().playSkillSuccess(); + // Automatically advance to next unlocked pin if available + for (size_t i = 0; i < m_pins.size(); ++i) { + if (!m_pins[i].locked) { + m_selectedPin = static_cast(i); + break; + } + } + return true; + } else { + // Pin slipped + pin.currentHeight = 0.0f; + SoundManager::get().playDamage(); + return false; + } +} + +bool TumblerLock::isAllPinsLocked() const { + for (const auto& pin : m_pins) { + if (!pin.locked) return false; + } + return true; +} + +} // namespace dnr diff --git a/src/hacking/TumblerLock.hpp b/src/hacking/TumblerLock.hpp new file mode 100644 index 0000000..e6442d2 --- /dev/null +++ b/src/hacking/TumblerLock.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "core/Common.hpp" +#include + +namespace dnr { + +struct TumblerPin { + float currentHeight = 0.0f; // 0.0 to 1.0 + float targetHeight = 0.5f; // sweet spot + float tolerance = 0.12f; + bool locked = false; + + bool isInSweetSpot() const { + return std::abs(currentHeight - targetHeight) <= tolerance; + } +}; + +class TumblerLock { +public: + TumblerLock(); + + void init(int pinCount = 4, float difficulty = 1.0f); + void update(float dt); + + void moveLeft(); + void moveRight(); + void adjustHeight(float delta); + bool tryLockCurrentPin(); + bool isAllPinsLocked() const; + + int getSelectedPin() const { return m_selectedPin; } + int getPinCount() const { return static_cast(m_pins.size()); } + const TumblerPin& getPin(int index) const { return m_pins[index]; } + + float getDecayRate() const { return m_decayRate; } + +private: + std::vector m_pins; + int m_selectedPin = 0; + float m_decayRate = 0.25f; +}; + +} // namespace dnr diff --git a/src/hub/HubRoom.cpp b/src/hub/HubRoom.cpp new file mode 100644 index 0000000..12f4337 --- /dev/null +++ b/src/hub/HubRoom.cpp @@ -0,0 +1,49 @@ +#include "HubRoom.hpp" + +namespace dnr { + +HubRoom::HubRoom() = default; + +void HubRoom::init(Player& player) { + // Spawn player in the cozy room facing North toward the Cyberspace Gateway + player.setGridPosition(3, 4); + player.setFacing(Direction::NORTH); +} + +void HubRoom::update(float dt, Player& player) { + player.update(dt); +} + +bool HubRoom::tryMove(int dx, int dy, Player& player) { + int nx = player.getGridX() + dx; + int ny = player.getGridY() + dy; + + // Room boundaries (walkable tiles nx: 1..5, ny: 1..5) + if (nx < 1 || nx > 5 || ny < 1 || ny > 5) return false; + + // Obstacle: Bed occupies the South-East corner (nx >= 4 && ny == 5) + if (nx >= 4 && ny == 5) return false; + + player.setGridPosition(nx, ny); + return true; +} + +bool HubRoom::isFacingPortal(const Player& player) const { + // Portal is on the North wall, player stands at (3, 1) facing NORTH + int px = player.getGridX(); + int py = player.getGridY(); + Direction f = player.getFacing(); + + return (px == 3 && py == 1 && f == Direction::NORTH); +} + +bool HubRoom::isFacingTerminal(const Player& player) const { + // Desk terminal is along West wall, player stands at nx = 1 (ny = 2 or 3) facing WEST + int px = player.getGridX(); + int py = player.getGridY(); + Direction f = player.getFacing(); + + return (px == 1 && (py == 2 || py == 3) && f == Direction::WEST); +} + +} // namespace dnr diff --git a/src/hub/HubRoom.hpp b/src/hub/HubRoom.hpp new file mode 100644 index 0000000..b38cbf1 --- /dev/null +++ b/src/hub/HubRoom.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Player.hpp" + +namespace dnr { + +class HubRoom { +public: + HubRoom(); + + void init(Player& player); + void update(float dt, Player& player); + + bool tryMove(int dx, int dy, Player& player); + bool isFacingPortal(const Player& player) const; + bool isFacingTerminal(const Player& player) const; + + int getWidth() const { return m_width; } + int getHeight() const { return m_height; } + + Vector3 getPortalPosition() const { return m_portalPos; } + Vector3 getDeskPosition() const { return m_deskPos; } + +private: + int m_width = 7; + int m_height = 7; + + Vector3 m_portalPos{ 6.0f, 1.2f, 2.0f }; // North door + Vector3 m_deskPos{ 2.0f, 0.7f, 3.0f }; // West desk +}; + +} // namespace dnr diff --git a/src/hub/RestRoom.cpp b/src/hub/RestRoom.cpp new file mode 100644 index 0000000..5544577 --- /dev/null +++ b/src/hub/RestRoom.cpp @@ -0,0 +1,74 @@ +#include "RestRoom.hpp" + +namespace dnr { + +RestRoom::RestRoom() = default; + +void RestRoom::init(Player& player, int completedFloor) { + m_completedFloor = completedFloor; + m_recharged = false; + // Spawn player in the Safe Haven at (3, 4) facing North toward the recharge matrix + player.setGridPosition(3, 4); + player.setFacing(Direction::NORTH); +} + +void RestRoom::update(float dt, Player& player) { + player.update(dt); +} + +bool RestRoom::tryMove(int dx, int dy, Player& player) { + int nx = player.getGridX() + dx; + int ny = player.getGridY() + dy; + + // Outer boundaries + if (nx < 1 || nx > 5 || ny < 1 || ny > 5) return false; + + // Obstacle: Central nanite recharge pod at (3, 3) + if (nx == 3 && ny == 3) return false; + + // Obstacle: Terminal at (1, 3) + if (nx == 1 && ny == 3) return false; + + player.setGridPosition(nx, ny); + return true; +} + +bool RestRoom::isFacingGateway(const Player& player) const { + // Gateway is at North wall (3, 0) + int px = player.getGridX(); + int py = player.getGridY(); + Direction f = player.getFacing(); + + return (px == 3 && py == 1 && f == Direction::NORTH); +} + +bool RestRoom::isFacingRechargeMatrix(const Player& player) const { + // Central recharge station at (3, 3) + int px = player.getGridX(); + int py = player.getGridY(); + Direction f = player.getFacing(); + + if (px == 3 && py == 4 && f == Direction::NORTH) return true; + if (px == 3 && py == 2 && f == Direction::SOUTH) return true; + if (px == 2 && py == 3 && f == Direction::EAST) return true; + if (px == 4 && py == 3 && f == Direction::WEST) return true; + + return false; +} + +bool RestRoom::isFacingTerminal(const Player& player) const { + // Save terminal at (1, 3) + int px = player.getGridX(); + int py = player.getGridY(); + Direction f = player.getFacing(); + + return (px == 2 && py == 3 && f == Direction::WEST); +} + +void RestRoom::triggerRecharge(Player& player) { + player.heal(player.getMaxHp()); + player.restoreShield(player.getMaxShield()); + m_recharged = true; +} + +} // namespace dnr diff --git a/src/hub/RestRoom.hpp b/src/hub/RestRoom.hpp new file mode 100644 index 0000000..6df2124 --- /dev/null +++ b/src/hub/RestRoom.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Player.hpp" + +namespace dnr { + +class RestRoom { +public: + RestRoom(); + + void init(Player& player, int completedFloor); + void update(float dt, Player& player); + + bool tryMove(int dx, int dy, Player& player); + bool isFacingGateway(const Player& player) const; + bool isFacingRechargeMatrix(const Player& player) const; + bool isFacingTerminal(const Player& player) const; + + int getNextFloor() const { return m_completedFloor + 1; } + int getCompletedFloor() const { return m_completedFloor; } + + bool isRecharged() const { return m_recharged; } + void triggerRecharge(Player& player); + + int getWidth() const { return m_width; } + int getHeight() const { return m_height; } + + Vector3 getGatewayPosition() const { return m_gatewayPos; } + Vector3 getRechargePosition() const { return m_rechargePos; } + Vector3 getTerminalPosition() const { return m_terminalPos; } + +private: + int m_width = 7; + int m_height = 7; + int m_completedFloor = 1; + bool m_recharged = false; + + Vector3 m_gatewayPos{ 6.0f, 1.2f, 2.0f }; + Vector3 m_rechargePos{ 6.0f, 0.8f, 6.0f }; + Vector3 m_terminalPos{ 2.0f, 0.8f, 6.0f }; +}; + +} // namespace dnr diff --git a/src/inventory/InventoryUI.cpp b/src/inventory/InventoryUI.cpp new file mode 100644 index 0000000..3492f55 --- /dev/null +++ b/src/inventory/InventoryUI.cpp @@ -0,0 +1,330 @@ +#include "InventoryUI.hpp" +#include "core/SoundManager.hpp" +#include + +namespace dnr { + +static const AmmoType AVAILABLE_TYPES[6] = { + AmmoType::KINETIC, + AmmoType::EMP, + AmmoType::OVERHEAT, + AmmoType::CORROSIVE, + AmmoType::GLITCH, + AmmoType::NANITE +}; + +InventoryUI::InventoryUI() = default; + +void InventoryUI::update(Player& player) { + auto& rev = player.getRevolver(); + Vector2 mouse = GetMousePosition(); + bool mouseClicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + + // 1. Chamber selection (1-6 keys or Left/Right or Mouse click) + for (int k = KEY_ONE; k <= KEY_SIX; ++k) { + if (IsKeyPressed(k)) { + m_selectedChamber = k - KEY_ONE; + SoundManager::get().playTumblerClick(); + } + } + + if (IsKeyPressed(KEY_LEFT) || IsKeyPressed(KEY_A)) { + m_selectedChamber = (m_selectedChamber + 5) % 6; + SoundManager::get().playTumblerClick(); + } + if (IsKeyPressed(KEY_RIGHT) || IsKeyPressed(KEY_D)) { + m_selectedChamber = (m_selectedChamber + 1) % 6; + SoundManager::get().playTumblerClick(); + } + + // Chamber mouse collision detection using live visual rotation + int cylCenterX = 350; + int cylCenterY = 320; + float cylRadius = 115.0f; + float chamberOrbitR = cylRadius - 35.0f; + float chamberR = 24.0f; + + for (int i = 0; i < 6; ++i) { + float angle = (-rev.getVisualAngle() + i * 60.0f - 90.0f) * (3.14159265f / 180.0f); + int chX = cylCenterX + static_cast(std::cos(angle) * chamberOrbitR); + int chY = cylCenterY + static_cast(std::sin(angle) * chamberOrbitR); + if (mouseClicked && CheckCollisionPointCircle(mouse, { static_cast(chX), static_cast(chY) }, chamberR + 6.0f)) { + m_selectedChamber = i; + SoundManager::get().playTumblerClick(); + } + } + + // 2. Ammo type selection (Up/Down or W/S or Mouse click) + if (IsKeyPressed(KEY_UP) || IsKeyPressed(KEY_W)) { + m_selectedTypeIndex = (m_selectedTypeIndex + 5) % 6; + SoundManager::get().playTumblerClick(); + } + if (IsKeyPressed(KEY_DOWN) || IsKeyPressed(KEY_S)) { + m_selectedTypeIndex = (m_selectedTypeIndex + 1) % 6; + SoundManager::get().playTumblerClick(); + } + + // Ammo row mouse collision detection + int pouchX = 660; + int pouchY = 120; + int pouchW = 520; + int startY = pouchY + 75; + for (int i = 0; i < 6; ++i) { + Rectangle rowRect = { static_cast(pouchX + 15), static_cast(startY + i * 50), static_cast(pouchW - 30), 44.0f }; + if (mouseClicked && CheckCollisionPointRec(mouse, rowRect)) { + m_selectedTypeIndex = i; + SoundManager::get().playTumblerClick(); + } + } + + // 3. Load into chamber (Enter / Space / E) + if (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_SPACE) || IsKeyPressed(KEY_E)) { + AmmoType targetType = AVAILABLE_TYPES[m_selectedTypeIndex]; + if (player.getAmmoCount(targetType) > 0) { + AmmoType oldAmmo = rev.getChamber(m_selectedChamber); + + player.consumeAmmo(targetType); + if (oldAmmo != AmmoType::EMPTY) { + player.addAmmo(oldAmmo, 1); + } + rev.setChamber(m_selectedChamber, targetType); + + SoundManager::get().playLoadShell(); + } else { + SoundManager::get().playDamage(); + } + } + + // 4. Unload chamber (U or Backspace) + if (IsKeyPressed(KEY_U) || IsKeyPressed(KEY_BACKSPACE)) { + AmmoType oldAmmo = rev.getChamber(m_selectedChamber); + if (oldAmmo != AmmoType::EMPTY) { + player.addAmmo(oldAmmo, 1); + rev.setChamber(m_selectedChamber, AmmoType::EMPTY); + SoundManager::get().playTumblerClick(); + } + } + + // 5. Spin & Test Cylinder (R) + if (IsKeyPressed(KEY_R) && !rev.isSpinning()) { + rev.spinToRandom(); + SoundManager::get().playRespin(); + } +} + +void InventoryUI::draw(const Player& player) { + const auto& rev = player.getRevolver(); + Vector2 mouse = GetMousePosition(); + + // Dark semi-transparent cyber overlay + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 245 }); + + // Header + DrawRectangle(80, 35, GetScreenWidth() - 160, 52, Color{ 12, 22, 38, 255 }); + DrawRectangleLines(80, 35, GetScreenWidth() - 160, 52, CyberColors::NeonCyan); + DrawText("CYBER-WEAPON ARMORY // CYLINDER CONFIGURATION", 105, 50, 20, CyberColors::NeonCyan); + DrawText("[TAB / ESC] RETURN TO GAME", GetScreenWidth() - 340, 51, 16, CyberColors::NeonYellow); + + // Left Panel: Revolver Cylinder + int cylCenterX = 350; + int cylCenterY = 320; + float cylRadius = 115.0f; + float chamberOrbitR = cylRadius - 35.0f; + float chamberR = 24.0f; + + // 1. Heavy gunmetal outer housing + DrawCircle(cylCenterX, cylCenterY, cylRadius + 18.0f, Color{ 8, 14, 24, 250 }); + DrawCircleLines(cylCenterX, cylCenterY, cylRadius + 18.0f, Color{ 30, 50, 75, 255 }); + + // Metallic cylinder body + DrawCircle(cylCenterX, cylCenterY, cylRadius, Color{ 20, 28, 42, 255 }); + DrawCircleLines(cylCenterX, cylCenterY, cylRadius, CyberColors::NeonCyan); + + // 6 Decorative flutes between chambers + float visAngle = rev.getVisualAngle(); + for (int i = 0; i < 6; ++i) { + float fluteAngle = (-visAngle + i * 60.0f + 30.0f - 90.0f) * (3.14159265f / 180.0f); + int fluteX = cylCenterX + static_cast(std::cos(fluteAngle) * (cylRadius + 2.0f)); + int fluteY = cylCenterY + static_cast(std::sin(fluteAngle) * (cylRadius + 2.0f)); + DrawCircle(fluteX, fluteY, 14.0f, Color{ 8, 14, 24, 255 }); + DrawCircleLines(fluteX, fluteY, 14.0f, Color{ 40, 65, 95, 255 }); + } + + // 2. Extractor Ratchet & Hub + DrawCircle(cylCenterX, cylCenterY, 36.0f, Color{ 14, 20, 32, 255 }); + DrawCircleLines(cylCenterX, cylCenterY, 36.0f, Color{ 70, 100, 140, 255 }); + DrawCircle(cylCenterX, cylCenterY, 16.0f, Color{ 220, 175, 45, 255 }); + DrawCircleLines(cylCenterX, cylCenterY, 16.0f, CyberColors::NeonYellow); + DrawText(".357", cylCenterX - 16, cylCenterY - 7, 13, CyberColors::NeonYellow); + + // 3. Golden Striker Hammer / Firing Pin at 12:00 (-90 deg) + int strikerTipY = cylCenterY - static_cast(cylRadius) - 4; + int strikerBaseY = cylCenterY - static_cast(cylRadius) - 28; + + DrawTriangle( + { static_cast(cylCenterX), static_cast(strikerTipY) }, + { static_cast(cylCenterX - 14), static_cast(strikerBaseY) }, + { static_cast(cylCenterX + 14), static_cast(strikerBaseY) }, + Color{ 255, 210, 50, 255 } + ); + DrawRectangle(cylCenterX - 20, strikerBaseY - 14, 40, 14, Color{ 210, 160, 30, 255 }); + DrawRectangleLines(cylCenterX - 20, strikerBaseY - 14, 40, 14, CyberColors::NeonYellow); + DrawCircle(cylCenterX, strikerTipY, 3.0f, CyberColors::NeonYellow); + + DrawRectangle(cylCenterX - 65, strikerBaseY - 32, 130, 16, Color{ 10, 16, 26, 230 }); + DrawRectangleLines(cylCenterX - 65, strikerBaseY - 32, 130, 16, Color{ 180, 150, 40, 200 }); + DrawText("FIRING PIN [12:00]", cylCenterX - 58, strikerBaseY - 30, 11, CyberColors::NeonYellow); + + // 4. Six Chambers (Rotates with rev.getVisualAngle()) + for (int i = 0; i < 6; ++i) { + float angleRad = (-visAngle + i * 60.0f - 90.0f) * (3.14159265f / 180.0f); + int chX = cylCenterX + static_cast(std::cos(angleRad) * chamberOrbitR); + int chY = cylCenterY + static_cast(std::sin(angleRad) * chamberOrbitR); + + AmmoType ammo = rev.getChamber(i); + const auto& info = getAmmoInfo(ammo); + bool isSelected = (i == m_selectedChamber); + bool isCurrentReady = (i == rev.getCurrentIndex()); + + DrawCircle(chX, chY, chamberR, Color{ 16, 22, 34, 255 }); + DrawCircleLines(chX, chY, chamberR, isSelected ? CyberColors::NeonYellow : Color{ 55, 80, 120, 255 }); + + if (ammo == AmmoType::EMPTY) { + DrawCircle(chX, chY, chamberR - 4.0f, Color{ 8, 12, 18, 255 }); + DrawCircleLines(chX, chY, chamberR - 4.0f, Color{ 60, 60, 60, 255 }); + DrawText("0", chX - 4, chY - 6, 12, DARKGRAY); + } else { + DrawCircle(chX, chY, chamberR - 4.0f, Color{ 34, 46, 62, 255 }); + DrawCircleLines(chX, chY, chamberR - 4.0f, Color{ 180, 140, 50, 255 }); + DrawCircle(chX, chY, chamberR - 8.0f, info.color); + DrawCircle(chX - 4, chY - 4, 3.0f, Color{ 255, 255, 255, 200 }); + DrawText(info.shortName.substr(0, 3).c_str(), chX - 9, chY - 5, 11, BLACK); + } + + // Selection Reticle + if (isSelected) { + DrawRectangleLines(chX - static_cast(chamberR) - 4, chY - static_cast(chamberR) - 4, + static_cast(chamberR * 2) + 8, static_cast(chamberR * 2) + 8, + CyberColors::NeonYellow); + } + + // Active Chamber Marker (Under firing pin) + if (isCurrentReady) { + DrawCircleLines(chX, chY, chamberR + 6.0f, CyberColors::NeonGreen); + DrawText("READY", chX - 16, chY + 28, 10, CyberColors::NeonGreen); + } + } + + // Selected chamber info box below cylinder + int boxX = 110; + int boxY = 480; + int boxW = 480; + int boxH = 100; + DrawRectangle(boxX, boxY, boxW, boxH, Color{ 10, 18, 30, 255 }); + DrawRectangleLines(boxX, boxY, boxW, boxH, CyberColors::NeonCyan); + + AmmoType selChamberAmmo = rev.getChamber(m_selectedChamber); + const auto& selChamberInfo = getAmmoInfo(selChamberAmmo); + + std::string selHeader = "SELECTED CHAMBER #" + std::to_string(m_selectedChamber + 1); + if (m_selectedChamber == rev.getCurrentIndex()) { + selHeader += " [ACTIVE @ 12:00 FIRING PIN]"; + } + DrawText(selHeader.c_str(), boxX + 15, boxY + 12, 15, CyberColors::NeonYellow); + DrawText(("LOADED: " + selChamberInfo.name).c_str(), boxX + 15, boxY + 36, 15, selChamberInfo.color); + DrawText(("IMPACT: " + std::to_string(selChamberInfo.baseDamage) + " DMG // " + selChamberInfo.description).c_str(), boxX + 15, boxY + 58, 12, RAYWHITE); + DrawText("[1-6 / CLICK] SELECT | [U] UNLOAD | [R] SPIN ROULETTE", boxX + 15, boxY + 78, 11, CyberColors::NeonCyan); + + // Ammo Probability Breakdown under info box + int oddsY = boxY + boxH + 12; + int oddsH = 65; + DrawRectangle(boxX, oddsY, boxW, oddsH, Color{ 8, 14, 24, 255 }); + DrawRectangleLines(boxX, oddsY, boxW, oddsH, Color{ 35, 60, 95, 255 }); + DrawText("// AMMO PROBABILITY ROULETTE", boxX + 15, oddsY + 8, 10, GRAY); + + int loadedCount = 0; + int typeCounts[7] = { 0 }; + for (int i = 0; i < 6; ++i) { + AmmoType a = rev.getChamber(i); + typeCounts[static_cast(a)]++; + if (a != AmmoType::EMPTY) loadedCount++; + } + int hitChance = (loadedCount * 100) / 6; + int blankChance = 100 - hitChance; + + std::string oddsStr = "LOADED: " + std::to_string(loadedCount) + "/6 (" + std::to_string(hitChance) + "% HIT)"; + DrawText(oddsStr.c_str(), boxX + 15, oddsY + 24, 13, hitChance >= 50 ? CyberColors::NeonGreen : CyberColors::NeonYellow); + + std::string blankStr = "MISFIRE RISK: " + std::to_string(blankChance) + "%"; + DrawText(blankStr.c_str(), boxX + 220, oddsY + 24, 13, blankChance == 0 ? CyberColors::NeonGreen : (blankChance > 33 ? RED : CyberColors::NeonYellow)); + + std::string chipSummary = ""; + for (int t = 1; t <= 6; ++t) { + if (typeCounts[t] > 0) { + const auto& inf = getAmmoInfo(static_cast(t)); + if (!chipSummary.empty()) chipSummary += " | "; + chipSummary += inf.shortName + "x" + std::to_string(typeCounts[t]); + } + } + if (chipSummary.empty()) chipSummary = "ALL CHAMBERS EMPTY - LOAD CARTRIDGES FROM STACK"; + DrawText(chipSummary.c_str(), boxX + 15, oddsY + 44, 11, Color{ 170, 195, 225, 255 }); + + // Right Panel: Ammo Pouch / Storage (Stacked 6 Types) + int pouchX = 660; + int pouchY = 120; + int pouchW = 520; + int pouchH = 550; + + DrawRectangle(pouchX, pouchY, pouchW, pouchH, Color{ 10, 16, 26, 255 }); + DrawRectangleLines(pouchX, pouchY, pouchW, pouchH, CyberColors::NeonCyan); + DrawText("TACTICAL CARTRIDGE STACK // INVENTORY", pouchX + 20, pouchY + 15, 18, CyberColors::NeonCyan); + + DrawText(("[W/S / CLICK] SELECT [ENTER / E / SPACE] LOAD INTO CHAMBER #" + std::to_string(m_selectedChamber + 1)).c_str(), + pouchX + 20, pouchY + 45, 13, CyberColors::NeonYellow); + + int startY = pouchY + 75; + for (int i = 0; i < 6; ++i) { + AmmoType aType = AVAILABLE_TYPES[i]; + const auto& itemInfo = getAmmoInfo(aType); + int count = player.getAmmoCount(aType); + + bool isSel = (i == m_selectedTypeIndex); + Rectangle rowRect = { static_cast(pouchX + 15), static_cast(startY + i * 50), static_cast(pouchW - 30), 44.0f }; + bool isHover = CheckCollisionPointRec(mouse, rowRect); + + Color boxCol = isSel ? Color{ 30, 50, 80, 255 } : (isHover ? Color{ 20, 32, 52, 255 } : Color{ 14, 20, 32, 255 }); + Color borderCol = isSel ? CyberColors::NeonYellow : (isHover ? CyberColors::NeonCyan : Color{ 30, 50, 75, 255 }); + + DrawRectangleRec(rowRect, boxCol); + DrawRectangleLinesEx(rowRect, isSel ? 2.0f : 1.0f, borderCol); + + // Icon + DrawCircle(pouchX + 35, startY + i * 50 + 22, 11.0f, count > 0 ? itemInfo.color : DARKGRAY); + + // Name + DrawText(itemInfo.name.c_str(), pouchX + 55, startY + i * 50 + 13, 15, count > 0 ? (isSel ? CyberColors::NeonYellow : RAYWHITE) : GRAY); + + // Damage Tag + DrawText(("DMG: " + std::to_string(itemInfo.baseDamage)).c_str(), pouchX + 230, startY + i * 50 + 13, 14, CyberColors::NeonCyan); + + // Stack Count + std::string countStr = "[x " + std::to_string(count) + "]"; + Color countColor = (count > 0) ? (isSel ? CyberColors::NeonYellow : CyberColors::NeonGreen) : RED; + DrawText(countStr.c_str(), pouchX + 430, startY + i * 50 + 12, 16, countColor); + } + + // Selected item detail footer + AmmoType curSelType = AVAILABLE_TYPES[m_selectedTypeIndex]; + const auto& curSelInfo = getAmmoInfo(curSelType); + int curSelCount = player.getAmmoCount(curSelType); + + DrawRectangle(pouchX + 15, pouchY + pouchH - 120, pouchW - 30, 105, Color{ 14, 24, 40, 255 }); + DrawRectangleLines(pouchX + 15, pouchY + pouchH - 120, pouchW - 30, 105, CyberColors::NeonGreen); + + DrawText(curSelInfo.name.c_str(), pouchX + 30, pouchY + pouchH - 110, 16, curSelInfo.color); + DrawText(("IN STOCK: " + std::to_string(curSelCount) + " CARTRIDGES").c_str(), pouchX + 330, pouchY + pouchH - 110, 14, curSelCount > 0 ? CyberColors::NeonGreen : RED); + DrawText(curSelInfo.description.c_str(), pouchX + 30, pouchY + pouchH - 85, 13, RAYWHITE); + DrawText("[ENTER / E / SPACE] INSERT INTO SELECTED CHAMBER", pouchX + 30, pouchY + pouchH - 50, 13, CyberColors::NeonYellow); +} + +} // namespace dnr diff --git a/src/inventory/InventoryUI.hpp b/src/inventory/InventoryUI.hpp new file mode 100644 index 0000000..75495e8 --- /dev/null +++ b/src/inventory/InventoryUI.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Player.hpp" + +namespace dnr { + +class InventoryUI { +public: + InventoryUI(); + + void update(Player& player); + void draw(const Player& player); + + int getSelectedChamber() const { return m_selectedChamber; } + int getSelectedTypeIndex() const { return m_selectedTypeIndex; } + +private: + int m_selectedChamber = 0; // 0 to 5 + int m_selectedTypeIndex = 0; // 0 to 5 (index in 6 ammo types) +}; + +} // namespace dnr diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..f15b091 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,25 @@ +#include "raylib.h" +#include "core/Game.hpp" + +int main() { + const int screenWidth = 1280; + const int screenHeight = 720; + + SetConfigFlags(FLAG_VSYNC_HINT | FLAG_MSAA_4X_HINT); + InitWindow(screenWidth, screenHeight, "DoorNroll // Cyber-Revolver Cyberspace Crawler"); + SetExitKey(KEY_NULL); // Prevent ESC from closing the game window + SetTargetFPS(60); + + dnr::Game game; + game.init(); + + while (!game.shouldClose()) { + float dt = GetFrameTime(); + if (dt > 0.1f) dt = 0.1f; // Clamp delta time on frame lag + game.update(dt); + game.draw(); + } + + CloseWindow(); + return 0; +} diff --git a/src/render/CyberRenderer.cpp b/src/render/CyberRenderer.cpp new file mode 100644 index 0000000..ac41718 --- /dev/null +++ b/src/render/CyberRenderer.cpp @@ -0,0 +1,1681 @@ +#include "CyberRenderer.hpp" +#include "hub/HubRoom.hpp" +#include "hub/RestRoom.hpp" +#include "combat/CombatSystem.hpp" +#include "rlgl.h" +#include +#include +#include +#include + +namespace dnr { + +namespace { + +// Draw a textured quad in 3D using rlgl +void drawTexturedQuad(Texture2D tex, Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, float uTiles = 1.0f, float vTiles = 1.0f, Color tint = WHITE) { + rlDisableBackfaceCulling(); + rlSetTexture(tex.id); + rlBegin(RL_QUADS); + rlColor4ub(tint.r, tint.g, tint.b, tint.a); + + rlTexCoord2f(0.0f, 0.0f); + rlVertex3f(v0.x, v0.y, v0.z); + + rlTexCoord2f(0.0f, vTiles); + rlVertex3f(v1.x, v1.y, v1.z); + + rlTexCoord2f(uTiles, vTiles); + rlVertex3f(v2.x, v2.y, v2.z); + + rlTexCoord2f(uTiles, 0.0f); + rlVertex3f(v3.x, v3.y, v3.z); + + rlEnd(); + rlSetTexture(0); + rlEnableBackfaceCulling(); +} + +// Draw a textured 3D box using rlgl +void drawTexturedBox(Texture2D tex, Vector3 pos, Vector3 size, float uTile = 1.0f, float vTile = 1.0f, Color tint = WHITE) { + float x = pos.x - size.x * 0.5f; + float y = pos.y - size.y * 0.5f; + float z = pos.z - size.z * 0.5f; + float w = size.x; + float h = size.y; + float d = size.z; + + rlDisableBackfaceCulling(); + rlSetTexture(tex.id); + rlBegin(RL_QUADS); + rlColor4ub(tint.r, tint.g, tint.b, tint.a); + + // Front face (+Z) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x, y + h, z + d); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x, y, z + d); + rlTexCoord2f(uTile, vTile); rlVertex3f(x + w, y, z + d); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x + w, y + h, z + d); + + // Back face (-Z) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x + w, y + h, z); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x + w, y, z); + rlTexCoord2f(uTile, vTile); rlVertex3f(x, y, z); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x, y + h, z); + + // Top face (+Y) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x, y + h, z); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x, y + h, z + d); + rlTexCoord2f(uTile, vTile); rlVertex3f(x + w, y + h, z + d); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x + w, y + h, z); + + // Bottom face (-Y) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x, y, z + d); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x, y, z); + rlTexCoord2f(uTile, vTile); rlVertex3f(x + w, y, z); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x + w, y, z + d); + + // Right face (+X) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x + w, y + h, z + d); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x + w, y, z + d); + rlTexCoord2f(uTile, vTile); rlVertex3f(x + w, y, z); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x + w, y + h, z); + + // Left face (-X) + rlTexCoord2f(0.0f, 0.0f); rlVertex3f(x, y + h, z); + rlTexCoord2f(0.0f, vTile); rlVertex3f(x, y, z); + rlTexCoord2f(uTile, vTile); rlVertex3f(x, y, z + d); + rlTexCoord2f(uTile, 0.0f); rlVertex3f(x, y + h, z + d); + + rlEnd(); + rlSetTexture(0); + rlEnableBackfaceCulling(); +} + +// Procedural generator: Warm Oak Parquet Floor (256x256) +Texture2D createParquetTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 145, 95, 52, 255 }); + Color* pixels = (Color*)img.data; + + const int plankHeight = 32; + + for (int y = 0; y < h; ++y) { + int row = y / plankHeight; + int rowY = y % plankHeight; + bool isHorizontalSeam = (rowY == 0 || rowY == plankHeight - 1); + + for (int x = 0; x < w; ++x) { + int shift = (row % 2 == 0) ? 0 : 48; + int plankW = 96; + int plankCol = (x + shift) / plankW; + int plankX = (x + shift) % plankW; + bool isVerticalSeam = (plankX == 0 || plankX == plankW - 1); + + if (isHorizontalSeam || isVerticalSeam) { + pixels[y * w + x] = Color{ 48, 28, 14, 255 }; + continue; + } + + int plankSeed = (row * 17 + plankCol * 31); + int toneOffset = (plankSeed % 31) - 15; + + int baseR = 158 + toneOffset; + int baseG = 108 + (toneOffset * 3) / 4; + int baseB = 60 + toneOffset / 2; + + float grainSin = std::sin((y * 4.0f + std::sin(x * 0.05f) * 6.0f) * 0.4f); + int grainVal = static_cast(grainSin * 14.0f); + int fiberNoise = ((x * 13 + y * 29) % 9) - 4; + + int finalR = std::clamp(baseR + grainVal + fiberNoise, 40, 240); + int finalG = std::clamp(baseG + (grainVal * 3) / 4 + fiberNoise, 25, 200); + int finalB = std::clamp(baseB + grainVal / 2 + fiberNoise, 10, 160); + + pixels[y * w + x] = Color{ (unsigned char)finalR, (unsigned char)finalG, (unsigned char)finalB, 255 }; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Luxury Royal Navy Persian/Cyber Rug (256x256) +Texture2D createRugTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 22, 32, 54, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + int distEdge = std::min({ x, w - 1 - x, y, h - 1 - y }); + int weave = ((x + y) % 2 == 0) ? 6 : -6; + + Color c = Color{ 24, 35, 58, 255 }; + + if (distEdge < 4) { + c = Color{ 210, 185, 130, 255 }; // Fringes + } else if (distEdge >= 6 && distEdge <= 22) { + // Wide ornate gold border + int pattern = ((x / 6 + y / 6) % 2 == 0) ? 35 : -15; + c = Color{ (unsigned char)std::clamp(185 + pattern, 0, 255), + (unsigned char)std::clamp(150 + pattern, 0, 255), + (unsigned char)std::clamp(75 + pattern, 0, 255), 255 }; + } else if (distEdge == 24 || distEdge == 25) { + c = Color{ 165, 35, 45, 255 }; // Crimson accent piping + } else if (distEdge >= 28 && distEdge <= 38) { + if ((x / 4) % 2 == (y / 4) % 2) { + c = Color{ 125, 98, 58, 255 }; + } else { + c = Color{ 36, 52, 84, 255 }; + } + } else { + int cx = w / 2; + int cy = h / 2; + int dx = std::abs(x - cx); + int dy = std::abs(y - cy); + int manhattanDist = dx + dy; + int eucDist = static_cast(std::sqrt(dx * dx + dy * dy)); + + if (manhattanDist < 60 && manhattanDist > 54) { + c = Color{ 215, 175, 85, 255 }; // Gold diamond outline + } else if (manhattanDist <= 54 && manhattanDist >= 40) { + c = Color{ 150, 32, 48, 255 }; // Crimson medallion body + } else if (eucDist < 25) { + if (eucDist < 8) { + c = Color{ 245, 215, 125, 255 }; + } else if ((dx == dy) || dx == 0 || dy == 0) { + c = Color{ 215, 175, 85, 255 }; + } else { + c = Color{ 20, 32, 54, 255 }; + } + } else if (manhattanDist < 85 && (dx < 12 || dy < 12)) { + c = Color{ 175, 145, 75, 255 }; + } + } + + int r = std::clamp(c.r + weave, 0, 255); + int g = std::clamp(c.g + weave, 0, 255); + int b = std::clamp(c.b + weave, 0, 255); + pixels[y * w + x] = Color{ (unsigned char)r, (unsigned char)g, (unsigned char)b, 255 }; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Textured Apartment Stucco/Wallpaper (256x256) +Texture2D createWallTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 78, 84, 94, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + float bottomShadow = (y > 220) ? (1.0f - (y - 220) / 36.0f * 0.35f) : 1.0f; + float topShadow = (y < 30) ? (0.82f + (y / 30.0f) * 0.18f) : 1.0f; + float vLighting = bottomShadow * topShadow; + + for (int x = 0; x < w; ++x) { + int seamMod = x % 64; + int seamShade = 0; + if (seamMod == 0) seamShade = -22; + else if (seamMod == 1) seamShade = +16; + + int stipple = ((x * 19 + y * 37 + (x ^ y) * 11) % 15) - 7; + + int baseR = static_cast((82 + stipple + seamShade) * vLighting); + int baseG = static_cast((88 + stipple + seamShade) * vLighting); + int baseB = static_cast((98 + stipple + seamShade) * vLighting); + + pixels[y * w + x] = Color{ + (unsigned char)std::clamp(baseR, 0, 255), + (unsigned char)std::clamp(baseG, 0, 255), + (unsigned char)std::clamp(baseB, 0, 255), + 255 + }; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Cyberpunk Synthwave Neon Wall Art (128x256) +Texture2D createPosterTexture() { + const int w = 128; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 15, 10, 24, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + float ny = (float)y / h; + for (int x = 0; x < w; ++x) { + float nx = (float)x / w; + + if (x < 4 || x >= w - 4 || y < 4 || y >= h - 4) { + pixels[y * w + x] = Color{ 25, 25, 30, 255 }; // Metal frame + continue; + } + + Color c; + if (ny < 0.65f) { + int r = static_cast(20 + ny * 140); + int g = static_cast(10 + ny * 20); + int b = static_cast(40 + ny * 70); + c = Color{ (unsigned char)r, (unsigned char)g, (unsigned char)b, 255 }; + + // Retro Neon Sun + float sdx = (float)(x - 64); + float sdy = (float)(y - 110); + float sDist = std::sqrt(sdx * sdx + sdy * sdy); + if (sDist < 38.0f) { + bool isCutout = (y > 105 && (y % 6 < 2)); + if (!isCutout) { + float sunT = (y - 72) / 76.0f; + int sr = 255; + int sg = static_cast(220 - sunT * 180); + int sb = static_cast(40 + sunT * 120); + c = Color{ (unsigned char)sr, (unsigned char)sg, (unsigned char)sb, 255 }; + } + } + } else { + float gridY = (ny - 0.65f) / 0.35f; + c = Color{ 10, 12, 22, 255 }; + + int lineIndex = static_cast(std::pow(gridY, 0.6f) * 10.0f); + int prevIndex = static_cast(std::pow(std::max(0.0f, gridY - 0.015f), 0.6f) * 10.0f); + if (lineIndex != prevIndex) { + c = Color{ 0, 230, 255, 255 }; + } + + float rayAngle = std::atan2(gridY, nx - 0.5f); + int rayId = static_cast((rayAngle + 3.14159f) * 6.0f); + int rayIdNext = static_cast((std::atan2(gridY, (nx + 0.03f) - 0.5f) + 3.14159f) * 6.0f); + if (rayId != rayIdNext) { + c = Color{ 255, 0, 160, 255 }; + } + } + + pixels[y * w + x] = c; + } + } + + ImageDrawText(&img, "NETRUNNER", 14, 12, 12, Color{ 0, 240, 255, 255 }); + ImageDrawText(&img, "2088", 50, 26, 10, Color{ 255, 50, 150, 255 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Polished Dark Walnut Desk Wood (256x256) +Texture2D createDeskWoodTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 90, 54, 32, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + float grain1 = std::sin((x * 0.4f + std::sin(y * 0.08f) * 4.0f)); + float grain2 = std::sin((x * 1.2f)); + int gVal = static_cast(grain1 * 12.0f + grain2 * 6.0f); + + int r = std::clamp(92 + gVal, 30, 200); + int g = std::clamp(56 + (gVal * 2) / 3, 20, 150); + int b = std::clamp(34 + gVal / 2, 10, 100); + + int sheen = static_cast(std::sin((x + y * 0.5f) * 0.05f) * 10.0f); + if (sheen > 0) { + r = std::clamp(r + sheen, 0, 255); + g = std::clamp(g + sheen, 0, 255); + b = std::clamp(b + sheen, 0, 255); + } + + pixels[y * w + x] = Color{ (unsigned char)r, (unsigned char)g, (unsigned char)b, 255 }; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Code Editor / IDE Monitor Screen (512x256) +Texture2D createMonitorCodeTexture() { + const int w = 512; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 18, 20, 28, 255 }); + + ImageDrawRectangle(&img, 0, 0, w, 22, Color{ 28, 32, 44, 255 }); + ImageDrawCircle(&img, 12, 11, 4, Color{ 255, 95, 86, 255 }); + ImageDrawCircle(&img, 26, 11, 4, Color{ 255, 189, 46, 255 }); + ImageDrawCircle(&img, 40, 11, 4, Color{ 39, 201, 63, 255 }); + + ImageDrawText(&img, "neural_daemon.cpp - [ACTIVE SESSION]", 60, 6, 10, Color{ 160, 175, 200, 255 }); + ImageDrawRectangle(&img, 0, 22, 38, h - 38, Color{ 22, 25, 34, 255 }); + + ImageDrawRectangle(&img, 0, h - 16, w, 16, Color{ 0, 122, 204, 255 }); + ImageDrawText(&img, " UTF-8 C++20 CYBER-LINK: 10 Gbps ICE_SECURITY: BYPASS READY", 8, h - 13, 10, WHITE); + ImageDrawRectangle(&img, w - 45, 22, 45, h - 38, Color{ 22, 24, 32, 255 }); + + const char* codeLines[] = { + "#include ", + "namespace netrunner {", + " // Primary ICE Breaker protocol", + " class NeuralExploit : public IDaemon {", + " public:", + " bool injectPayload(uint64_t targetNode) {", + " CipherBuffer buffer = decryptChamber(6);", + " if (!buffer.isValid()) {", + " triggerAlarm(ICE_WARNING);", + " return false; // lockout", + " }", + " log(\"JACK IN SEQUENCE CONFIRMED\");", + " return m_revolver.spinAndFire();", + " }", + " private:", + " CyberRevolver m_revolver{ 6 };", + " };", + "} // namespace netrunner", + "// Awaiting Neural Sync command..." + }; + + int lineCount = sizeof(codeLines) / sizeof(codeLines[0]); + for (int i = 0; i < lineCount; ++i) { + int lineY = 28 + i * 11; + char numBuf[8]; + snprintf(numBuf, sizeof(numBuf), "%02d", i + 1); + ImageDrawText(&img, numBuf, 8, lineY, 10, Color{ 85, 95, 115, 255 }); + + Color syntaxCol = Color{ 220, 225, 235, 255 }; + std::string s = codeLines[i]; + if (s.rfind("#include", 0) == 0) syntaxCol = Color{ 210, 140, 230, 255 }; + else if (s.find("//") != std::string::npos) syntaxCol = Color{ 100, 210, 115, 255 }; + else if (s.find("class") != std::string::npos || s.find("namespace") != std::string::npos) syntaxCol = Color{ 86, 156, 214, 255 }; + else if (s.find("bool") != std::string::npos || s.find("uint64_t") != std::string::npos) syntaxCol = Color{ 78, 201, 176, 255 }; + else if (s.find("return") != std::string::npos) syntaxCol = Color{ 197, 134, 192, 255 }; + else if (s.find("\"") != std::string::npos) syntaxCol = Color{ 206, 145, 120, 255 }; + else if (s.find("injectPayload") != std::string::npos || s.find("decryptChamber") != std::string::npos) syntaxCol = Color{ 220, 220, 130, 255 }; + + ImageDrawText(&img, codeLines[i], 46, lineY, 10, syntaxCol); + ImageDrawRectangle(&img, w - 40, lineY, static_cast(s.length() * 0.8f), 2, syntaxCol); + } + + ImageDrawRectangle(&img, 46 + 220, 28 + 18 * 11, 7, 10, Color{ 0, 240, 255, 255 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Cyberspace Telemetry Radar Monitor (256x256) +Texture2D createMonitorRadarTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 10, 16, 26, 255 }); + + ImageDrawRectangle(&img, 0, 0, w, 20, Color{ 16, 26, 42, 255 }); + ImageDrawText(&img, "NET SCAN // SUBNET 192.168.0", 8, 5, 10, Color{ 0, 220, 255, 255 }); + + int cx = w / 2; + int cy = h / 2 + 10; + ImageDrawCircleLines(&img, cx, cy, 30, Color{ 0, 140, 180, 180 }); + ImageDrawCircleLines(&img, cx, cy, 60, Color{ 0, 140, 180, 180 }); + ImageDrawCircleLines(&img, cx, cy, 90, Color{ 0, 140, 180, 180 }); + + ImageDrawLine(&img, cx - 100, cy, cx + 100, cy, Color{ 0, 180, 220, 120 }); + ImageDrawLine(&img, cx, cy - 100, cx, cy + 100, Color{ 0, 180, 220, 120 }); + ImageDrawLine(&img, cx, cy, cx + 65, cy - 65, Color{ 0, 255, 180, 255 }); + + Vector2 nodes[] = { + { 70.0f, 90.0f }, + { 180.0f, 80.0f }, + { 160.0f, 190.0f }, + { 90.0f, 180.0f }, + { 128.0f, 138.0f } + }; + for (int i = 0; i < 4; ++i) { + ImageDrawLine(&img, (int)nodes[i].x, (int)nodes[i].y, (int)nodes[4].x, (int)nodes[4].y, Color{ 0, 200, 255, 160 }); + ImageDrawCircle(&img, (int)nodes[i].x, (int)nodes[i].y, 4, Color{ 255, 190, 0, 255 }); + } + ImageDrawCircle(&img, (int)nodes[4].x, (int)nodes[4].y, 6, Color{ 0, 255, 120, 255 }); + + ImageDrawText(&img, "GATEWAY STATUS: STANDBY", 10, h - 26, 10, Color{ 0, 255, 120, 255 }); + ImageDrawText(&img, "ICE BREAKER: READY", 10, h - 14, 10, Color{ 255, 200, 60, 255 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Diamond-Quilted Bedspread (256x256) +Texture2D createBedQuiltTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 42, 54, 76, 255 }); + Color* pixels = (Color*)img.data; + + const int gridSize = 32; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + int d1 = (x + y) % gridSize; + int d2 = (x - y + 1000 * gridSize) % gridSize; + + bool isSeam = (d1 <= 1 || d2 <= 1); + int distToSeam = std::min({ d1, gridSize - d1, d2, gridSize - d2 }); + + float puff = std::clamp(distToSeam / (gridSize * 0.5f), 0.0f, 1.0f); + int puffShade = static_cast(puff * 22.0f); + int weave = ((x ^ y) % 3) * 3 - 3; + + int r = 40 + puffShade + weave; + int g = 52 + puffShade + weave; + int b = 74 + puffShade + weave; + + if (isSeam) { + bool isStitch = ((x / 4 + y / 4) % 2 == 0); + if (isStitch) { + r = 110; g = 135; b = 175; + } else { + r = 24; g = 32; b = 46; + } + } + + pixels[y * w + x] = Color{ + (unsigned char)std::clamp(r, 0, 255), + (unsigned char)std::clamp(g, 0, 255), + (unsigned char)std::clamp(b, 0, 255), + 255 + }; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Megacity Night Skyline Window Backdrop (512x256) +Texture2D createCityNightTexture() { + const int w = 512; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 8, 12, 24, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + float t = (float)y / h; + int r = static_cast(6 + t * 24); + int g = static_cast(10 + t * 16); + int b = static_cast(20 + t * 35); + for (int x = 0; x < w; ++x) { + pixels[y * w + x] = Color{ (unsigned char)r, (unsigned char)g, (unsigned char)b, 255 }; + } + } + + // Stars + for (int i = 0; i < 70; ++i) { + int sx = (i * 73 + 17) % w; + int sy = (i * 37 + 5) % (h / 2); + int brightness = 140 + (i * 29) % 115; + ImageDrawPixel(&img, sx, sy, Color{ (unsigned char)brightness, (unsigned char)brightness, 255, 255 }); + } + + // Distant dark silhouettes + for (int b = 0; b < 24; ++b) { + int bx = b * 22; + int bw = 18 + (b * 7) % 15; + int bh = 70 + (b * 31) % 80; + int by = h - bh; + ImageDrawRectangle(&img, bx, by, bw, bh, Color{ 14, 18, 32, 255 }); + } + + // Foreground lit skyscrapers + for (int b = 0; b < 16; ++b) { + int bx = b * 32 + (b % 3) * 4; + int bw = 24 + (b * 11) % 18; + int bh = 110 + (b * 47) % 115; + int by = h - bh; + + Color bldgCol = Color{ 20, 26, 42, 255 }; + ImageDrawRectangle(&img, bx, by, bw, bh, bldgCol); + + if (bh > 160) { + ImageDrawLine(&img, bx + bw / 2, by - 25, bx + bw / 2, by, Color{ 60, 70, 90, 255 }); + ImageDrawCircle(&img, bx + bw / 2, by - 26, 2, Color{ 255, 40, 40, 255 }); + } + + for (int wy = by + 12; wy < h - 8; wy += 8) { + for (int wx = bx + 4; wx < bx + bw - 4; wx += 5) { + int litSeed = (wx * 17 + wy * 31 + b * 53); + if (litSeed % 5 == 0) { + Color winCol = Color{ 255, 230, 130, 255 }; + if (litSeed % 3 == 0) winCol = Color{ 140, 230, 255, 255 }; + else if (litSeed % 7 == 0) winCol = Color{ 255, 120, 160, 255 }; + ImageDrawRectangle(&img, wx, wy, 3, 4, winCol); + } + } + } + } + + // Traffic streaks + ImageDrawLine(&img, 0, h - 35, w, h - 25, Color{ 255, 140, 0, 120 }); + ImageDrawLine(&img, 0, h - 33, w, h - 23, Color{ 0, 230, 255, 100 }); + ImageDrawLine(&img, 0, h - 18, w, h - 12, Color{ 255, 50, 80, 140 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Brushed Steel & Hazard Stripe Cyber Gateway (256x256) +Texture2D createCyberGateTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 28, 35, 48, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + int metalNoise = ((y * 11 + (x ^ y) * 7) % 9) - 4; + + Color c = Color{ + (unsigned char)std::clamp(32 + metalNoise, 0, 255), + (unsigned char)std::clamp(38 + metalNoise, 0, 255), + (unsigned char)std::clamp(50 + metalNoise, 0, 255), + 255 + }; + + int borderDist = std::min({ x, w - 1 - x, y, h - 1 - y }); + if (borderDist < 20) { + bool isYellow = (((x + y) / 14) % 2 == 0); + if (isYellow) { + c = Color{ 240, 195, 25, 255 }; + } else { + c = Color{ 22, 22, 26, 255 }; + } + } else if (borderDist >= 20 && borderDist <= 23) { + c = Color{ 12, 16, 24, 255 }; + } else if (borderDist == 26) { + if ((x % 24 == 12 && (y <= 28 || y >= h - 28)) || + (y % 24 == 12 && (x <= 28 || x >= w - 28))) { + c = Color{ 190, 205, 225, 255 }; + } + } + + pixels[y * w + x] = c; + } + } + + ImageDrawRectangle(&img, 32, h / 2 - 12, w - 64, 24, Color{ 20, 26, 38, 255 }); + ImageDrawRectangleLines(&img, Rectangle{ 32.0f, h / 2.0f - 12.0f, (float)(w - 64), 24.0f }, 2, Color{ 0, 180, 220, 200 }); + + ImageDrawText(&img, "NEURAL DIVER GATEWAY", 46, h / 2 - 8, 12, Color{ 0, 240, 255, 255 }); + ImageDrawText(&img, "CYBERSPACE ICE LEVEL 1+", 54, 40, 10, Color{ 240, 195, 25, 255 }); + ImageDrawText(&img, "AUTHORIZED RUNNERS ONLY", 50, h - 50, 10, Color{ 220, 50, 60, 255 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: High-Tech Gunmetal Dungeon Wall (256x256) +Texture2D createDungeonWallTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 18, 24, 36, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + int n = ((x * 13 + y * 29) % 17) - 8; + int r = std::clamp(22 + n, 0, 255); + int g = std::clamp(28 + n, 0, 255); + int b = std::clamp(42 + n, 0, 255); + Color c = Color{ (unsigned char)r, (unsigned char)g, (unsigned char)b, 255 }; + + // Panel bevel seams + if (x < 4 || x >= w - 4 || y < 4 || y >= h - 4 || + y == 85 || y == 86 || y == 170 || y == 171 || + (x == 128 && (y < 85 || y > 170))) { + c = Color{ 8, 12, 18, 255 }; + } + + // Hex bolts in corners + for (int bx : { 16, w - 16 }) { + for (int by : { 16, 96, 182, h - 16 }) { + if (std::abs(x - bx) <= 3 && std::abs(y - by) <= 3) { + c = Color{ 130, 160, 200, 255 }; + } + } + } + + // Vertical glowing cyan data fiber conduit + if (x >= 124 && x <= 132) { + int dist = std::abs(x - 128); + if (dist <= 1) { + c = Color{ 0, 240, 255, 255 }; + } else if (dist <= 3) { + c = Color{ 0, 160, 220, 255 }; + } else { + c = Color{ 0, 80, 140, 255 }; + } + } + + // Hazard warning stripes at bottom + if (y >= h - 22 && y < h - 4) { + bool isYellow = (((x + y) / 10) % 2 == 0); + c = isYellow ? Color{ 230, 180, 25, 255 } : Color{ 15, 18, 24, 255 }; + } + + // Server status LEDs + if (x >= 40 && x <= 85 && y >= 110 && y <= 145) { + if (x % 12 < 4 && y % 10 < 4) { + c = ((x + y) % 3 == 0) ? CyberColors::NeonGreen : + (((x + y) % 3 == 1) ? CyberColors::NeonCyan : CyberColors::NeonOrange); + } else { + c = Color{ 12, 16, 24, 255 }; + } + } + + pixels[y * w + x] = c; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Industrial Tech Floor Grating (256x256) +Texture2D createDungeonFloorTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 12, 16, 24, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + bool isMeshLine = (((x + y) % 16 == 0) || ((x - y + 512) % 16 == 0)); + bool isGrateHole = (((x + y) % 16 >= 6 && (x + y) % 16 <= 10) && + ((x - y + 512) % 16 >= 6 && (x - y + 512) % 16 <= 10)); + + Color c = Color{ 20, 28, 40, 255 }; + + if (isMeshLine) { + c = Color{ 70, 90, 120, 255 }; + } else if (isGrateHole) { + bool isCircuit = (x % 64 < 6 || y % 64 < 6); + c = isCircuit ? Color{ 0, 200, 240, 255 } : Color{ 6, 8, 14, 255 }; + } + + if (x < 6 || x >= w - 6 || y < 6 || y >= h - 6) { + c = Color{ 35, 45, 60, 255 }; + if ((x % 32 < 4) && (y % 32 < 4)) { + c = Color{ 140, 165, 195, 255 }; + } + } + + pixels[y * w + x] = c; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Industrial Grid Ceiling with Neon Fixture (256x256) +Texture2D createDungeonCeilTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 10, 14, 20, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + Color c = Color{ 12, 16, 24, 255 }; + + if (x % 64 < 8 || y % 64 < 8) { + c = Color{ 30, 40, 55, 255 }; + } + + if (x >= 96 && x <= 160 && y >= 32 && y <= 224) { + int distFromCenter = std::abs(x - 128); + if (distFromCenter <= 12) { + c = Color{ 245, 252, 255, 255 }; + } else if (distFromCenter <= 22) { + c = Color{ 0, 220, 255, 255 }; + } else { + c = Color{ 0, 80, 140, 255 }; + } + } + + pixels[y * w + x] = c; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Armored Bulkhead Sliding Door (256x256) +Texture2D createBulkheadDoorTexture() { + const int w = 256; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 22, 30, 44, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + Color c = Color{ 26, 36, 52, 255 }; + + if (x < 6 || x >= w - 6 || y < 6 || y >= h - 6 || std::abs(x - 128) <= 2) { + c = Color{ 12, 16, 24, 255 }; + } + + if ((y >= 8 && y <= 32) || (y >= h - 34 && y <= h - 10)) { + bool isYellow = (((x + y) / 14) % 2 == 0); + c = isYellow ? Color{ 235, 185, 20, 255 } : Color{ 18, 20, 26, 255 }; + } + + if (x == 50 || x == 51 || x == 205 || x == 206) { + c = Color{ 60, 80, 110, 255 }; + } + + if (x >= 88 && x <= 168 && y >= 96 && y <= 160) { + int dx = std::abs(x - 128); + int dy = std::abs(y - 128); + if (dx <= 32 && dy <= 24) { + c = Color{ 10, 14, 20, 255 }; + if (dx <= 20 && dy <= 12) { + c = Color{ 255, 60, 40, 255 }; + } + } + } + + pixels[y * w + x] = c; + } + } + + ImageDrawText(&img, "RESTRICTED ICE", 70, 44, 12, Color{ 0, 220, 255, 255 }); + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +// Procedural generator: Structural Door Frame (128x256) +Texture2D createDoorFrameTexture() { + const int w = 128; + const int h = 256; + Image img = GenImageColor(w, h, Color{ 30, 40, 58, 255 }); + Color* pixels = (Color*)img.data; + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + Color c = Color{ 28, 38, 54, 255 }; + + if (x < 6 || x >= w - 6) { + c = Color{ 14, 18, 26, 255 }; + } + + if (x >= 40 && x <= 88) { + c = Color{ 55, 75, 100, 255 }; + if (x >= 56 && x <= 72) { + c = Color{ 160, 185, 215, 255 }; + } + } + + if (x >= w - 18 && x <= w - 6) { + bool isYellow = (((x + y) / 10) % 2 == 0); + c = isYellow ? Color{ 230, 180, 25, 255 } : Color{ 16, 20, 28, 255 }; + } + + pixels[y * w + x] = c; + } + } + + Texture2D tex = LoadTextureFromImage(img); + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + UnloadImage(img); + return tex; +} + +} // anonymous namespace + +CyberRenderer::CyberRenderer() = default; + +CyberRenderer::~CyberRenderer() { + cleanup(); +} + +void CyberRenderer::init() { + m_camera.position = { 0.0f, 0.8f, 0.0f }; + m_camera.target = { 0.0f, 0.8f, -1.0f }; + m_camera.up = { 0.0f, 1.0f, 0.0f }; + m_camera.fovy = 70.0f; + m_camera.projection = CAMERA_PERSPECTIVE; + + // 1. Offscreen Render Target for 3D Post-Processing + m_renderTarget = LoadRenderTexture(GetScreenWidth(), GetScreenHeight()); + SetTextureFilter(m_renderTarget.texture, TEXTURE_FILTER_BILINEAR); + + // 2. Post-Processing GLSL Shader + std::vector> shaderPaths = { + { "assets/shaders/postprocess.vs", "assets/shaders/postprocess.fs" }, + { "../assets/shaders/postprocess.vs", "../assets/shaders/postprocess.fs" } + }; + for (const auto& sp : shaderPaths) { + if (FileExists(sp.first.c_str()) && FileExists(sp.second.c_str())) { + m_shaderPostProcess = LoadShader(sp.first.c_str(), sp.second.c_str()); + if (m_shaderPostProcess.id != 0) { + m_locTime = GetShaderLocation(m_shaderPostProcess, "u_time"); + m_locGlitch = GetShaderLocation(m_shaderPostProcess, "u_glitchIntensity"); + m_locRes = GetShaderLocation(m_shaderPostProcess, "u_resolution"); + m_postProcessLoaded = true; + break; + } + } + } + + generateTextures(); +} + +void CyberRenderer::cleanup() { + if (m_postProcessLoaded) { + UnloadShader(m_shaderPostProcess); + UnloadRenderTexture(m_renderTarget); + m_postProcessLoaded = false; + } + unloadTextures(); +} + +void CyberRenderer::generateTextures() { + if (m_texturesLoaded) return; + + m_texDungeonWall = createDungeonWallTexture(); + m_texDungeonFloor = createDungeonFloorTexture(); + m_texDungeonCeil = createDungeonCeilTexture(); + m_texDungeonDoor = createBulkheadDoorTexture(); + m_texDungeonDoorFrame = createDoorFrameTexture(); + + m_texParquet = createParquetTexture(); + m_texRug = createRugTexture(); + m_texPlasterWall = createWallTexture(); + m_texPoster = createPosterTexture(); + m_texDeskWood = createDeskWoodTexture(); + m_texMonitorCode = createMonitorCodeTexture(); + m_texMonitorRadar = createMonitorRadarTexture(); + m_texBedQuilt = createBedQuiltTexture(); + m_texCityNight = createCityNightTexture(); + m_texCyberGate = createCyberGateTexture(); + + m_texturesLoaded = true; +} + +void CyberRenderer::unloadTextures() { + if (!m_texturesLoaded) return; + + UnloadTexture(m_texDungeonWall); + UnloadTexture(m_texDungeonFloor); + UnloadTexture(m_texDungeonCeil); + UnloadTexture(m_texDungeonDoor); + UnloadTexture(m_texDungeonDoorFrame); + + UnloadTexture(m_texParquet); + UnloadTexture(m_texRug); + UnloadTexture(m_texPlasterWall); + UnloadTexture(m_texPoster); + UnloadTexture(m_texDeskWood); + UnloadTexture(m_texMonitorCode); + UnloadTexture(m_texMonitorRadar); + UnloadTexture(m_texBedQuilt); + UnloadTexture(m_texCityNight); + UnloadTexture(m_texCyberGate); + + m_texturesLoaded = false; +} + +void CyberRenderer::beginPostProcess() { + if (m_postProcessLoaded) { + BeginTextureMode(m_renderTarget); + ClearBackground(Color{ 4, 6, 12, 255 }); + } +} + +void CyberRenderer::endPostProcess(float animTime) { + if (m_postProcessLoaded) { + EndTextureMode(); + + float dt = GetFrameTime(); + m_glitchIntensity = std::max(0.0f, m_glitchIntensity - dt * 2.5f); + + SetShaderValue(m_shaderPostProcess, m_locTime, &animTime, SHADER_UNIFORM_FLOAT); + SetShaderValue(m_shaderPostProcess, m_locGlitch, &m_glitchIntensity, SHADER_UNIFORM_FLOAT); + float res[2] = { (float)GetScreenWidth(), (float)GetScreenHeight() }; + SetShaderValue(m_shaderPostProcess, m_locRes, res, SHADER_UNIFORM_VEC2); + + BeginShaderMode(m_shaderPostProcess); + DrawTextureRec(m_renderTarget.texture, + Rectangle{ 0.0f, 0.0f, (float)m_renderTarget.texture.width, -(float)m_renderTarget.texture.height }, + Vector2{ 0.0f, 0.0f }, WHITE); + EndShaderMode(); + } +} + +void CyberRenderer::triggerGlitch(float intensity) { + m_glitchIntensity = std::max(m_glitchIntensity, intensity); +} + +void CyberRenderer::renderCyberDrone(Vector3 pos, Color coreColor, float scale, float animTime, bool isHit, bool isAttacking) { + float bob = std::sin(animTime * 3.5f) * 0.12f; + Vector3 basePos = { pos.x, pos.y + bob, pos.z }; + + if (isHit) { + float shakeX = ((std::rand() % 100) / 100.0f - 0.5f) * 0.18f; + float shakeY = ((std::rand() % 100) / 100.0f - 0.5f) * 0.18f; + basePos.x += shakeX; + basePos.y += shakeY; + } + + // 1. Armored Faceted Chassis + DrawSphere(basePos, 0.42f * scale, Color{ 22, 28, 42, 255 }); + DrawSphereWires(basePos, 0.44f * scale, 8, 8, Color{ 45, 60, 90, 255 }); + + // 2. Glowing Elemental Core / Reactor + float pulse = 0.5f + 0.5f * std::sin(animTime * (isAttacking ? 14.0f : 5.0f)); + Color pulseCol = isHit ? WHITE : (isAttacking ? RED : coreColor); + DrawSphere(basePos, 0.28f * scale, pulseCol); + + // 3. Forward Sensory Laser Visor + Vector3 visorPos = { basePos.x, basePos.y + 0.05f * scale, basePos.z - 0.38f * scale }; + DrawCube(visorPos, 0.38f * scale, 0.12f * scale, 0.12f * scale, Color{ 14, 18, 26, 255 }); + DrawCube(visorPos, 0.34f * scale, 0.08f * scale, 0.14f * scale, isAttacking ? RED : coreColor); + DrawCubeWires(visorPos, 0.36f * scale, 0.10f * scale, 0.14f * scale, WHITE); + + // 4. Orbiting Deflection Shields (4 plates rotating around core) + float rot = animTime * 1.6f; + for (int i = 0; i < 4; ++i) { + float angle = rot + i * (3.14159265f * 0.5f); + float dist = 0.68f * scale; + Vector3 shieldPos = { + basePos.x + std::cos(angle) * dist, + basePos.y + std::sin(angle * 2.0f) * 0.10f * scale, + basePos.z + std::sin(angle) * dist + }; + DrawCube(shieldPos, 0.18f * scale, 0.38f * scale, 0.06f * scale, Color{ 30, 42, 62, 255 }); + DrawCubeWires(shieldPos, 0.19f * scale, 0.39f * scale, 0.07f * scale, coreColor); + } + + // 5. Plasma Thruster & Exhaust Plume + Vector3 nozzlePos = { basePos.x, basePos.y - 0.40f * scale, basePos.z }; + DrawCylinder(nozzlePos, 0.14f * scale, 0.18f * scale, 0.15f * scale, 8, Color{ 35, 45, 60, 255 }); + float flameH = 0.25f + pulse * 0.15f; + Vector3 flamePos = { basePos.x, basePos.y - (0.45f + flameH * 0.5f) * scale, basePos.z }; + DrawCylinder(flamePos, 0.10f * scale, 0.01f * scale, flameH * scale, 8, CyberColors::NeonCyan); + DrawSphere({ basePos.x, basePos.y - 0.45f * scale, basePos.z }, 0.08f * scale, WHITE); +} + +void CyberRenderer::render3D(const Dungeon& dungeon, const Player& player, float animTime) { + if (!m_texturesLoaded) { + generateTextures(); + } + + beginPostProcess(); + + Vector3 eye = player.getVisualPosition(); + m_camera.position = eye; + + float rad = player.getVisualYaw() * (3.14159265f / 180.0f); + float dirX = std::sin(rad); + float dirZ = -std::cos(rad); + + m_camera.target = { eye.x + dirX, eye.y, eye.z + dirZ }; + + BeginMode3D(m_camera); + + const float tileSize = 2.0f; + const float wallHeight = 2.0f; + + int pX = player.getGridX(); + int pY = player.getGridY(); + int renderDist = 7; + + int minX = std::max(0, pX - renderDist); + int maxX = std::min(dungeon.getWidth() - 1, pX + renderDist); + int minY = std::max(0, pY - renderDist); + int maxY = std::min(dungeon.getHeight() - 1, pY + renderDist); + + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + Vector3 center = { x * tileSize, 0.0f, y * tileSize }; + TileType tile = dungeon.getTile(x, y); + + // Atmospheric depth fog + float dist = std::sqrt(static_cast((x - pX) * (x - pX) + (y - pY) * (y - pY))); + float fog = std::clamp((dist - 1.2f) / (renderDist - 1.2f), 0.0f, 1.0f); + float b = 1.0f - fog * 0.85f; + Color tileTint = Color{ static_cast(255 * b), static_cast(255 * b), static_cast(255 * b), 255 }; + + if (tile == TileType::WALL) { + Vector3 wallCenter = { center.x, wallHeight * 0.5f, center.z }; + Vector3 wallSize = { tileSize, wallHeight, tileSize }; + + drawTexturedBox(m_texDungeonWall, wallCenter, wallSize, 1.0f, 1.0f, tileTint); + + Vector3 accentCenter = { center.x, wallHeight * 0.5f, center.z }; + DrawCubeWires(accentCenter, wallSize.x * 1.008f, 0.06f, wallSize.z * 1.008f, + Color{ 0, static_cast(220 * b), static_cast(255 * b), 255 }); + } else { + // Textured Floor + drawTexturedQuad(m_texDungeonFloor, + { center.x - tileSize * 0.5f, -0.01f, center.z - tileSize * 0.5f }, + { center.x - tileSize * 0.5f, -0.01f, center.z + tileSize * 0.5f }, + { center.x + tileSize * 0.5f, -0.01f, center.z + tileSize * 0.5f }, + { center.x + tileSize * 0.5f, -0.01f, center.z - tileSize * 0.5f }, + 1.0f, 1.0f, tileTint); + + // Textured Ceiling + drawTexturedQuad(m_texDungeonCeil, + { center.x + tileSize * 0.5f, wallHeight, center.z - tileSize * 0.5f }, + { center.x + tileSize * 0.5f, wallHeight, center.z + tileSize * 0.5f }, + { center.x - tileSize * 0.5f, wallHeight, center.z + tileSize * 0.5f }, + { center.x - tileSize * 0.5f, wallHeight, center.z - tileSize * 0.5f }, + 1.0f, 1.0f, tileTint); + + if (tile == TileType::DOOR_LOCKED || tile == TileType::DOOR_UNLOCKED) { + bool locked = (tile == TileType::DOOR_LOCKED); + bool wallLeftRight = (dungeon.getTile(x - 1, y) == TileType::WALL && dungeon.getTile(x + 1, y) == TileType::WALL); + + if (wallLeftRight) { + Vector3 leftPost = { center.x - 0.9f, wallHeight * 0.5f, center.z }; + drawTexturedBox(m_texDungeonDoorFrame, leftPost, { 0.2f, wallHeight, 0.35f }, 1.0f, 1.0f, tileTint); + + Vector3 rightPost = { center.x + 0.9f, wallHeight * 0.5f, center.z }; + drawTexturedBox(m_texDungeonDoorFrame, rightPost, { 0.2f, wallHeight, 0.35f }, 1.0f, 1.0f, tileTint); + + Vector3 topLintel = { center.x, wallHeight - 0.1f, center.z }; + drawTexturedBox(m_texDungeonDoorFrame, topLintel, { 1.8f, 0.2f, 0.35f }, 2.0f, 1.0f, tileTint); + + if (locked) { + Vector3 doorBody = { center.x, (wallHeight - 0.2f) * 0.5f, center.z }; + drawTexturedBox(m_texDungeonDoor, doorBody, { 1.6f, wallHeight - 0.2f, 0.2f }, 1.0f, 1.0f, tileTint); + + float pulse = 0.5f + 0.5f * std::sin(animTime * 6.0f); + Color lockCol = (pulse > 0.5f) ? CyberColors::NeonOrange : RED; + Vector3 lockCore = { center.x, 0.9f, center.z }; + DrawCube(lockCore, 0.4f, 0.25f, 0.24f, lockCol); + DrawCubeWires(lockCore, 0.42f, 0.27f, 0.26f, CyberColors::NeonYellow); + } else { + Vector3 leftSlab = { center.x - 0.7f, (wallHeight - 0.2f) * 0.5f, center.z }; + Vector3 rightSlab = { center.x + 0.7f, (wallHeight - 0.2f) * 0.5f, center.z }; + drawTexturedBox(m_texDungeonDoor, leftSlab, { 0.25f, wallHeight - 0.2f, 0.18f }, 0.5f, 1.0f, tileTint); + drawTexturedBox(m_texDungeonDoor, rightSlab, { 0.25f, wallHeight - 0.2f, 0.18f }, 0.5f, 1.0f, tileTint); + } + } else { + Vector3 northPost = { center.x, wallHeight * 0.5f, center.z - 0.9f }; + drawTexturedBox(m_texDungeonDoorFrame, northPost, { 0.35f, wallHeight, 0.2f }, 1.0f, 1.0f, tileTint); + + Vector3 southPost = { center.x, wallHeight * 0.5f, center.z + 0.9f }; + drawTexturedBox(m_texDungeonDoorFrame, southPost, { 0.35f, wallHeight, 0.2f }, 1.0f, 1.0f, tileTint); + + Vector3 topLintel = { center.x, wallHeight - 0.1f, center.z }; + drawTexturedBox(m_texDungeonDoorFrame, topLintel, { 0.35f, 0.2f, 1.8f }, 1.0f, 2.0f, tileTint); + + if (locked) { + Vector3 doorBody = { center.x, (wallHeight - 0.2f) * 0.5f, center.z }; + drawTexturedBox(m_texDungeonDoor, doorBody, { 0.2f, wallHeight - 0.2f, 1.6f }, 1.0f, 1.0f, tileTint); + + float pulse = 0.5f + 0.5f * std::sin(animTime * 6.0f); + Color lockCol = (pulse > 0.5f) ? CyberColors::NeonOrange : RED; + Vector3 lockCore = { center.x, 0.9f, center.z }; + DrawCube(lockCore, 0.24f, 0.25f, 0.4f, lockCol); + DrawCubeWires(lockCore, 0.26f, 0.27f, 0.42f, CyberColors::NeonYellow); + } else { + Vector3 northSlab = { center.x, (wallHeight - 0.2f) * 0.5f, center.z - 0.7f }; + Vector3 southSlab = { center.x, (wallHeight - 0.2f) * 0.5f, center.z + 0.7f }; + drawTexturedBox(m_texDungeonDoor, northSlab, { 0.18f, wallHeight - 0.2f, 0.25f }, 0.5f, 1.0f, tileTint); + drawTexturedBox(m_texDungeonDoor, southSlab, { 0.18f, wallHeight - 0.2f, 0.25f }, 0.5f, 1.0f, tileTint); + } + } + } else if (tile == TileType::LOOT_CACHE) { + float floatY = 0.6f + std::sin(animTime * 3.0f + x) * 0.12f; + Vector3 lootPos = { center.x, floatY, center.z }; + DrawCubeWires(lootPos, 0.6f, 0.6f, 0.6f, CyberColors::NeonYellow); + DrawCube(lootPos, 0.35f, 0.35f, 0.35f, Color{ 255, 230, 0, 180 }); + } else if (tile == TileType::EXIT_PORTAL) { + float pulse = 0.5f + 0.5f * std::sin(animTime * 4.0f); + + // 1. Raised Octagonal Platform Dais with hazard glow + Vector3 basePos = { center.x, 0.10f, center.z }; + DrawCylinder(basePos, 0.95f, 1.0f, 0.20f, 8, Color{ 22, 30, 44, 255 }); + DrawCylinderWires(basePos, 0.96f, 1.01f, 0.20f, 8, CyberColors::NeonCyan); + DrawCircle3D({ center.x, 0.21f, center.z }, 0.90f, { 1.0f, 0.0f, 0.0f }, 90.0f, Color{ 0, 240, 255, 120 }); + + // 2. Four Corner Energy Emitter Pylons with glowing caps + float pylonDist = 0.72f; + Vector3 pylons[4] = { + { center.x - pylonDist, 0.42f, center.z - pylonDist }, + { center.x + pylonDist, 0.42f, center.z - pylonDist }, + { center.x - pylonDist, 0.42f, center.z + pylonDist }, + { center.x + pylonDist, 0.42f, center.z + pylonDist } + }; + for (int p = 0; p < 4; ++p) { + DrawCylinder(pylons[p], 0.06f, 0.08f, 0.65f, 8, Color{ 36, 46, 66, 255 }); + DrawCylinderWires(pylons[p], 0.065f, 0.085f, 0.65f, 8, CyberColors::NeonCyan); + DrawSphere({ pylons[p].x, 0.76f, pylons[p].z }, 0.08f, Color{ 0, 255, 200, 255 }); + } + + // 3. Three Concentric Levitating Counter-Rotating Neon Rings + float ring1Y = 0.42f + 0.05f * std::sin(animTime * 3.0f); + float ring2Y = 0.88f + 0.07f * std::sin(animTime * 3.5f + 1.2f); + float ring3Y = 1.34f + 0.05f * std::sin(animTime * 2.8f + 2.4f); + + DrawCircle3D({ center.x, ring1Y, center.z }, 0.75f, { 1.0f, 0.0f, 0.0f }, 90.0f, + Color{ 0, 255, 180, static_cast(160 + pulse * 95) }); + DrawCircle3D({ center.x, ring2Y, center.z }, 0.55f, { 1.0f, 0.0f, 0.0f }, 90.0f, + Color{ 0, 240, 255, static_cast(180 + pulse * 75) }); + DrawCircle3D({ center.x, ring3Y, center.z }, 0.35f, { 1.0f, 0.0f, 0.0f }, 90.0f, + Color{ 180, 0, 255, static_cast(160 + pulse * 95) }); + + // 4. Central Swirling Singularity Core + Vector3 corePos = { center.x, 0.85f + pulse * 0.05f, center.z }; + DrawCylinder(corePos, 0.20f + pulse * 0.03f, 0.20f + pulse * 0.03f, 1.3f, 16, + Color{ 0, 230, 255, static_cast(130 + pulse * 80) }); + DrawCylinderWires(corePos, 0.23f, 0.23f, 1.32f, 8, CyberColors::NeonGreen); + DrawSphere(corePos, 0.18f + pulse * 0.04f, Color{ 255, 255, 255, 220 }); + + // 5. Overhead Holographic Beacon Frame + Vector3 signPos = { center.x, wallHeight - 0.20f, center.z }; + DrawCube(signPos, 1.3f, 0.18f, 0.06f, Color{ 10, 16, 26, 240 }); + DrawCubeWires(signPos, 1.32f, 0.20f, 0.08f, CyberColors::NeonCyan); + } + } + } + } + + // Render Corridor Patrol Enemies as 3D Cyber-Drones + auto& enemies = const_cast(dungeon).getEnemies(); + for (const auto& me : enemies) { + if (!me.alive) continue; + if (std::abs(me.x - pX) > renderDist || std::abs(me.y - pY) > renderDist) continue; + + Vector3 ePos = { me.x * tileSize, 0.9f, me.y * tileSize }; + renderCyberDrone(ePos, me.enemy.getColor(), 1.0f, animTime); + } + + EndMode3D(); + + endPostProcess(animTime); +} + +void CyberRenderer::renderCombat3D(const Dungeon& dungeon, const Player& player, const CombatSystem& combat, float animTime) { + if (!m_texturesLoaded) { + generateTextures(); + } + + beginPostProcess(); + + Vector3 eye = player.getVisualPosition(); + m_camera.position = eye; + + float rad = player.getVisualYaw() * (3.14159265f / 180.0f); + float dirX = std::sin(rad); + float dirZ = -std::cos(rad); + + m_camera.target = { eye.x + dirX, eye.y, eye.z + dirZ }; + + BeginMode3D(m_camera); + + const float tileSize = 2.0f; + const float wallHeight = 2.0f; + int pX = player.getGridX(); + int pY = player.getGridY(); + int renderDist = 5; + + int minX = std::max(0, pX - renderDist); + int maxX = std::min(dungeon.getWidth() - 1, pX + renderDist); + int minY = std::max(0, pY - renderDist); + int maxY = std::min(dungeon.getHeight() - 1, pY + renderDist); + + // 1. Render surrounding corridor geometry + for (int y = minY; y <= maxY; ++y) { + for (int x = minX; x <= maxX; ++x) { + Vector3 center = { x * tileSize, 0.0f, y * tileSize }; + TileType tile = dungeon.getTile(x, y); + + float dist = std::sqrt(static_cast((x - pX) * (x - pX) + (y - pY) * (y - pY))); + float fog = std::clamp((dist - 1.0f) / 4.0f, 0.0f, 1.0f); + float b = 1.0f - fog * 0.85f; + Color tileTint = Color{ static_cast(255 * b), static_cast(255 * b), static_cast(255 * b), 255 }; + + if (tile == TileType::WALL) { + Vector3 wallCenter = { center.x, wallHeight * 0.5f, center.z }; + drawTexturedBox(m_texDungeonWall, wallCenter, { tileSize, wallHeight, tileSize }, 1.0f, 1.0f, tileTint); + } else { + drawTexturedQuad(m_texDungeonFloor, + { center.x - tileSize * 0.5f, -0.01f, center.z - tileSize * 0.5f }, + { center.x - tileSize * 0.5f, -0.01f, center.z + tileSize * 0.5f }, + { center.x + tileSize * 0.5f, -0.01f, center.z + tileSize * 0.5f }, + { center.x + tileSize * 0.5f, -0.01f, center.z - tileSize * 0.5f }, + 1.0f, 1.0f, tileTint); + + drawTexturedQuad(m_texDungeonCeil, + { center.x + tileSize * 0.5f, wallHeight, center.z - tileSize * 0.5f }, + { center.x + tileSize * 0.5f, wallHeight, center.z + tileSize * 0.5f }, + { center.x - tileSize * 0.5f, wallHeight, center.z + tileSize * 0.5f }, + { center.x - tileSize * 0.5f, wallHeight, center.z - tileSize * 0.5f }, + 1.0f, 1.0f, tileTint); + } + } + } + + // 2. STAGE ENEMY 3D DRONE IN FRONT OF PLAYER + const auto& enemy = combat.getEnemy(); + if (enemy && enemy->isAlive()) { + Vector3 combatDronePos = { + eye.x + dirX * 2.5f, + eye.y + 0.02f, + eye.z + dirZ * 2.5f + }; + + bool isHit = (combat.getPhase() == CombatPhase::PLAYER_ACTION_ANIM); + bool isAttacking = (combat.getPhase() == CombatPhase::ENEMY_ACTION_ANIM); + + renderCyberDrone(combatDronePos, enemy->getColor(), 1.5f, animTime, isHit, isAttacking); + } + + EndMode3D(); + + endPostProcess(animTime); +} + +void CyberRenderer::renderHub(const HubRoom& hub, const Player& player, float animTime) { + if (!m_texturesLoaded) { + generateTextures(); + } + + Vector3 eye = player.getVisualPosition(); + m_camera.position = eye; + + float rad = player.getVisualYaw() * (3.14159265f / 180.0f); + float dirX = std::sin(rad); + float dirZ = -std::cos(rad); + + m_camera.target = { eye.x + dirX, eye.y, eye.z + dirZ }; + + BeginMode3D(m_camera); + + const float tileSize = 2.0f; + const float roomHeight = 2.8f; + const float minCoord = 0.6f; + const float maxCoord = 11.4f; + + // 1. FLOOR: Warm Oak Parquet Floor (snug room = 10.8m x 10.8m) + drawTexturedQuad(m_texParquet, + { minCoord, 0.0f, minCoord }, + { minCoord, 0.0f, maxCoord }, + { maxCoord, 0.0f, maxCoord }, + { maxCoord, 0.0f, minCoord }, + 5.5f, 5.5f); + + // 2. LUXURY RUG: Centered in room (6.0m, 6.0m), size 4.0m x 4.0m + Vector3 rugCenter = { 6.0f, 0.02f, 6.0f }; + float rw = 2.0f; + float rl = 2.0f; + drawTexturedQuad(m_texRug, + { rugCenter.x - rw, 0.02f, rugCenter.z - rl }, + { rugCenter.x - rw, 0.02f, rugCenter.z + rl }, + { rugCenter.x + rw, 0.02f, rugCenter.z + rl }, + { rugCenter.x + rw, 0.02f, rugCenter.z - rl }, + 1.0f, 1.0f); + + // 3. CEILING: Solid ceiling covering the entire room and walls + DrawCube({ 6.0f, roomHeight + 0.10f, 6.0f }, 11.6f, 0.20f, 11.6f, Color{ 32, 36, 44, 255 }); + DrawCube({ 6.0f, roomHeight - 0.02f, 6.0f }, 2.4f, 0.04f, 2.4f, Color{ 255, 245, 220, 255 }); // Warm LED ceiling lamp + DrawCubeWires({ 6.0f, roomHeight - 0.02f, 6.0f }, 2.42f, 0.04f, 2.42f, Color{ 180, 140, 80, 255 }); // Brass fixture + + // 4. SURROUNDING WALLS (Solid textured boxes with 0.24m thickness) + const float wallThick = 0.24f; + const float halfThick = wallThick * 0.5f; + + // South Wall (Z = maxCoord = 11.4f) + drawTexturedBox(m_texPlasterWall, + { 6.0f, roomHeight * 0.5f, maxCoord + halfThick }, + { 10.8f + wallThick * 2.0f, roomHeight, wallThick }, + 5.5f, 1.4f); + + // West Wall (X = minCoord = 0.6f) + drawTexturedBox(m_texPlasterWall, + { minCoord - halfThick, roomHeight * 0.5f, 6.0f }, + { wallThick, roomHeight, 10.8f + wallThick * 2.0f }, + 5.5f, 1.4f); + + // North Wall: Split around Gateway (Gateway is at X = 6.0f, width 2.6f: X in [4.7, 7.3]) + // Left Segment: X from minCoord (0.6f) to 4.7f => length 4.1f, center 2.65f + drawTexturedBox(m_texPlasterWall, + { 2.65f, roomHeight * 0.5f, minCoord - halfThick }, + { 4.1f + wallThick, roomHeight, wallThick }, + 2.1f, 1.4f); + // Right Segment: X from 7.3f to maxCoord (11.4f) => length 4.1f, center 9.35f + drawTexturedBox(m_texPlasterWall, + { 9.35f, roomHeight * 0.5f, minCoord - halfThick }, + { 4.1f + wallThick, roomHeight, wallThick }, + 2.1f, 1.4f); + // Lintel above Gateway: X in [4.7f, 7.3f], Y from 2.4f to roomHeight (0.4f height) + drawTexturedBox(m_texPlasterWall, + { 6.0f, 2.6f, minCoord - halfThick }, + { 2.6f, 0.4f, wallThick }, + 1.3f, 0.2f); + + // East Wall: Split around Window (Window at Z in [3.6f, 8.4f], Y in [0.8f, 2.4f]) + // North Segment: Z from minCoord (0.6f) to 3.6f => length 3.0f, center 2.1f + drawTexturedBox(m_texPlasterWall, + { maxCoord + halfThick, roomHeight * 0.5f, 2.1f }, + { wallThick, roomHeight, 3.0f + wallThick }, + 1.5f, 1.4f); + // South Segment: Z from 8.4f to maxCoord (11.4f) => length 3.0f, center 9.9f + drawTexturedBox(m_texPlasterWall, + { maxCoord + halfThick, roomHeight * 0.5f, 9.9f }, + { wallThick, roomHeight, 3.0f + wallThick }, + 1.5f, 1.4f); + // Below Window: Z in [3.6f, 8.4f] (length 4.8f), Y from 0.0f to 0.8f (height 0.8f, center 0.4f) + drawTexturedBox(m_texPlasterWall, + { maxCoord + halfThick, 0.4f, 6.0f }, + { wallThick, 0.8f, 4.8f }, + 2.4f, 0.4f); + // Above Window: Z in [3.6f, 8.4f] (length 4.8f), Y from 2.4f to roomHeight (height 0.4f, center 2.6f) + drawTexturedBox(m_texPlasterWall, + { maxCoord + halfThick, 2.6f, 6.0f }, + { wallThick, 0.4f, 4.8f }, + 2.4f, 0.2f); + + // Baseboards along bottom of walls inside room + DrawCube({ 6.0f, 0.08f, maxCoord - 0.04f }, 10.8f, 0.16f, 0.08f, Color{ 75, 48, 30, 255 }); + DrawCube({ minCoord + 0.04f, 0.08f, 6.0f }, 0.08f, 0.16f, 10.8f, Color{ 75, 48, 30, 255 }); + DrawCube({ 2.65f, 0.08f, minCoord + 0.04f }, 4.1f, 0.16f, 0.08f, Color{ 75, 48, 30, 255 }); + DrawCube({ 9.35f, 0.08f, minCoord + 0.04f }, 4.1f, 0.16f, 0.08f, Color{ 75, 48, 30, 255 }); + DrawCube({ maxCoord - 0.04f, 0.08f, 2.1f }, 0.08f, 0.16f, 3.0f, Color{ 75, 48, 30, 255 }); + DrawCube({ maxCoord - 0.04f, 0.08f, 9.9f }, 0.08f, 0.16f, 3.0f, Color{ 75, 48, 30, 255 }); + + // 5. FRAMED SYNTHWAVE POSTER: South Wall (Z = maxCoord - 0.01f, centered at X = 3.8f, Y = 1.55f) + drawTexturedQuad(m_texPoster, + { 3.0f, 2.35f, maxCoord - 0.01f }, + { 3.0f, 0.75f, maxCoord - 0.01f }, + { 4.6f, 0.75f, maxCoord - 0.01f }, + { 4.6f, 2.35f, maxCoord - 0.01f }, + 1.0f, 1.0f); + DrawCubeWires({ 3.8f, 1.55f, maxCoord - 0.005f }, 1.62f, 1.62f, 0.02f, Color{ 20, 20, 25, 255 }); + + // 6. PANORAMIC WINDOW & NIGHT CITY SKYLINE (East Wall, X = maxCoord = 11.4f) + // Step A: Outdoor Megacity Backdrop Quad (rendered first so depth test passes for everything in front) + drawTexturedQuad(m_texCityNight, + { 16.5f, 6.0f, -2.0f }, + { 16.5f, -2.0f, -2.0f }, + { 16.5f, -2.0f, 14.0f }, + { 16.5f, 6.0f, 14.0f }, + 1.0f, 1.0f); + + // Step B: 3D Skyscraper buildings outside window (X = 13.5 .. 14.8) + Vector3 bldPositions[4] = { + { 14.0f, 1.2f, 4.4f }, + { 14.6f, 1.7f, 5.8f }, + { 14.2f, 2.1f, 7.0f }, + { 14.5f, 1.1f, 8.0f } + }; + for (int i = 0; i < 4; ++i) { + float bldH = 3.4f + (i % 3) * 1.2f; + Vector3 bldPos = { bldPositions[i].x, bldH * 0.5f - 0.5f, bldPositions[i].z }; + DrawCube(bldPos, 1.6f, bldH, 1.4f, Color{ 12, 16, 28, 255 }); + DrawCubeWires(bldPos, 1.6f, bldH, 1.4f, (i % 2 == 0) ? CyberColors::NeonCyan : CyberColors::NeonPink); + // Roof antenna beacon + DrawLine3D({ bldPos.x, bldH - 0.5f, bldPos.z }, { bldPos.x, bldH + 0.45f, bldPos.z }, Color{ 140, 150, 170, 255 }); + DrawSphere({ bldPos.x, bldH + 0.48f, bldPos.z }, 0.06f, Color{ 255, 40, 40, 255 }); + } + + // Step C: Window frame, mullions, and wooden sill in the wall opening + DrawCubeWires({ maxCoord, 1.6f, 6.0f }, 0.28f, 1.62f, 4.82f, Color{ 15, 20, 30, 255 }); + DrawCube({ maxCoord, 1.6f, 6.0f }, 0.08f, 1.6f, 0.06f, Color{ 22, 28, 40, 255 }); // Center vertical mullion + DrawCube({ maxCoord, 1.6f, 6.0f }, 0.08f, 0.06f, 4.8f, Color{ 22, 28, 40, 255 }); // Center horizontal mullion + DrawCube({ maxCoord - 0.12f, 0.78f, 6.0f }, 0.32f, 0.06f, 5.0f, Color{ 85, 55, 35, 255 }); // Interior wooden sill + + // Step D: Translucent Glass Reflections (depth mask DISABLED so city behind is NEVER occluded!) + rlDisableDepthMask(); + DrawCube({ maxCoord, 1.6f, 6.0f }, 0.01f, 1.56f, 4.74f, Color{ 0, 160, 230, 20 }); + DrawLine3D({ maxCoord - 0.01f, 2.25f, 4.3f }, { maxCoord - 0.01f, 0.95f, 5.6f }, Color{ 255, 255, 255, 55 }); + DrawLine3D({ maxCoord - 0.01f, 2.15f, 6.5f }, { maxCoord - 0.01f, 0.95f, 7.7f }, Color{ 255, 255, 255, 45 }); + rlEnableDepthMask(); + + // 7. COMPUTER WORKSTATION DESK (West Wall: against X = minCoord = 0.6f, Z = 5.0f) + Vector3 deskPos = { 1.05f, 0.75f, 5.0f }; + Vector3 deskSize = { 0.85f, 0.08f, 2.2f }; + drawTexturedBox(m_texDeskWood, deskPos, deskSize, 1.0f, 2.0f); + + // Metal legs + DrawCube({ 0.70f, 0.36f, 4.0f }, 0.08f, 0.72f, 0.08f, Color{ 40, 44, 52, 255 }); + DrawCube({ 1.40f, 0.36f, 4.0f }, 0.08f, 0.72f, 0.08f, Color{ 40, 44, 52, 255 }); + DrawCube({ 0.70f, 0.36f, 6.0f }, 0.08f, 0.72f, 0.08f, Color{ 40, 44, 52, 255 }); + DrawCube({ 1.40f, 0.36f, 6.0f }, 0.08f, 0.72f, 0.08f, Color{ 40, 44, 52, 255 }); + + // Primary Monitor (IDE code editor): facing East (+X) toward the player + Vector3 mon1Pos = { 0.90f, 1.25f, 4.5f }; + DrawCube(mon1Pos, 0.08f, 0.75f, 0.95f, Color{ 18, 22, 30, 255 }); + DrawCube({ mon1Pos.x, 0.92f, mon1Pos.z }, 0.08f, 0.32f, 0.12f, Color{ 35, 40, 50, 255 }); + DrawCube({ mon1Pos.x + 0.05f, 0.79f, mon1Pos.z }, 0.22f, 0.02f, 0.32f, Color{ 35, 40, 50, 255 }); + + // Primary Monitor Screen Quad with C++ Code + drawTexturedQuad(m_texMonitorCode, + { mon1Pos.x + 0.045f, mon1Pos.y + 0.34f, mon1Pos.z + 0.44f }, + { mon1Pos.x + 0.045f, mon1Pos.y - 0.34f, mon1Pos.z + 0.44f }, + { mon1Pos.x + 0.045f, mon1Pos.y - 0.34f, mon1Pos.z - 0.44f }, + { mon1Pos.x + 0.045f, mon1Pos.y + 0.34f, mon1Pos.z - 0.44f }, + 1.0f, 1.0f); + + // Secondary Monitor (Cyberspace Radar): angled slightly + Vector3 mon2Pos = { 0.92f, 1.25f, 5.5f }; + DrawCube(mon2Pos, 0.08f, 0.75f, 0.85f, Color{ 18, 22, 30, 255 }); + DrawCube({ mon2Pos.x, 0.92f, mon2Pos.z }, 0.08f, 0.32f, 0.12f, Color{ 35, 40, 50, 255 }); + DrawCube({ mon2Pos.x + 0.05f, 0.79f, mon2Pos.z }, 0.22f, 0.02f, 0.32f, Color{ 35, 40, 50, 255 }); + + // Secondary Monitor Screen Quad with Radar + drawTexturedQuad(m_texMonitorRadar, + { mon2Pos.x + 0.045f, mon2Pos.y + 0.34f, mon2Pos.z + 0.40f }, + { mon2Pos.x + 0.045f, mon2Pos.y - 0.34f, mon2Pos.z + 0.40f }, + { mon2Pos.x + 0.045f, mon2Pos.y - 0.34f, mon2Pos.z - 0.40f }, + { mon2Pos.x + 0.045f, mon2Pos.y + 0.34f, mon2Pos.z - 0.40f }, + 1.0f, 1.0f); + + // Keyboard & Mouse + DrawCube({ 1.20f, 0.80f, 4.7f }, 0.30f, 0.02f, 0.70f, Color{ 25, 28, 36, 255 }); + DrawCubeWires({ 1.20f, 0.80f, 4.7f }, 0.30f, 0.02f, 0.70f, Color{ 0, 180, 220, 160 }); + DrawCube({ 1.20f, 0.80f, 5.30f }, 0.14f, 0.025f, 0.10f, Color{ 30, 35, 45, 255 }); + + // PC Tower on floor under desk with pulsing cyan LED + Vector3 pcPos = { 0.95f, 0.35f, 3.6f }; + DrawCube(pcPos, 0.40f, 0.70f, 0.60f, Color{ 14, 18, 26, 255 }); + DrawCubeWires(pcPos, 0.40f, 0.70f, 0.60f, Color{ 35, 45, 60, 255 }); + float ledPulse = 0.5f + 0.5f * std::sin(animTime * 3.0f); + DrawCube({ pcPos.x + 0.21f, pcPos.y + 0.25f, pcPos.z }, 0.02f, 0.03f, 0.30f, + Color{ 0, static_cast(180 + ledPulse * 75), 255, 255 }); + + // 8. BED (South-East corner: X in [8.5f, 11.2f], Z in [8.8f, 11.2f]) + Vector3 bedPos = { 9.8f, 0.30f, 10.0f }; + Vector3 bedSize = { 2.3f, 0.45f, 2.2f }; + DrawCube({ bedPos.x, 0.08f, bedPos.z }, bedSize.x + 0.1f, 0.16f, bedSize.z + 0.1f, Color{ 55, 35, 20, 255 }); + drawTexturedBox(m_texBedQuilt, bedPos, bedSize, 2.0f, 2.0f); + + // Bed Headboard against South wall + DrawCube({ bedPos.x, 0.75f, maxCoord - 0.08f }, bedSize.x + 0.1f, 1.1f, 0.10f, Color{ 55, 35, 20, 255 }); + + // Two soft pillows at head of bed + Vector3 pillow1 = { 9.2f, 0.58f, 10.7f }; + Vector3 pillow2 = { 10.4f, 0.58f, 10.7f }; + DrawCube(pillow1, 0.9f, 0.12f, 0.50f, Color{ 235, 238, 246, 255 }); + DrawCube(pillow2, 0.9f, 0.12f, 0.50f, Color{ 235, 238, 246, 255 }); + + // 9. CYBERSPACE GATEWAY DOOR (North wall at X = 6.0f, Z = minCoord) + Vector3 gateFramePos = { 6.0f, roomHeight * 0.5f, minCoord + 0.05f }; + drawTexturedBox(m_texCyberGate, gateFramePos, { 2.6f, roomHeight, 0.30f }, 1.0f, 1.0f); + + float pulse = 0.5f + 0.5f * std::sin(animTime * 4.0f); + Vector3 portalField = { 6.0f, (roomHeight - 0.4f) * 0.5f, minCoord + 0.06f }; + DrawCube(portalField, 1.8f, roomHeight - 0.4f, 0.08f, Color{ 0, 230, 160, 140 }); + DrawCubeWires(portalField, 1.82f, roomHeight - 0.38f, 0.10f, + Color{ 0, 255, 120, static_cast(180 + pulse * 75) }); + + Vector3 signPos = { 6.0f, roomHeight - 0.22f, minCoord + 0.24f }; + DrawCube(signPos, 1.6f, 0.25f, 0.06f, Color{ 10, 16, 26, 255 }); + DrawCubeWires(signPos, 1.6f, 0.25f, 0.06f, CyberColors::NeonCyan); + + EndMode3D(); +} + +void CyberRenderer::renderRestRoom(const RestRoom& restRoom, const Player& player, float animTime) { + if (!m_texturesLoaded) { + generateTextures(); + } + + beginPostProcess(); + + Vector3 eye = player.getVisualPosition(); + m_camera.position = eye; + + float rad = player.getVisualYaw() * (3.14159265f / 180.0f); + float dirX = std::sin(rad); + float dirZ = -std::cos(rad); + + m_camera.target = { eye.x + dirX, eye.y, eye.z + dirZ }; + + BeginMode3D(m_camera); + + const float roomHeight = 3.2f; + const float minCoord = 0.6f; + const float maxCoord = 11.4f; + + // 1. High-Tech Floor & Ceiling Textures + drawTexturedQuad(m_texDungeonFloor, + { minCoord, -0.01f, minCoord }, + { minCoord, -0.01f, maxCoord }, + { maxCoord, -0.01f, maxCoord }, + { maxCoord, -0.01f, minCoord }, + 5.0f, 5.0f); + + drawTexturedQuad(m_texDungeonCeil, + { maxCoord, roomHeight, minCoord }, + { maxCoord, roomHeight, maxCoord }, + { minCoord, roomHeight, maxCoord }, + { minCoord, roomHeight, minCoord }, + 5.0f, 5.0f); + + // Neon floor grid rings around central sanctuary + for (float r = 1.0f; r <= 4.0f; r += 1.0f) { + DrawCircle3D({ 6.0f, 0.01f, 6.0f }, r, { 1.0f, 0.0f, 0.0f }, 90.0f, Color{ 0, 180, 240, 60 }); + } + + // 2. High-Tech Cyber Walls + // South Wall (Z = maxCoord) + drawTexturedBox(m_texDungeonWall, { 6.0f, roomHeight * 0.5f, maxCoord }, { 11.0f, roomHeight, 0.3f }, 5.5f, 1.0f); + // East Wall (X = maxCoord) + drawTexturedBox(m_texDungeonWall, { maxCoord, roomHeight * 0.5f, 6.0f }, { 0.3f, roomHeight, 11.0f }, 5.5f, 1.0f); + // West Wall (X = minCoord) + drawTexturedBox(m_texDungeonWall, { minCoord, roomHeight * 0.5f, 6.0f }, { 0.3f, roomHeight, 11.0f }, 5.5f, 1.0f); + + // North Wall: Split around Gateway (X = 6.0f, width 2.6f) + drawTexturedBox(m_texDungeonWall, { 2.7f, roomHeight * 0.5f, minCoord }, { 4.2f, roomHeight, 0.3f }, 2.0f, 1.0f); + drawTexturedBox(m_texDungeonWall, { 9.3f, roomHeight * 0.5f, minCoord }, { 4.2f, roomHeight, 0.3f }, 2.0f, 1.0f); + drawTexturedBox(m_texDungeonWall, { 6.0f, roomHeight - 0.3f, minCoord }, { 2.6f, 0.6f, 0.3f }, 1.3f, 0.3f); + + // Corner Energy Pillars + Vector3 pillars[4] = { + { minCoord + 0.5f, roomHeight * 0.5f, minCoord + 0.5f }, + { maxCoord - 0.5f, roomHeight * 0.5f, minCoord + 0.5f }, + { minCoord + 0.5f, roomHeight * 0.5f, maxCoord - 0.5f }, + { maxCoord - 0.5f, roomHeight * 0.5f, maxCoord - 0.5f } + }; + for (int i = 0; i < 4; ++i) { + drawTexturedBox(m_texDungeonWall, pillars[i], { 0.7f, roomHeight, 0.7f }, 1.0f, 1.0f); + DrawCubeWires(pillars[i], 0.72f, roomHeight, 0.72f, CyberColors::NeonCyan); + } + + // 3. Central Nanite Recharge Matrix / Pod at (6.0f, 0.0f, 6.0f) + Vector3 podCenter = { 6.0f, 0.12f, 6.0f }; + DrawCylinder(podCenter, 1.3f, 1.4f, 0.24f, 8, Color{ 22, 32, 50, 255 }); + DrawCylinderWires(podCenter, 1.32f, 1.42f, 0.24f, 8, CyberColors::NeonCyan); + + float pulse = 0.5f + 0.5f * std::sin(animTime * 3.5f); + Color auraColor = restRoom.isRecharged() ? Color{ 0, 255, 140, 140 } : + Color{ 0, static_cast(180 + pulse * 75), 255, static_cast(140 + pulse * 90) }; + + // Fluid Nanite Column + Vector3 fluidCol = { 6.0f, 0.85f, 6.0f }; + DrawCylinder(fluidCol, 0.55f, 0.55f, 1.2f, 16, auraColor); + DrawCylinderWires(fluidCol, 0.58f, 0.58f, 1.22f, 8, CyberColors::NeonGreen); + + // Rising Levitation Rings + DrawCircle3D({ 6.0f, 0.55f + pulse * 0.10f, 6.0f }, 0.85f, { 1.0f, 0.0f, 0.0f }, 90.0f, auraColor); + DrawCircle3D({ 6.0f, 1.05f + pulse * 0.15f, 6.0f }, 0.65f, { 1.0f, 0.0f, 0.0f }, 90.0f, CyberColors::NeonCyan); + DrawSphere({ 6.0f, 0.85f + pulse * 0.10f, 6.0f }, 0.25f, restRoom.isRecharged() ? CyberColors::NeonGreen : WHITE); + + // Diagnostic console on South face of nanite pod (facing player at ny = 4) + Vector3 diagPos = { 6.0f, 0.45f, 7.35f }; + DrawCube(diagPos, 0.6f, 0.5f, 0.2f, Color{ 16, 24, 38, 255 }); + DrawCubeWires(diagPos, 0.62f, 0.52f, 0.22f, CyberColors::NeonCyan); + DrawCube({ diagPos.x, diagPos.y + 0.1f, diagPos.z + 0.08f }, 0.45f, 0.25f, 0.04f, restRoom.isRecharged() ? Color{ 0, 255, 120, 200 } : Color{ 0, 200, 255, 200 }); + + // 4. Save Archive Terminal at West Wall (nx = 1, ny = 3 => X = 1.0f, Z = 6.0f) + Vector3 termPos = { 1.05f, 0.65f, 6.0f }; + DrawCube(termPos, 0.8f, 1.3f, 1.4f, Color{ 20, 28, 44, 255 }); + DrawCubeWires(termPos, 0.82f, 1.32f, 1.42f, CyberColors::NeonCyan); + + // Angled telemetry monitor + Vector3 monPos = { 1.15f, 1.15f, 6.0f }; + drawTexturedQuad(m_texMonitorRadar, + { monPos.x + 0.30f, monPos.y + 0.35f, monPos.z + 0.45f }, + { monPos.x + 0.30f, monPos.y - 0.25f, monPos.z + 0.45f }, + { monPos.x + 0.30f, monPos.y - 0.25f, monPos.z - 0.45f }, + { monPos.x + 0.30f, monPos.y + 0.35f, monPos.z - 0.45f }, + 1.0f, 1.0f); + + // 5. Gateway Portal to Next Layer (North: X = 6.0f, Z = minCoord) + Vector3 gateFrame = { 6.0f, roomHeight * 0.5f, minCoord + 0.08f }; + drawTexturedBox(m_texCyberGate, gateFrame, { 2.6f, roomHeight, 0.30f }, 1.0f, 1.0f); + + float gatePulse = 0.5f + 0.5f * std::sin(animTime * 4.0f); + Vector3 gateField = { 6.0f, (roomHeight - 0.4f) * 0.5f, minCoord + 0.10f }; + DrawCube(gateField, 1.8f, roomHeight - 0.4f, 0.08f, Color{ 0, 240, 255, 150 }); + DrawCubeWires(gateField, 1.82f, roomHeight - 0.38f, 0.10f, + Color{ 0, 255, 255, static_cast(180 + gatePulse * 75) }); + + Vector3 gateSign = { 6.0f, roomHeight - 0.20f, minCoord + 0.26f }; + DrawCube(gateSign, 1.8f, 0.22f, 0.06f, Color{ 10, 16, 26, 255 }); + DrawCubeWires(gateSign, 1.8f, 0.22f, 0.06f, CyberColors::NeonYellow); + + EndMode3D(); + + endPostProcess(animTime); +} + +} // namespace dnr diff --git a/src/render/CyberRenderer.hpp b/src/render/CyberRenderer.hpp new file mode 100644 index 0000000..5f2d05e --- /dev/null +++ b/src/render/CyberRenderer.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Dungeon.hpp" +#include "dungeon/Player.hpp" + +namespace dnr { + +class HubRoom; +class RestRoom; +class CombatSystem; + +class CyberRenderer { +public: + CyberRenderer(); + ~CyberRenderer(); + + void init(); + void cleanup(); + + void render3D(const Dungeon& dungeon, const Player& player, float animTime); + void renderCombat3D(const Dungeon& dungeon, const Player& player, const CombatSystem& combat, float animTime); + void renderHub(const HubRoom& hub, const Player& player, float animTime); + void renderRestRoom(const RestRoom& restRoom, const Player& player, float animTime); + + void triggerGlitch(float intensity = 0.7f); + +private: + void generateTextures(); + void unloadTextures(); + + void beginPostProcess(); + void endPostProcess(float animTime); + + void renderCyberDrone(Vector3 pos, Color coreColor, float scale, float animTime, bool isHit = false, bool isAttacking = false); + + Camera3D m_camera{}; + bool m_texturesLoaded = false; + + // Post-Processing Framebuffer & GLSL Shader + RenderTexture2D m_renderTarget{}; + Shader m_shaderPostProcess{}; + int m_locTime = -1; + int m_locGlitch = -1; + int m_locRes = -1; + float m_glitchIntensity = 0.0f; + bool m_postProcessLoaded = false; + + // Dungeon High-Tech Procedural Textures + Texture2D m_texDungeonWall{}; + Texture2D m_texDungeonFloor{}; + Texture2D m_texDungeonCeil{}; + Texture2D m_texDungeonDoor{}; + Texture2D m_texDungeonDoorFrame{}; + + // Hub Room Procedural Textures + Texture2D m_texParquet{}; + Texture2D m_texRug{}; + Texture2D m_texPlasterWall{}; + Texture2D m_texPoster{}; + Texture2D m_texDeskWood{}; + Texture2D m_texMonitorCode{}; + Texture2D m_texMonitorRadar{}; + Texture2D m_texBedQuilt{}; + Texture2D m_texCityNight{}; + Texture2D m_texCyberGate{}; +}; + +} // namespace dnr diff --git a/src/render/UIRenderer.cpp b/src/render/UIRenderer.cpp new file mode 100644 index 0000000..1e68fa1 --- /dev/null +++ b/src/render/UIRenderer.cpp @@ -0,0 +1,1042 @@ +#include "UIRenderer.hpp" +#include +#include + +namespace dnr { + +UIRenderer::UIRenderer() = default; + +void UIRenderer::drawProgressBar(int x, int y, int width, int height, float ratio, Color barColor, Color bgColor) { + ratio = std::clamp(ratio, 0.0f, 1.0f); + DrawRectangle(x, y, width, height, bgColor); + DrawRectangle(x, y, static_cast(width * ratio), height, barColor); + DrawRectangleLines(x, y, width, height, Color{ 40, 80, 120, 255 }); +} + +void UIRenderer::drawMinimap(const Dungeon& dungeon, const Player& player, int startX, int startY, int size) { + int w = dungeon.getWidth(); + int h = dungeon.getHeight(); + float cellSize = static_cast(size) / std::max(w, h); + + // Frame + DrawRectangle(startX - 4, startY - 4, size + 8, size + 8, Color{ 4, 10, 18, 230 }); + DrawRectangleLines(startX - 4, startY - 4, size + 8, size + 8, CyberColors::NeonCyan); + DrawText("NET_MAP // 2.5D", startX, startY - 18, 11, CyberColors::NeonCyan); + + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + if (!dungeon.isExplored(x, y)) continue; + + int px = startX + static_cast(x * cellSize); + int py = startY + static_cast(y * cellSize); + int cs = static_cast(cellSize) + 1; + + TileType t = dungeon.getTile(x, y); + switch (t) { + case TileType::WALL: + DrawRectangle(px, py, cs, cs, Color{ 25, 45, 75, 255 }); + break; + case TileType::FLOOR: + DrawRectangle(px, py, cs, cs, Color{ 10, 20, 35, 255 }); + break; + case TileType::DOOR_LOCKED: + DrawRectangle(px, py, cs, cs, CyberColors::NeonOrange); + break; + case TileType::DOOR_UNLOCKED: + DrawRectangle(px, py, cs, cs, CyberColors::NeonGreen); + break; + case TileType::LOOT_CACHE: + DrawRectangle(px, py, cs, cs, CyberColors::NeonYellow); + break; + case TileType::EXIT_PORTAL: + DrawRectangle(px, py, cs, cs, CyberColors::NeonCyan); + break; + default: + break; + } + } + } + + // Player icon and direction on minimap + int plX = startX + static_cast((player.getGridX() + 0.5f) * cellSize); + int plY = startY + static_cast((player.getGridY() + 0.5f) * cellSize); + DrawCircle(plX, plY, cellSize * 0.7f, CyberColors::NeonYellow); + + Vector2 dirVec = getDirectionOffset(player.getFacing()); + DrawLine(plX, plY, plX + static_cast(dirVec.x * cellSize * 1.5f), plY + static_cast(dirVec.y * cellSize * 1.5f), CyberColors::NeonGreen); +} + +void UIRenderer::drawMatrixDigitalRain(float animTime) { + static const char* glyphs[] = { "01", "FF", "A7", "EXE", "NULL", "ACK", "SYN", "ICE", "ERR", "CPU", "357", "7F" }; + int colWidth = 55; + int cols = GetScreenWidth() / colWidth; + + for (int c = 0; c < cols; ++c) { + float speed = 120.0f + (c % 5) * 40.0f; + float yPos = std::fmod(animTime * speed + c * 80.0f, static_cast(GetScreenHeight() + 100)) - 50.0f; + int xPos = c * colWidth + 10; + + const char* txt = glyphs[(c + static_cast(animTime * 2.0f)) % 12]; + DrawText(txt, xPos, static_cast(yPos), 12, Color{ 0, 255, 100, 35 }); + DrawText(txt, xPos, static_cast(yPos - 20), 11, Color{ 0, 255, 100, 18 }); + } +} + +void UIRenderer::drawMatrixOverlay(float animTime) { + // Subtle Matrix CRT scanlines + int screenH = GetScreenHeight(); + int screenW = GetScreenWidth(); + + for (int y = 0; y < screenH; y += 3) { + DrawLine(0, y, screenW, y, Color{ 0, 255, 120, 14 }); + } + + // Top terminal status bar + DrawText("[SYS::MATRIX_ONLINE] [SUBNET::0x7F00] [PROTOCOL::CYBER_REVOLVER] [SECURITY::ICE_ACTIVE]", + 15, screenH - 18, 11, Color{ 0, 240, 160, 160 }); +} + +void UIRenderer::drawExplorationHUD(const Dungeon& dungeon, const Player& player, const std::string& prompt) { + // Top-Left: Player Vital Stats + int hudX = 20; + int hudY = 20; + DrawRectangle(hudX, hudY, 330, 145, Color{ 6, 12, 22, 220 }); + DrawRectangleLines(hudX, hudY, 330, 145, CyberColors::NeonCyan); + + DrawText(("SECTOR LAYER // " + std::to_string(dungeon.getFloor())).c_str(), hudX + 15, hudY + 12, 16, CyberColors::NeonCyan); + + // HP Bar + float hpRatio = static_cast(player.getHp()) / player.getMaxHp(); + drawProgressBar(hudX + 60, hudY + 40, 250, 18, hpRatio, CyberColors::NeonPink, Color{ 30, 10, 20, 255 }); + DrawText("CORE", hudX + 15, hudY + 40, 14, CyberColors::NeonPink); + DrawText((std::to_string(player.getHp()) + " / " + std::to_string(player.getMaxHp())).c_str(), hudX + 145, hudY + 42, 13, RAYWHITE); + + // Shield Bar + float shRatio = static_cast(player.getShield()) / player.getMaxShield(); + drawProgressBar(hudX + 60, hudY + 68, 250, 18, shRatio, CyberColors::NeonCyan, Color{ 10, 25, 45, 255 }); + DrawText("SHLD", hudX + 15, hudY + 68, 14, CyberColors::NeonCyan); + DrawText((std::to_string(player.getShield()) + " / " + std::to_string(player.getMaxShield())).c_str(), hudX + 145, hudY + 70, 13, RAYWHITE); + + // Cyber-Picks & Statuses + DrawText(("ICE PICKS: " + std::to_string(player.getCyberPicks())).c_str(), hudX + 15, hudY + 102, 15, CyberColors::NeonYellow); + + std::string statStr = "STATUS: "; + if (player.getStatuses().empty()) { + statStr += "SECURE"; + DrawText(statStr.c_str(), hudX + 160, hudY + 102, 14, CyberColors::NeonGreen); + } else { + statStr += "MALWARE_DEBUFF"; + DrawText(statStr.c_str(), hudX + 160, hudY + 102, 14, RED); + } + + // Top-Right: Holographic Minimap + drawMinimap(dungeon, player, GetScreenWidth() - 200, 20, 180); + + // Bottom-Right: Revolver Ready Ammo preview + int revX = GetScreenWidth() - 260; + int revY = GetScreenHeight() - 110; + DrawRectangle(revX, revY, 240, 90, Color{ 6, 12, 22, 220 }); + DrawRectangleLines(revX, revY, 240, 90, CyberColors::NeonCyan); + + AmmoType curAmmo = player.getRevolver().getCurrentAmmo(); + AmmoType nextAmmo = player.getRevolver().getNextAmmo(); + const auto& curInfo = getAmmoInfo(curAmmo); + const auto& nextInfo = getAmmoInfo(nextAmmo); + + DrawText("REVOLVER READY", revX + 15, revY + 10, 13, CyberColors::NeonCyan); + DrawCircle(revX + 25, revY + 42, 10, curInfo.color); + DrawText(curInfo.name.c_str(), revX + 45, revY + 34, 15, curInfo.color); + + DrawText(("NEXT: " + nextInfo.shortName).c_str(), revX + 15, revY + 65, 12, GRAY); + DrawText("[TAB] ARMORY", revX + 140, revY + 65, 12, CyberColors::NeonYellow); + + // Bottom-Center: Context Action Prompt + if (!prompt.empty()) { + int promptW = MeasureText(prompt.c_str(), 18) + 40; + int promptX = (GetScreenWidth() - promptW) / 2; + int promptY = GetScreenHeight() - 70; + DrawRectangle(promptX, promptY, promptW, 45, Color{ 10, 18, 32, 240 }); + DrawRectangleLines(promptX, promptY, promptW, 45, CyberColors::NeonYellow); + DrawText(prompt.c_str(), promptX + 20, promptY + 13, 18, CyberColors::NeonYellow); + } + + // Controls hints in bottom-left + DrawText("[W/S] WALK | [A/D] TURN 90 | [Q/E] STRAFE | [SPACE/E] INTERACT | [TAB] ARMORY", + 20, GetScreenHeight() - 35, 13, Color{ 120, 160, 200, 220 }); +} + +void UIRenderer::drawHackingUI(const HackingGame& hacking) { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 235 }); + + int panelW = 760; + int panelH = 520; + int panelX = (GetScreenWidth() - panelW) / 2; + int panelY = (GetScreenHeight() - panelH) / 2; + + DrawRectangle(panelX, panelY, panelW, panelH, Color{ 8, 14, 26, 255 }); + DrawRectangleLines(panelX, panelY, panelW, panelH, CyberColors::NeonCyan); + + // Header + DrawRectangle(panelX, panelY, panelW, 50, Color{ 14, 24, 42, 255 }); + DrawText(("SECURITY ICE BYPASS TERMINAL // DEFENSE LEVEL 0" + std::to_string(hacking.getSecurityLevel())).c_str(), + panelX + 25, panelY + 16, 18, CyberColors::NeonCyan); + + if (hacking.getPhase() == HackingPhase::PHASE_1_TUMBLER) { + DrawText("PHASE 1: FREQUENCY TUMBLER ALIGNMENT", panelX + 25, panelY + 65, 16, CyberColors::NeonYellow); + DrawText("Tune each pin into the sweet spot frequency band, then lock it into place.", panelX + 25, panelY + 90, 13, RAYWHITE); + + const auto& tumbler = hacking.getTumbler(); + int pinCount = tumbler.getPinCount(); + int pinW = 60; + int pinGap = 40; + int totalPinsW = pinCount * pinW + (pinCount - 1) * pinGap; + int startPinX = panelX + (panelW - totalPinsW) / 2; + int trackY = panelY + 130; + int trackH = 260; + + for (int i = 0; i < pinCount; ++i) { + const auto& pin = tumbler.getPin(i); + int px = startPinX + i * (pinW + pinGap); + bool isSelected = (i == tumbler.getSelectedPin()); + + DrawRectangle(px, trackY, pinW, trackH, Color{ 14, 20, 32, 255 }); + DrawRectangleLines(px, trackY, pinW, trackH, isSelected ? CyberColors::NeonYellow : Color{ 40, 60, 90, 255 }); + + // Sweet spot target zone + int sweetY = trackY + static_cast((1.0f - pin.targetHeight) * trackH); + int sweetSpan = static_cast(pin.tolerance * 2.0f * trackH); + DrawRectangle(px + 2, sweetY - sweetSpan / 2, pinW - 4, sweetSpan, Color{ 0, 240, 255, 60 }); + DrawLine(px, sweetY, px + pinW, sweetY, CyberColors::NeonCyan); + + int pinH = 40; + int pinCurrY = trackY + static_cast((1.0f - pin.currentHeight) * (trackH - pinH)); + + Color pinColor = pin.locked ? CyberColors::NeonGreen : + (pin.isInSweetSpot() ? CyberColors::NeonYellow : Color{ 70, 110, 160, 255 }); + + DrawRectangle(px + 4, pinCurrY, pinW - 8, pinH, pinColor); + DrawRectangleLines(px + 4, pinCurrY, pinW - 8, pinH, WHITE); + + if (pin.locked) { + DrawText("LOCKED", px + 8, trackY + trackH + 12, 12, CyberColors::NeonGreen); + } else if (pin.isInSweetSpot()) { + DrawText("SWEET", px + 12, trackY + trackH + 12, 12, CyberColors::NeonYellow); + } else { + DrawText(std::to_string(i + 1).c_str(), px + 24, trackY + trackH + 12, 14, GRAY); + } + } + + DrawRectangle(panelX + 20, panelY + panelH - 65, panelW - 40, 45, Color{ 12, 18, 30, 255 }); + if (tumbler.isAllPinsLocked()) { + DrawText("[ENTER / SPACE] ALL PINS LOCKED! INITIATE DEAD-LOCK TIMING CHECK", panelX + 70, panelY + panelH - 52, 16, CyberColors::NeonGreen); + } else { + DrawText("[A/D] Select Pin [W/S] Tune Frequency [SPACE] Lock Pin", panelX + 130, panelY + panelH - 52, 14, CyberColors::NeonCyan); + } + } else if (hacking.getPhase() == HackingPhase::PHASE_2_SKILLCHECK) { + DrawText("PHASE 2: DEAD-LOCK TIMING INJECTION (DBD SKILL CHECK)", panelX + 25, panelY + 65, 16, CyberColors::NeonPink); + DrawText("STRIKE [SPACE] WHEN THE NEEDLE HITS THE TARGET ZONE!", panelX + 25, panelY + 90, 14, CyberColors::NeonYellow); + + const auto& sc = hacking.getSkillCheck(); + int dialCenterX = panelX + panelW / 2; + int dialCenterY = panelY + panelH / 2 + 10; + float dialRadius = 130.0f; + + DrawCircle(dialCenterX, dialCenterY, dialRadius + 12.0f, Color{ 10, 16, 26, 255 }); + DrawCircleLines(dialCenterX, dialCenterY, dialRadius + 12.0f, CyberColors::NeonCyan); + DrawCircle(dialCenterX, dialCenterY, dialRadius - 20.0f, Color{ 6, 10, 18, 255 }); + + // Success zone + DrawCircleSectorLines({ static_cast(dialCenterX), static_cast(dialCenterY) }, + dialRadius, + sc.getSuccessStart() - 90.0f, + sc.getSuccessEnd() - 90.0f, + 32, RAYWHITE); + DrawCircleSector({ static_cast(dialCenterX), static_cast(dialCenterY) }, + dialRadius, + sc.getSuccessStart() - 90.0f, + sc.getSuccessEnd() - 90.0f, + 32, Color{ 220, 220, 220, 120 }); + + // Great success zone + DrawCircleSector({ static_cast(dialCenterX), static_cast(dialCenterY) }, + dialRadius, + sc.getGreatStart() - 90.0f, + sc.getGreatEnd() - 90.0f, + 16, CyberColors::NeonGreen); + + // Rotating needle + float needleRad = (sc.getNeedleAngle() - 90.0f) * (3.14159265f / 180.0f); + int needleEndX = dialCenterX + static_cast(std::cos(needleRad) * (dialRadius + 8.0f)); + int needleEndY = dialCenterY + static_cast(std::sin(needleRad) * (dialRadius + 8.0f)); + + DrawLineEx({ static_cast(dialCenterX), static_cast(dialCenterY) }, + { static_cast(needleEndX), static_cast(needleEndY) }, + 4.0f, RED); + DrawCircle(dialCenterX, dialCenterY, 14, RED); + + DrawText("HIT [ SPACE ]", dialCenterX - 60, dialCenterY + static_cast(dialRadius) + 30, 20, CyberColors::NeonYellow); + } else { + int cx = panelX + panelW / 2; + int cy = panelY + panelH / 2; + + if (hacking.getPhase() == HackingPhase::RESULT_CRITICAL) { + DrawText(">>> CRITICAL OVERRIDE SUCCESS! <<<", cx - 210, cy - 40, 24, CyberColors::NeonGreen); + DrawText("Subroutines penetrated. Bonus cyber-cartridge recovered!", cx - 200, cy + 10, 16, RAYWHITE); + } else if (hacking.getPhase() == HackingPhase::RESULT_SUCCESS) { + DrawText(">>> ACCESS GRANTED <<<", cx - 140, cy - 40, 26, CyberColors::NeonCyan); + DrawText("Security locks bypassed successfully.", cx - 150, cy + 10, 16, RAYWHITE); + } else { + DrawText("!!! ICE ALARM TRIGGERED !!!", cx - 180, cy - 40, 26, RED); + DrawText("Intrusion countermeasures activated. Defense Sentinel deployed!", cx - 240, cy + 10, 16, CyberColors::NeonOrange); + } + } +} + +void UIRenderer::drawRevolverCylinderCombat(const Revolver& revolver, int centerX, int centerY, float animTime, bool isSpinning) { + float cylinderR = 95.0f; + float chamberOrbitR = cylinderR - 30.0f; + float chamberR = 20.0f; + + // 1. Heavy gunmetal outer housing + DrawCircle(centerX, centerY, cylinderR + 14.0f, Color{ 8, 14, 24, 250 }); + DrawCircleLines(centerX, centerY, cylinderR + 14.0f, Color{ 30, 50, 75, 255 }); + + // Metallic cylinder body + DrawCircle(centerX, centerY, cylinderR, Color{ 20, 28, 42, 255 }); + DrawCircleLines(centerX, centerY, cylinderR, CyberColors::NeonCyan); + + // Decorative flutes/grooves between chambers (6 radial outer notches) + float visAngle = revolver.getVisualAngle(); + for (int i = 0; i < 6; ++i) { + float fluteAngle = (-visAngle + i * 60.0f + 30.0f - 90.0f) * (3.14159265f / 180.0f); + int fluteX = centerX + static_cast(std::cos(fluteAngle) * (cylinderR + 2.0f)); + int fluteY = centerY + static_cast(std::sin(fluteAngle) * (cylinderR + 2.0f)); + DrawCircle(fluteX, fluteY, 12.0f, Color{ 8, 14, 24, 255 }); + DrawCircleLines(fluteX, fluteY, 12.0f, Color{ 40, 65, 95, 255 }); + } + + // 2. Center extractor ratchet & hub + DrawCircle(centerX, centerY, 34.0f, Color{ 14, 20, 32, 255 }); + DrawCircleLines(centerX, centerY, 34.0f, Color{ 70, 100, 140, 255 }); + DrawCircle(centerX, centerY, 14.0f, Color{ 220, 175, 45, 255 }); + DrawCircleLines(centerX, centerY, 14.0f, CyberColors::NeonYellow); + DrawText(".357", centerX - 15, centerY - 6, 12, CyberColors::NeonYellow); + + // 3. Golden Striker Hammer / Firing Pin at 12 o'clock (-90 deg / top) + float strikerPulse = 0.5f + 0.5f * std::sin(animTime * 6.0f); + int strikerTipY = centerY - static_cast(cylinderR) - 4; + int strikerBaseY = centerY - static_cast(cylinderR) - 26; + + DrawTriangle( + { static_cast(centerX), static_cast(strikerTipY) }, + { static_cast(centerX - 12), static_cast(strikerBaseY) }, + { static_cast(centerX + 12), static_cast(strikerBaseY) }, + Color{ 255, 210, 50, 255 } + ); + DrawRectangle(centerX - 18, strikerBaseY - 14, 36, 14, Color{ 210, 160, 30, 255 }); + DrawRectangleLines(centerX - 18, strikerBaseY - 14, 36, 14, CyberColors::NeonYellow); + DrawCircle(centerX, strikerTipY, 2.5f + strikerPulse * 1.5f, CyberColors::NeonYellow); + + // Striker tag + DrawRectangle(centerX - 65, strikerBaseY - 32, 130, 16, Color{ 10, 16, 26, 230 }); + DrawRectangleLines(centerX - 65, strikerBaseY - 32, 130, 16, Color{ 180, 150, 40, 200 }); + DrawText("FIRING PIN [12:00]", centerX - 58, strikerBaseY - 30, 11, CyberColors::NeonYellow); + + // 4. Six Chambers (Top chamber at 12:00 aligns with striker when visualAngle == index * 60) + for (int i = 0; i < 6; ++i) { + float angleRad = (-visAngle + i * 60.0f - 90.0f) * (3.14159265f / 180.0f); + int chX = centerX + static_cast(std::cos(angleRad) * chamberOrbitR); + int chY = centerY + static_cast(std::sin(angleRad) * chamberOrbitR); + + AmmoType ammo = revolver.getChamber(i); + const auto& info = getAmmoInfo(ammo); + bool isCurrent = (i == revolver.getCurrentIndex()); + + DrawCircle(chX, chY, chamberR, Color{ 16, 22, 34, 255 }); + DrawCircleLines(chX, chY, chamberR, isCurrent ? CyberColors::NeonYellow : Color{ 55, 80, 120, 255 }); + + if (ammo == AmmoType::EMPTY) { + DrawCircle(chX, chY, chamberR - 4.0f, Color{ 8, 12, 18, 255 }); + DrawCircleLines(chX, chY, chamberR - 4.0f, Color{ 60, 60, 60, 255 }); + DrawText("0", chX - 4, chY - 6, 12, DARKGRAY); + } else { + DrawCircle(chX, chY, chamberR - 4.0f, Color{ 34, 46, 62, 255 }); + DrawCircleLines(chX, chY, chamberR - 4.0f, Color{ 180, 140, 50, 255 }); + DrawCircle(chX, chY, chamberR - 8.0f, info.color); + DrawCircle(chX - 3, chY - 3, 2.5f, Color{ 255, 255, 255, 200 }); + DrawText(info.shortName.substr(0, 3).c_str(), chX - 9, chY - 5, 10, BLACK); + } + + if (isCurrent) { + float reticleR = chamberR + 5.0f + 2.0f * std::sin(animTime * 6.0f); + DrawCircleLines(chX, chY, reticleR, CyberColors::NeonYellow); + DrawLine(chX - static_cast(reticleR) - 3, chY, chX - static_cast(reticleR) + 3, chY, CyberColors::NeonYellow); + DrawLine(chX + static_cast(reticleR) - 3, chY, chX + static_cast(reticleR) + 3, chY, CyberColors::NeonYellow); + DrawLine(chX, chY - static_cast(reticleR) - 3, chX, chY - static_cast(reticleR) + 3, CyberColors::NeonYellow); + DrawLine(chX, chY + static_cast(reticleR) - 3, chX, chY + static_cast(reticleR) + 3, CyberColors::NeonYellow); + } + } +} + +void UIRenderer::drawCodexModal() { + int screenW = GetScreenWidth(); + int screenH = GetScreenHeight(); + + DrawRectangle(0, 0, screenW, screenH, Color{ 4, 8, 16, 235 }); + + int modalW = 980; + int modalH = 580; + int modalX = (screenW - modalW) / 2; + int modalY = (screenH - modalH) / 2; + + DrawRectangle(modalX, modalY, modalW, modalH, Color{ 8, 14, 26, 255 }); + DrawRectangleLines(modalX, modalY, modalW, modalH, CyberColors::NeonCyan); + + // Decorative corners + DrawRectangle(modalX - 2, modalY - 2, 8, 8, CyberColors::NeonCyan); + DrawRectangle(modalX + modalW - 6, modalY - 2, 8, 8, CyberColors::NeonCyan); + DrawRectangle(modalX - 2, modalY + modalH - 6, 8, 8, CyberColors::NeonCyan); + DrawRectangle(modalX + modalW - 6, modalY + modalH - 6, 8, 8, CyberColors::NeonCyan); + + // Header banner + DrawRectangle(modalX, modalY, modalW, 48, Color{ 14, 24, 42, 255 }); + DrawText("// TACTICAL AMMUNITION & COMBAT CODEX // DATABASE 404", modalX + 25, modalY + 15, 17, CyberColors::NeonCyan); + DrawText("[H] / [ESC] TO CLOSE", modalX + modalW - 190, modalY + 16, 13, CyberColors::NeonYellow); + + // Left column: 7 Ammo Types breakdown + int leftX = modalX + 25; + int colY = modalY + 62; + DrawText("AMMUNITION CARTRIDGES (7 PROTOCOLS)", leftX, colY, 14, CyberColors::NeonYellow); + + AmmoType ammos[7] = { + AmmoType::KINETIC, + AmmoType::EMP, + AmmoType::OVERHEAT, + AmmoType::CORROSIVE, + AmmoType::GLITCH, + AmmoType::NANITE, + AmmoType::EMPTY + }; + + int cardY = colY + 24; + for (int i = 0; i < 7; ++i) { + const auto& inf = getAmmoInfo(ammos[i]); + DrawRectangle(leftX, cardY, 455, 46, Color{ 12, 18, 30, 255 }); + DrawRectangleLines(leftX, cardY, 455, 46, (ammos[i] == AmmoType::EMPTY) ? DARKGRAY : inf.color); + + DrawCircle(leftX + 20, cardY + 23, 10.0f, (ammos[i] == AmmoType::EMPTY) ? Color{ 25, 25, 25, 255 } : inf.color); + DrawCircleLines(leftX + 20, cardY + 23, 10.0f, WHITE); + + std::string title = inf.name + " [" + inf.shortName + "]"; + if (ammos[i] != AmmoType::EMPTY) { + title += " // DMG: " + std::to_string(inf.baseDamage); + } + DrawText(title.c_str(), leftX + 40, cardY + 8, 13, inf.color); + DrawText(inf.description.c_str(), leftX + 40, cardY + 25, 11, RAYWHITE); + + cardY += 52; + } + + // Right column: Combat Rules & Tactical Intel + int rightX = modalX + 505; + DrawText("TACTICAL COMBAT MECHANICS & PROTOCOLS", rightX, colY, 14, CyberColors::NeonYellow); + + struct GuideRule { + std::string title; + std::string desc1; + std::string desc2; + Color color; + }; + GuideRule rules[4] = { + { "1. THE 12:00 FIRING PIN RULE", + "The cylinder spins roulette-style. The chamber aligned at 12 o'clock", + "directly beneath the golden striker is the round that detonates.", + CyberColors::NeonYellow }, + { "2. CRITICAL SURGE WEAKNESS (+100% DMG)", + "Scan the hostile unit data scanner at top. Matching target weakness", + "immediately triggers a Critical Surge dealing +100% double damage!", + CyberColors::NeonGreen }, + { "3. DEFENSE MATRIX OVERCLOCK", + "Use [2] Overclock Shield when facing heavy retaliation or low ammo.", + "Instantly grants +25 shield and cuts incoming damage by 50% for 1 turn.", + CyberColors::NeonCyan }, + { "4. FAN THE HAMMER (3-ROUND BURST)", + "Protocol [3] discharges 3 consecutive chambers in rapid succession.", + "Verify your cylinder is filled in [TAB] Armory for devastating bursts!", + CyberColors::NeonPink } + }; + + int ruleY = colY + 24; + for (int i = 0; i < 4; ++i) { + DrawRectangle(rightX, ruleY, 450, 84, Color{ 12, 18, 30, 255 }); + DrawRectangleLines(rightX, ruleY, 450, 84, Color{ 40, 65, 100, 255 }); + + DrawText(rules[i].title.c_str(), rightX + 15, ruleY + 10, 13, rules[i].color); + DrawText(rules[i].desc1.c_str(), rightX + 15, ruleY + 34, 11, RAYWHITE); + DrawText(rules[i].desc2.c_str(), rightX + 15, ruleY + 52, 11, RAYWHITE); + + ruleY += 92; + } + + // Close Button at bottom + Rectangle btnClose = { static_cast(modalX + modalW / 2 - 120), static_cast(modalY + modalH - 52), 240.0f, 38.0f }; + Vector2 mousePos = GetMousePosition(); + bool hoverClose = CheckCollisionPointRec(mousePos, btnClose); + DrawRectangleRec(btnClose, hoverClose ? Color{ 40, 80, 120, 255 } : Color{ 20, 36, 60, 255 }); + DrawRectangleLinesEx(btnClose, hoverClose ? 2.0f : 1.0f, hoverClose ? CyberColors::NeonYellow : CyberColors::NeonCyan); + DrawText("[ CLOSE CODEX (PRESS H) ]", modalX + modalW / 2 - 95, modalY + modalH - 40, 13, hoverClose ? CyberColors::NeonYellow : WHITE); +} + +void UIRenderer::drawCombatUI(const CombatSystem& combat, const Player& player, float animTime, bool showCodex) { + const Enemy* enemy = combat.getEnemy(); + if (!enemy) return; + + // Digital matrix rain in combat background + drawMatrixDigitalRain(animTime); + + // 1. Top: Tactical Target Scanner HUD + int scanW = 760; + int scanH = 145; + int scanX = (GetScreenWidth() - scanW) / 2; + int scanY = 18; + + DrawRectangle(scanX, scanY, scanW, scanH, Color{ 8, 14, 26, 240 }); + DrawRectangleLines(scanX, scanY, scanW, scanH, enemy->getColor()); + + // Corner decorative accents + DrawRectangle(scanX - 2, scanY - 2, 8, 8, enemy->getColor()); + DrawRectangle(scanX + scanW - 6, scanY - 2, 8, 8, enemy->getColor()); + DrawRectangle(scanX - 2, scanY + scanH - 6, 8, 8, enemy->getColor()); + DrawRectangle(scanX + scanW - 6, scanY + scanH - 6, 8, 8, enemy->getColor()); + + DrawText("// TACTICAL TARGET SCANNER // HOSTILE_UNIT_DATA", scanX + 25, scanY + 10, 11, CyberColors::NeonCyan); + DrawText(enemy->getName().c_str(), scanX + 25, scanY + 26, 22, enemy->getColor()); + + // Core HP Segmented Bar + float eHpRatio = static_cast(enemy->getHp()) / enemy->getMaxHp(); + int hpBarW = scanW - 220; + drawProgressBar(scanX + 105, scanY + 54, hpBarW, 20, eHpRatio, enemy->getColor(), Color{ 36, 12, 18, 255 }); + DrawText("CORE HP", scanX + 25, scanY + 56, 14, RAYWHITE); + std::string hpTxt = std::to_string(enemy->getHp()) + " / " + std::to_string(enemy->getMaxHp()) + + " (" + std::to_string(static_cast(eHpRatio * 100)) + "%)"; + DrawText(hpTxt.c_str(), scanX + scanW / 2 - 40, scanY + 56, 13, WHITE); + + // Shield Segmented Bar + float eShRatio = (enemy->getMaxShield() > 0) ? (static_cast(enemy->getShield()) / enemy->getMaxShield()) : 0.0f; + drawProgressBar(scanX + 105, scanY + 80, hpBarW, 16, eShRatio, CyberColors::NeonCyan, Color{ 14, 24, 44, 255 }); + DrawText("SHIELD", scanX + 25, scanY + 81, 14, CyberColors::NeonCyan); + std::string shTxt = std::to_string(enemy->getShield()) + " / " + std::to_string(enemy->getMaxShield()) + + " (" + std::to_string(static_cast(eShRatio * 100)) + "%)"; + DrawText(shTxt.c_str(), scanX + scanW / 2 - 35, scanY + 81, 12, WHITE); + + // Weakness & Weapon Tags Row + const auto& atkInfo = getAmmoInfo(enemy->getAttackElement()); + const auto& wknInfo = getAmmoInfo(enemy->getWeakness()); + const auto& rev = player.getRevolver(); + + DrawText("WEAPON:", scanX + 25, scanY + 112, 12, GRAY); + DrawText(atkInfo.shortName.c_str(), scanX + 85, scanY + 112, 12, atkInfo.color); + + DrawText("WEAKNESS:", scanX + 160, scanY + 112, 12, GRAY); + DrawRectangle(scanX + 235, scanY + 107, 240, 24, Color{ 35, 28, 12, 240 }); + DrawRectangleLines(scanX + 235, scanY + 107, 240, 24, CyberColors::NeonYellow); + std::string wknText = wknInfo.shortName + " -> +100% CRIT SURGE!"; + DrawText(wknText.c_str(), scanX + 242, scanY + 112, 12, CyberColors::NeonYellow); + + if (rev.getCurrentAmmo() == enemy->getWeakness()) { + float surgePulse = 0.5f + 0.5f * std::sin(animTime * 10.0f); + Color badgeCol = (surgePulse > 0.5f) ? CyberColors::NeonGreen : CyberColors::NeonYellow; + DrawText("[SURGE PRIMED!]", scanX + 482, scanY + 112, 12, badgeCol); + } + + // Active Status Chips + int chipX = scanX + scanW - 20; + const auto& statuses = enemy->getStatuses(); + if (statuses.empty()) { + int w = MeasureText("[STATUS: NOMINAL]", 11); + DrawText("[STATUS: NOMINAL]", chipX - w, scanY + 112, 11, Color{ 120, 150, 180, 255 }); + } else { + int curRight = chipX; + for (const auto& st : statuses) { + std::string stStr = ""; + Color stCol = WHITE; + switch (st.effect) { + case StatusEffect::BURNING: + stStr = "BURN " + std::to_string(st.durationTurns) + "T"; + stCol = CyberColors::NeonOrange; + break; + case StatusEffect::SHOCKED: + stStr = "STUN " + std::to_string(st.durationTurns) + "T"; + stCol = CyberColors::NeonCyan; + break; + case StatusEffect::CORRODED: + stStr = "ACID +50% " + std::to_string(st.durationTurns) + "T"; + stCol = CyberColors::NeonGreen; + break; + case StatusEffect::GLITCHED: + stStr = "GLITCH " + std::to_string(st.durationTurns) + "T"; + stCol = CyberColors::NeonPurple; + break; + default: break; + } + if (!stStr.empty()) { + int stW = MeasureText(stStr.c_str(), 11) + 12; + curRight -= (stW + 6); + DrawRectangle(curRight, scanY + 108, stW, 20, Color{ 15, 22, 35, 230 }); + DrawRectangleLines(curRight, scanY + 108, stW, 20, stCol); + DrawText(stStr.c_str(), curRight + 6, scanY + 112, 11, stCol); + } + } + } + + // 2. Center-Left: Revolver Cylinder & Under-cylinder Odds breakdown + int drumCenterX = 210; + int drumCenterY = 320; + drawRevolverCylinderCombat(rev, drumCenterX, drumCenterY, animTime, rev.isSpinning()); + + AmmoType curAmmo = rev.getCurrentAmmo(); + const auto& curInfo = getAmmoInfo(curAmmo); + int activeW = 310; + int activeX = drumCenterX - activeW / 2; + int activeY = drumCenterY + 115; + + DrawRectangle(activeX, activeY, activeW, 46, Color{ 10, 16, 28, 240 }); + DrawRectangleLines(activeX, activeY, activeW, 46, curAmmo == AmmoType::EMPTY ? RED : curInfo.color); + DrawText("12:00 CHAMBER // PRIMED TO FIRE", activeX + 12, activeY + 6, 10, CyberColors::NeonCyan); + + if (curAmmo == AmmoType::EMPTY) { + DrawText("BLANK // DRY-FIRE MISFIRE (0 DMG)", activeX + 12, activeY + 22, 14, RED); + } else { + std::string actTxt = curInfo.name + " (" + std::to_string(curInfo.baseDamage) + " DMG)"; + DrawText(actTxt.c_str(), activeX + 12, activeY + 22, 14, curInfo.color); + } + + int statsY = activeY + 52; + int statsH = 68; + DrawRectangle(activeX, statsY, activeW, statsH, Color{ 8, 14, 24, 240 }); + DrawRectangleLines(activeX, statsY, activeW, statsH, Color{ 35, 60, 95, 255 }); + DrawText("// AMMO PROBABILITY ROULETTE", activeX + 12, statsY + 6, 10, GRAY); + + int loadedCount = 0; + int typeCounts[7] = { 0 }; + for (int i = 0; i < 6; ++i) { + AmmoType a = rev.getChamber(i); + typeCounts[static_cast(a)]++; + if (a != AmmoType::EMPTY) loadedCount++; + } + int hitChance = (loadedCount * 100) / 6; + int blankChance = 100 - hitChance; + + std::string oddsStr = "LOADED: " + std::to_string(loadedCount) + "/6 (" + std::to_string(hitChance) + "% HIT)"; + DrawText(oddsStr.c_str(), activeX + 12, statsY + 22, 13, hitChance >= 50 ? CyberColors::NeonGreen : CyberColors::NeonYellow); + + std::string blankStr = "MISFIRE RISK: " + std::to_string(blankChance) + "%"; + DrawText(blankStr.c_str(), activeX + 175, statsY + 22, 13, blankChance == 0 ? CyberColors::NeonGreen : (blankChance > 33 ? RED : CyberColors::NeonYellow)); + + std::string chipSummary = ""; + for (int t = 1; t <= 6; ++t) { + if (typeCounts[t] > 0) { + const auto& inf = getAmmoInfo(static_cast(t)); + if (!chipSummary.empty()) chipSummary += " | "; + chipSummary += inf.shortName + "x" + std::to_string(typeCounts[t]); + } + } + if (chipSummary.empty()) chipSummary = "ALL CHAMBERS EMPTY - RELOAD IN ARMORY"; + DrawText(chipSummary.c_str(), activeX + 12, statsY + 44, 11, Color{ 170, 195, 225, 255 }); + + // 3. Center-Right: Combat Data Stream & Operator Vitals + int logX = 770; + int logY = 180; + int logW = 460; + int logH = 210; + + DrawRectangle(logX, logY, logW, logH, Color{ 6, 12, 22, 245 }); + DrawRectangleLines(logX, logY, logW, logH, Color{ 30, 55, 90, 255 }); + DrawText("// COMBAT_DATA_STREAM // REAL-TIME LOG", logX + 15, logY + 10, 11, CyberColors::NeonCyan); + + const auto& logs = combat.getLog(); + int logTextY = logY + 30; + for (const auto& line : logs) { + Color logCol = RAYWHITE; + if (line.find("CRITICAL") != std::string::npos || line.find("SURGE") != std::string::npos) logCol = CyberColors::NeonYellow; + else if (line.find("FLATLINE") != std::string::npos || line.find("attacks") != std::string::npos) logCol = Color{ 255, 120, 120, 255 }; + else if (line.find("STUNNED") != std::string::npos || line.find("Nanites") != std::string::npos) logCol = CyberColors::NeonGreen; + else if (line.find("FAN THE HAMMER") != std::string::npos) logCol = CyberColors::NeonPink; + + DrawText(("> " + line).c_str(), logX + 15, logTextY, 13, logCol); + logTextY += 21; + } + + int pvY = logY + logH + 12; + int pvH = 78; + DrawRectangle(logX, pvY, logW, pvH, Color{ 8, 14, 26, 245 }); + DrawRectangleLines(logX, pvY, logW, pvH, CyberColors::NeonGreen); + DrawText("// OPERATOR VITALS // NEURAL SYNC", logX + 15, pvY + 8, 10, CyberColors::NeonGreen); + + float pHpRatio = static_cast(player.getHp()) / player.getMaxHp(); + drawProgressBar(logX + 80, pvY + 26, logW - 190, 18, pHpRatio, CyberColors::NeonGreen, Color{ 35, 12, 15, 255 }); + DrawText("HULL HP", logX + 15, pvY + 28, 12, RAYWHITE); + std::string pHpStr = std::to_string(player.getHp()) + "/" + std::to_string(player.getMaxHp()); + DrawText(pHpStr.c_str(), logX + 85 + (logW - 190) / 2 - 25, pvY + 28, 12, WHITE); + + float pShRatio = (player.getMaxShield() > 0) ? (static_cast(player.getShield()) / player.getMaxShield()) : 0.0f; + drawProgressBar(logX + 80, pvY + 50, logW - 190, 16, pShRatio, CyberColors::NeonCyan, Color{ 12, 22, 40, 255 }); + DrawText("SHIELD", logX + 15, pvY + 51, 12, CyberColors::NeonCyan); + std::string pShStr = std::to_string(player.getShield()) + "/" + std::to_string(player.getMaxShield()); + DrawText(pShStr.c_str(), logX + 85 + (logW - 190) / 2 - 20, pvY + 51, 11, WHITE); + + Rectangle btnCodex = { static_cast(logX + logW - 95), static_cast(pvY + 25), 85.0f, 40.0f }; + Vector2 mousePos = GetMousePosition(); + bool hoverCodex = CheckCollisionPointRec(mousePos, btnCodex); + DrawRectangleRec(btnCodex, hoverCodex ? Color{ 40, 65, 100, 255 } : Color{ 16, 28, 48, 255 }); + DrawRectangleLinesEx(btnCodex, hoverCodex ? 2.0f : 1.0f, hoverCodex ? CyberColors::NeonYellow : CyberColors::NeonCyan); + DrawText("[H] CODEX", logX + logW - 87, pvY + 38, 12, hoverCodex ? CyberColors::NeonYellow : RAYWHITE); + + // 4. Center: Tactical AI Advisor Hologram Ticker + int advX = 380; + int advY = 485; + int advW = 370; + int advH = 46; + + DrawRectangle(advX, advY, advW, advH, Color{ 8, 16, 30, 240 }); + DrawRectangleLines(advX, advY, advW, advH, CyberColors::NeonCyan); + + float aiPulse = 0.5f + 0.5f * std::sin(animTime * 5.0f); + DrawCircle(advX + 18, advY + 23, 6.0f + aiPulse * 2.0f, CyberColors::NeonCyan); + DrawCircleLines(advX + 18, advY + 23, 10.0f, CyberColors::NeonYellow); + DrawText("AI ADVISOR", advX + 32, advY + 8, 10, CyberColors::NeonYellow); + + std::string advisorMsg = ""; + if (rev.getCurrentAmmo() == enemy->getWeakness()) { + advisorMsg = "WEAKNESS MATCH! Striking now triggers +100% Critical Surge!"; + } else if (rev.getCurrentAmmo() == AmmoType::EMPTY) { + advisorMsg = "WARNING: 12:00 chamber is EMPTY! Will misfire. Spin again or shield!"; + } else if (enemy->getShield() > 0 && rev.getCurrentAmmo() == AmmoType::EMP) { + advisorMsg = "EMP PRIMED: Discharge to collapse enemy barrier and stun!"; + } else if (player.getHp() < 35) { + advisorMsg = "CRITICAL: Hull integrity low! Overclock Shield [2] recommended!"; + } else if (combat.getTripleCooldown() == 0 && loadedCount >= 3) { + advisorMsg = "OPPORTUNITY: Fan the Hammer [3] ready for 3-round rapid burst!"; + } else if (enemy->getShield() > 0) { + advisorMsg = "Hostile shield online. Kinetic sabots or EMP advised."; + } else { + advisorMsg = "Spin cylinder [1] to strike, or press [H] to review Ammunition Codex."; + } + DrawText(advisorMsg.c_str(), advX + 32, advY + 24, 11, RAYWHITE); + + // 5. Bottom Action Bar: Cyber Ability Cards + int barX = 50; + int barY = 548; + int barW = GetScreenWidth() - 100; + int barH = 120; + + DrawRectangle(barX, barY, barW, barH, Color{ 8, 14, 24, 245 }); + DrawRectangleLines(barX, barY, barW, barH, CyberColors::NeonCyan); + + if (combat.getPhase() == CombatPhase::PLAYER_TURN) { + // Card 1: [1 / SPACE] SPIN & SHOOT + Rectangle btnShoot = { static_cast(barX + 15), static_cast(barY + 12), 370.0f, 96.0f }; + bool hoverShoot = CheckCollisionPointRec(mousePos, btnShoot); + + DrawRectangleRec(btnShoot, hoverShoot ? Color{ 25, 45, 75, 255 } : Color{ 14, 24, 42, 255 }); + DrawRectangleLinesEx(btnShoot, hoverShoot ? 2.0f : 1.0f, hoverShoot ? CyberColors::NeonGreen : CyberColors::NeonYellow); + DrawText("// PROTOCOL 01: ROULETTE", barX + 30, barY + 20, 10, CyberColors::NeonCyan); + DrawText("[1 / SPACE] SPIN & SHOOT", barX + 30, barY + 36, 18, hoverShoot ? CyberColors::NeonGreen : CyberColors::NeonYellow); + DrawText("Spins cylinder randomly & strikes 12:00 chamber.", barX + 30, barY + 62, 12, RAYWHITE); + std::string oddsBadge = "ODDS: " + std::to_string(hitChance) + "% HIT / " + std::to_string(blankChance) + "% BLANK"; + DrawText(oddsBadge.c_str(), barX + 30, barY + 82, 12, hitChance >= 50 ? CyberColors::NeonGreen : CyberColors::NeonOrange); + + // Card 2: [2] OVERCLOCK SHIELD + Rectangle btnShield = { static_cast(barX + 405), static_cast(barY + 12), 370.0f, 96.0f }; + bool hoverShield = CheckCollisionPointRec(mousePos, btnShield); + + DrawRectangleRec(btnShield, hoverShield ? Color{ 25, 45, 75, 255 } : Color{ 14, 24, 42, 255 }); + DrawRectangleLinesEx(btnShield, hoverShield ? 2.0f : 1.0f, hoverShield ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("// PROTOCOL 02: DEFENSE MATRIX", barX + 420, barY + 20, 10, CyberColors::NeonCyan); + DrawText("[2] OVERCLOCK SHIELD", barX + 420, barY + 36, 18, hoverShield ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("Instantly adds +25 Shield & absorbs 50% damage this turn.", barX + 420, barY + 62, 12, RAYWHITE); + DrawText("ACTIVE MITIGATION // ALWAYS READY", barX + 420, barY + 82, 12, CyberColors::NeonGreen); + + // Card 3: [3] FAN THE HAMMER + int cd = combat.getTripleCooldown(); + Rectangle btnTriple = { static_cast(barX + 795), static_cast(barY + 12), 370.0f, 96.0f }; + bool hoverTriple = CheckCollisionPointRec(mousePos, btnTriple) && (cd == 0); + + Color tripleBg = (cd == 0) ? (hoverTriple ? Color{ 60, 25, 55, 255 } : Color{ 32, 16, 32, 255 }) : Color{ 14, 16, 22, 255 }; + Color tripleBorder = (cd == 0) ? (hoverTriple ? CyberColors::NeonPink : Color{ 220, 70, 150, 255 }) : Color{ 45, 50, 65, 255 }; + Color tripleTitle = (cd == 0) ? (hoverTriple ? WHITE : CyberColors::NeonPink) : GRAY; + + DrawRectangleRec(btnTriple, tripleBg); + DrawRectangleLinesEx(btnTriple, hoverTriple ? 2.0f : 1.0f, tripleBorder); + DrawText("// PROTOCOL 03: BURST SURGE", barX + 810, barY + 20, 10, (cd == 0) ? CyberColors::NeonPink : GRAY); + DrawText("[3] FAN THE HAMMER", barX + 810, barY + 36, 18, tripleTitle); + DrawText("Rapid-fires 3 consecutive chambers in continuous sequence.", barX + 810, barY + 62, 12, (cd == 0) ? RAYWHITE : GRAY); + + if (cd == 0) { + DrawText("READY TO FIRE! (Requires 3 rounds in cylinder)", barX + 810, barY + 82, 12, CyberColors::NeonYellow); + } else { + DrawText(("RECHARGING [COOLDOWN: " + std::to_string(cd) + " TURNS LEFT]").c_str(), barX + 810, barY + 82, 12, Color{ 150, 155, 170, 255 }); + } + } else if (combat.getPhase() == CombatPhase::SPINNING_SHOT) { + float pulse = 0.5f + 0.5f * std::sin(animTime * 10.0f); + Color spinCol = (pulse > 0.5f) ? CyberColors::NeonYellow : CyberColors::NeonPink; + DrawText(">>> ROULETTE SPINNING... DECELERATING TO TARGET CHAMBER <<<", barX + 220, barY + 45, 22, spinCol); + } else if (combat.getPhase() == CombatPhase::TRIPLE_SHOT_BURST) { + float pulse = 0.5f + 0.5f * std::sin(animTime * 14.0f); + Color burstCol = (pulse > 0.5f) ? CyberColors::NeonPink : CyberColors::NeonYellow; + DrawText(">>> FANNING THE HAMMER! 3-ROUND CYBER-BURST RAPID FIRE! <<<", barX + 210, barY + 45, 22, burstCol); + } else if (combat.getPhase() == CombatPhase::VICTORY) { + DrawText(">>> HOSTILE SENTINEL TERMINATED! PRESS [SPACE / CLICK] TO ADVANCE <<<", barX + 160, barY + 45, 22, CyberColors::NeonGreen); + } else if (combat.getPhase() == CombatPhase::DEFEAT) { + DrawText(">>> SYSTEM COLLAPSE: PLAYER DEFEATED! PRESS [SPACE / CLICK] TO REBOOT <<<", barX + 140, barY + 45, 22, RED); + } else { + DrawText("CALCULATING COMBAT RESOLUTION DATA...", barX + 360, barY + 45, 20, CyberColors::NeonCyan); + } + + // Floating combat numbers + for (const auto& ft : combat.getFloatingTexts()) { + DrawText(ft.text.c_str(), static_cast(ft.pos.x), static_cast(ft.pos.y), 22, ft.color); + } + + // 6. Overlay Codex Modal if active + if (showCodex) { + drawCodexModal(); + } +} + +void UIRenderer::drawLevelClearScreen(int floor) { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 235 }); + int cx = GetScreenWidth() / 2; + int cy = GetScreenHeight() / 2; + + DrawText(">>> SECTOR SECURED <<<", cx - 180, cy - 60, 30, CyberColors::NeonGreen); + DrawText(("FLOOR " + std::to_string(floor) + " CLEARED. DATA DOWNLOAD COMPLETE.").c_str(), cx - 220, cy - 10, 18, RAYWHITE); + DrawText("PRESS [ SPACE ] TO DESCEND TO NEXT DEEPER CYBERSPACE LAYER", cx - 290, cy + 40, 18, CyberColors::NeonYellow); +} + +void UIRenderer::drawGameOverScreen() { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 6, 12, 245 }); + int cx = GetScreenWidth() / 2; + int cy = GetScreenHeight() / 2; + + DrawText(">>> CONNECTION TERMINATED <<<", cx - 240, cy - 60, 32, RED); + DrawText("FLATLINE: NEURAL INTERFACE DISCONNECTED", cx - 210, cy - 10, 18, GRAY); + DrawText("PRESS [ SPACE / ENTER ] TO RECONNECT AND START NEW RUN", cx - 260, cy + 50, 18, CyberColors::NeonCyan); +} + +void UIRenderer::drawHubHUD(const Player& player, const std::string& prompt) { + int hudX = 20; + int hudY = 20; + DrawRectangle(hudX, hudY, 320, 80, Color{ 16, 22, 30, 220 }); + DrawRectangleLines(hudX, hudY, 320, 80, Color{ 180, 150, 100, 255 }); + + DrawText("PHYSICAL REALITY // ROOM 404", hudX + 15, hudY + 12, 16, Color{ 240, 210, 160, 255 }); + DrawText("NEURAL DECK: ONLINE & READY", hudX + 15, hudY + 38, 14, CyberColors::NeonGreen); + DrawText("STATUS: SAFE APARTMENT", hudX + 15, hudY + 56, 12, GRAY); + + if (!prompt.empty()) { + int promptW = MeasureText(prompt.c_str(), 18) + 40; + int promptX = (GetScreenWidth() - promptW) / 2; + int promptY = GetScreenHeight() - 70; + DrawRectangle(promptX, promptY, promptW, 45, Color{ 18, 25, 36, 240 }); + DrawRectangleLines(promptX, promptY, promptW, 45, CyberColors::NeonGreen); + DrawText(prompt.c_str(), promptX + 20, promptY + 13, 18, CyberColors::NeonGreen); + } + + DrawText("[W/S] WALK | [A/D] TURN 90 | [SPACE/E] INTERACT | [TAB] ARMORY | [ESC] PAUSE MENU", + 20, GetScreenHeight() - 35, 13, Color{ 180, 190, 200, 220 }); +} + +void UIRenderer::drawRestRoomHUD(int nextFloor, const Player& player, const std::string& prompt) { + int hudX = 20; + int hudY = 20; + DrawRectangle(hudX, hudY, 340, 120, Color{ 8, 16, 30, 220 }); + DrawRectangleLines(hudX, hudY, 340, 120, CyberColors::NeonCyan); + + DrawText("CYBERSANCTUARY // REST NODE", hudX + 15, hudY + 10, 16, CyberColors::NeonCyan); + DrawText(("NEXT TARGET: CYBERSPACE LAYER 0" + std::to_string(nextFloor)).c_str(), hudX + 15, hudY + 30, 13, CyberColors::NeonYellow); + + // HP Bar + float hpRatio = static_cast(player.getHp()) / player.getMaxHp(); + drawProgressBar(hudX + 55, hudY + 52, 260, 16, hpRatio, CyberColors::NeonGreen, Color{ 40, 15, 15, 255 }); + DrawText("HP", hudX + 15, hudY + 52, 13, CyberColors::NeonGreen); + DrawText((std::to_string(player.getHp()) + "/" + std::to_string(player.getMaxHp())).c_str(), hudX + 150, hudY + 53, 12, RAYWHITE); + + // Shield Bar + float shRatio = static_cast(player.getShield()) / player.getMaxShield(); + drawProgressBar(hudX + 55, hudY + 74, 260, 16, shRatio, CyberColors::NeonCyan, Color{ 10, 25, 45, 255 }); + DrawText("SHLD", hudX + 15, hudY + 74, 13, CyberColors::NeonCyan); + DrawText((std::to_string(player.getShield()) + "/" + std::to_string(player.getMaxShield())).c_str(), hudX + 150, hudY + 75, 12, RAYWHITE); + + DrawText(("PICKS: " + std::to_string(player.getCyberPicks())).c_str(), hudX + 15, hudY + 98, 13, CyberColors::NeonYellow); + DrawText("STATUS: SAFE HAVEN", hudX + 150, hudY + 98, 13, CyberColors::NeonGreen); + + // Context Action Prompt + if (!prompt.empty()) { + int promptW = MeasureText(prompt.c_str(), 18) + 40; + int promptX = (GetScreenWidth() - promptW) / 2; + int promptY = GetScreenHeight() - 70; + DrawRectangle(promptX, promptY, promptW, 45, Color{ 10, 20, 36, 240 }); + DrawRectangleLines(promptX, promptY, promptW, 45, CyberColors::NeonGreen); + DrawText(prompt.c_str(), promptX + 20, promptY + 13, 18, CyberColors::NeonGreen); + } + + DrawText("[W/S] WALK | [A/D] TURN 90 | [SPACE/E] INTERACT | [TAB] ARMORY | [ESC] PAUSE MENU", + 20, GetScreenHeight() - 35, 13, Color{ 180, 190, 200, 220 }); +} + +void UIRenderer::drawPauseMenu(bool hasSavedMessage) { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 225 }); + + int menuW = 480; + int menuH = 430; + int menuX = (GetScreenWidth() - menuW) / 2; + int menuY = (GetScreenHeight() - menuH) / 2; + + DrawRectangle(menuX, menuY, menuW, menuH, Color{ 10, 16, 28, 255 }); + DrawRectangleLines(menuX, menuY, menuW, menuH, CyberColors::NeonCyan); + + DrawRectangle(menuX, menuY, menuW, 50, Color{ 16, 26, 44, 255 }); + DrawText("// PROTOCOL PAUSED", menuX + 25, menuY + 16, 20, CyberColors::NeonCyan); + + if (hasSavedMessage) { + DrawText(">>> RUN SAVED TO DISK (savegame.dat) <<<", menuX + 45, menuY + 62, 14, CyberColors::NeonGreen); + } else { + DrawText("SESSION SUSPENDED // SELECT ACTION", menuX + 60, menuY + 62, 13, GRAY); + } + + Vector2 mouse = GetMousePosition(); + + const char* labels[] = { + "[1 / ESC] RESUME SESSION", + "[2] AUDIO & MUSIC SETTINGS", + "[3] SAVE PROGRESS TO DISK", + "[4] RETURN TO ROOM HUB", + "[5] EXIT TO DESKTOP" + }; + + int startY = menuY + 90; + for (int i = 0; i < 5; ++i) { + Rectangle btnRect = { static_cast(menuX + 30), static_cast(startY + i * 58), static_cast(menuW - 60), 48.0f }; + bool isHover = CheckCollisionPointRec(mouse, btnRect); + + DrawRectangleRec(btnRect, isHover ? Color{ 30, 55, 90, 255 } : Color{ 16, 25, 40, 255 }); + DrawRectangleLinesEx(btnRect, isHover ? 2.0f : 1.0f, isHover ? CyberColors::NeonGreen : (i == 4 ? RED : CyberColors::NeonCyan)); + + DrawText(labels[i], menuX + 50, startY + i * 58 + 15, 16, isHover ? CyberColors::NeonYellow : (i == 4 ? Color{ 255, 100, 100, 255 } : RAYWHITE)); + } +} + +void UIRenderer::drawMainMenu(bool canContinue) { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 255 }); + + drawMatrixDigitalRain(static_cast(GetTime())); + + int cx = GetScreenWidth() / 2; + int cy = GetScreenHeight() / 2; + + DrawText("DOORNROLL", cx - 180, cy - 200, 54, CyberColors::NeonCyan); + DrawText("// CYBERSPACE PROTOCOL & PHYSICAL REALITY", cx - 210, cy - 135, 16, CyberColors::NeonGreen); + + Vector2 mouse = GetMousePosition(); + + Rectangle btnContinue = { static_cast(cx - 190), static_cast(cy - 50), 380.0f, 48.0f }; + Rectangle btnNewRun = { static_cast(cx - 190), static_cast(cy + 10), 380.0f, 48.0f }; + Rectangle btnSettings = { static_cast(cx - 190), static_cast(cy + 70), 380.0f, 48.0f }; + Rectangle btnExit = { static_cast(cx - 190), static_cast(cy + 130), 380.0f, 48.0f }; + + bool hoverCont = CheckCollisionPointRec(mouse, btnContinue) && canContinue; + bool hoverNew = CheckCollisionPointRec(mouse, btnNewRun); + bool hoverSet = CheckCollisionPointRec(mouse, btnSettings); + bool hoverExit = CheckCollisionPointRec(mouse, btnExit); + + if (canContinue) { + DrawRectangleRec(btnContinue, hoverCont ? Color{ 30, 60, 95, 255 } : Color{ 16, 28, 48, 255 }); + DrawRectangleLinesEx(btnContinue, hoverCont ? 2.0f : 1.0f, hoverCont ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("[1] CONTINUE SAVED RUN", cx - 130, cy - 35, 18, hoverCont ? CyberColors::NeonYellow : RAYWHITE); + } else { + DrawRectangleRec(btnContinue, Color{ 12, 18, 28, 255 }); + DrawRectangleLinesEx(btnContinue, 1.0f, DARKGRAY); + DrawText("CONTINUE (NO SAVE DATA)", cx - 130, cy - 35, 16, DARKGRAY); + } + + DrawRectangleRec(btnNewRun, hoverNew ? Color{ 30, 60, 95, 255 } : Color{ 16, 28, 48, 255 }); + DrawRectangleLinesEx(btnNewRun, hoverNew ? 2.0f : 1.0f, hoverNew ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("[2] NEW RUN // REALITY ROOM", cx - 145, cy + 25, 18, hoverNew ? CyberColors::NeonYellow : RAYWHITE); + + DrawRectangleRec(btnSettings, hoverSet ? Color{ 30, 60, 95, 255 } : Color{ 16, 28, 48, 255 }); + DrawRectangleLinesEx(btnSettings, hoverSet ? 2.0f : 1.0f, hoverSet ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("[3] AUDIO & MUSIC SETTINGS", cx - 145, cy + 85, 18, hoverSet ? CyberColors::NeonYellow : RAYWHITE); + + DrawRectangleRec(btnExit, hoverExit ? Color{ 50, 20, 30, 255 } : Color{ 20, 14, 22, 255 }); + DrawRectangleLinesEx(btnExit, hoverExit ? 2.0f : 1.0f, hoverExit ? RED : Color{ 120, 40, 50, 255 }); + DrawText("[4] QUIT TO DESKTOP", cx - 105, cy + 145, 18, hoverExit ? RED : GRAY); + + drawMatrixOverlay(static_cast(GetTime())); +} + +void UIRenderer::drawSettingsMenu(float masterVol, float musicVol, float sfxVol, int selectedSlider) { + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Color{ 4, 8, 16, 230 }); + + int menuW = 580; + int menuH = 430; + int menuX = (GetScreenWidth() - menuW) / 2; + int menuY = (GetScreenHeight() - menuH) / 2; + + DrawRectangle(menuX, menuY, menuW, menuH, Color{ 10, 16, 28, 255 }); + DrawRectangleLines(menuX, menuY, menuW, menuH, CyberColors::NeonCyan); + + // Title Bar + DrawRectangle(menuX, menuY, menuW, 52, Color{ 16, 26, 44, 255 }); + DrawText("// AUDIO CONFIGURATION PROTOCOL", menuX + 25, menuY + 16, 20, CyberColors::NeonCyan); + + Vector2 mouse = GetMousePosition(); + + const char* titles[] = { "MASTER VOLUME", "MUSIC VOLUME", "SFX VOLUME" }; + float vols[] = { masterVol, musicVol, sfxVol }; + + int startY = menuY + 85; + int sliderW = 340; + int sliderH = 26; + int sliderX = menuX + 180; + + for (int i = 0; i < 3; ++i) { + int itemY = startY + i * 78; + bool isSel = (i == selectedSlider); + + Rectangle itemBox = { static_cast(menuX + 20), static_cast(itemY - 10), static_cast(menuW - 40), 65.0f }; + bool isHover = CheckCollisionPointRec(mouse, itemBox); + + if (isSel || isHover) { + DrawRectangleRec(itemBox, Color{ 20, 32, 52, 140 }); + DrawRectangleLinesEx(itemBox, 1.0f, isSel ? CyberColors::NeonYellow : Color{ 40, 70, 110, 255 }); + } + + DrawText(titles[i], menuX + 30, itemY + 4, 15, isSel ? CyberColors::NeonYellow : RAYWHITE); + + Rectangle sBar = { static_cast(sliderX), static_cast(itemY), static_cast(sliderW), static_cast(sliderH) }; + DrawRectangleRec(sBar, Color{ 14, 20, 32, 255 }); + DrawRectangleLinesEx(sBar, 1.0f, isSel ? CyberColors::NeonGreen : Color{ 50, 75, 110, 255 }); + + int fillW = static_cast(vols[i] * sliderW); + Color fillCol = (i == 0) ? CyberColors::NeonCyan : ((i == 1) ? CyberColors::NeonPurple : CyberColors::NeonGreen); + DrawRectangle(sliderX + 2, itemY + 2, fillW - 4 > 0 ? fillW - 4 : 0, sliderH - 4, fillCol); + + int knobX = sliderX + fillW; + DrawRectangle(knobX - 4, itemY - 3, 8, sliderH + 6, WHITE); + + std::string pctStr = std::to_string(static_cast(std::round(vols[i] * 100.0f))) + "%"; + DrawText(pctStr.c_str(), sliderX + sliderW + 15, itemY + 4, 16, isSel ? CyberColors::NeonYellow : RAYWHITE); + } + + Rectangle btnBack = { static_cast(menuX + (menuW - 240) / 2), static_cast(menuY + menuH - 65), 240.0f, 44.0f }; + bool hoverBack = CheckCollisionPointRec(mouse, btnBack); + DrawRectangleRec(btnBack, hoverBack ? Color{ 30, 60, 95, 255 } : Color{ 16, 26, 42, 255 }); + DrawRectangleLinesEx(btnBack, hoverBack ? 2.0f : 1.0f, hoverBack ? CyberColors::NeonGreen : CyberColors::NeonCyan); + DrawText("[ESC / ENTER] SAVE & RETURN", static_cast(btnBack.x) + 18, static_cast(btnBack.y) + 13, 14, hoverBack ? CyberColors::NeonYellow : RAYWHITE); + + DrawText("[W/S] SELECT SLIDER | [A/D] ADJUST +/-5% | CLICK & DRAG SLIDERS", + menuX + 40, menuY + menuH - 18, 12, GRAY); +} + +} // namespace dnr diff --git a/src/render/UIRenderer.hpp b/src/render/UIRenderer.hpp new file mode 100644 index 0000000..cbf10b1 --- /dev/null +++ b/src/render/UIRenderer.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "core/Common.hpp" +#include "dungeon/Dungeon.hpp" +#include "dungeon/Player.hpp" +#include "hacking/HackingGame.hpp" +#include "combat/CombatSystem.hpp" + +namespace dnr { + +class UIRenderer { +public: + UIRenderer(); + + void drawExplorationHUD(const Dungeon& dungeon, const Player& player, const std::string& prompt); + void drawHubHUD(const Player& player, const std::string& prompt); + void drawRestRoomHUD(int nextFloor, const Player& player, const std::string& prompt); + void drawHackingUI(const HackingGame& hacking); + void drawCombatUI(const CombatSystem& combat, const Player& player, float animTime, bool showCodex = false); + void drawCodexModal(); + void drawLevelClearScreen(int floor); + void drawGameOverScreen(); + void drawMatrixOverlay(float animTime); + void drawPauseMenu(bool hasSavedMessage); + void drawMainMenu(bool canContinue); + void drawSettingsMenu(float masterVol, float musicVol, float sfxVol, int selectedSlider); + +private: + void drawMinimap(const Dungeon& dungeon, const Player& player, int x, int y, int size); + void drawProgressBar(int x, int y, int width, int height, float ratio, Color barColor, Color bgColor); + void drawMatrixDigitalRain(float animTime); + void drawRevolverCylinderCombat(const Revolver& revolver, int centerX, int centerY, float animTime, bool isSpinning); +}; + +} // namespace dnr