Initial commit: Cyberpunk 2.5D Dungeon Crawler DoorNroll

This commit is contained in:
Игорь Федосеев 2026-10-05 12:57:38 +03:00
commit 5a76e602f1
60 changed files with 7166 additions and 0 deletions

29
.gitignore vendored Normal file
View file

@ -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

53
CMakeLists.txt Normal file
View file

@ -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"
"$<TARGET_FILE_DIR:DoorNroll>/assets"
)

76
README.md Normal file
View file

@ -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
```

View file

@ -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;
}

View file

@ -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);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/sounds/ost/rush.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

2
run.bat Normal file
View file

@ -0,0 +1,2 @@
@echo off
start "" "%~dp0build\Release\DoorNroll.exe"

301
src/combat/CombatSystem.cpp Normal file
View file

@ -0,0 +1,301 @@
#include "CombatSystem.hpp"
#include "core/SoundManager.hpp"
#include <cstdlib>
namespace dnr {
CombatSystem::CombatSystem() = default;
void CombatSystem::startCombat(Player& player, std::unique_ptr<Enemy> 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<int>(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<int>(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

View file

@ -0,0 +1,77 @@
#pragma once
#include "core/Common.hpp"
#include "dungeon/Player.hpp"
#include "Enemy.hpp"
#include <vector>
#include <string>
#include <memory>
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> 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<std::string>& getLog() const { return m_combatLog; }
const std::vector<FloatingText>& 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<Enemy> 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<std::string> m_combatLog;
std::vector<FloatingText> m_floatingTexts;
};
} // namespace dnr

140
src/combat/Enemy.cpp Normal file
View file

@ -0,0 +1,140 @@
#include "Enemy.hpp"
#include <algorithm>
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<int>(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<std::string>& 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

60
src/combat/Enemy.hpp Normal file
View file

@ -0,0 +1,60 @@
#pragma once
#include "core/Common.hpp"
#include <string>
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<std::string>& combatLog);
int calculateAttackDamage() const;
bool isStunned() const;
const std::vector<ActiveStatus>& 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<ActiveStatus> m_statuses;
};
} // namespace dnr

108
src/combat/Revolver.cpp Normal file
View file

@ -0,0 +1,108 @@
#include "Revolver.hpp"
#include "core/SoundManager.hpp"
#include <cstdlib>
#include <cmath>
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

52
src/combat/Revolver.hpp Normal file
View file

@ -0,0 +1,52 @@
#pragma once
#include "core/Common.hpp"
#include <array>
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<AmmoType, CHAMBERS_COUNT> 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

130
src/core/Common.hpp Normal file
View file

@ -0,0 +1,130 @@
#pragma once
#include <string>
#include <vector>
#include <array>
#include <memory>
#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

795
src/core/Game.cpp Normal file
View file

@ -0,0 +1,795 @@
#include "Game.hpp"
#include "dungeon/DungeonGen.hpp"
#include "core/SoundManager.hpp"
#include <cstdlib>
#include <algorithm>
#include <cmath>
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<AmmoType>(data.chambers[i]));
}
// Restore stacked ammo counts
for (size_t i = 0; i < data.ammoCounts.size(); ++i) {
m_player.setAmmoCount(static_cast<AmmoType>(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<float>(cx - 190), static_cast<float>(cy - 50), 380.0f, 48.0f };
Rectangle btnNewRun = { static_cast<float>(cx - 190), static_cast<float>(cy + 10), 380.0f, 48.0f };
Rectangle btnSettings = { static_cast<float>(cx - 190), static_cast<float>(cy + 70), 380.0f, 48.0f };
Rectangle btnExit = { static_cast<float>(cx - 190), static_cast<float>(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<int>(dirVec.x), static_cast<int>(dirVec.y));
}
if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) {
tryMoveHub(-static_cast<int>(dirVec.x), -static_cast<int>(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<int>(strafe.x), static_cast<int>(strafe.y));
}
if (IsKeyPressed(KEY_E)) {
if (!m_contextPrompt.empty()) {
tryInteractHub();
} else {
Vector2 strafe = { -dirVec.y, dirVec.x };
tryMoveHub(static_cast<int>(strafe.x), static_cast<int>(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<int>(dirVec.x), static_cast<int>(dirVec.y));
}
if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) {
tryMoveRestRoom(-static_cast<int>(dirVec.x), -static_cast<int>(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<int>(strafe.x), static_cast<int>(strafe.y));
}
if (IsKeyPressed(KEY_E)) {
if (!m_contextPrompt.empty()) {
tryInteractRestRoom();
} else {
Vector2 strafe = { -dirVec.y, dirVec.x };
tryMoveRestRoom(static_cast<int>(strafe.x), static_cast<int>(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<int>(dirVec.x);
int frontY = m_player.getGridY() + static_cast<int>(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<int>(dirVec.x), static_cast<int>(dirVec.y));
}
if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) {
tryMoveDungeon(-static_cast<int>(dirVec.x), -static_cast<int>(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<int>(strafe.x), static_cast<int>(strafe.y));
}
if (IsKeyPressed(KEY_E)) {
if (!m_contextPrompt.empty()) {
tryInteractDungeon();
} else {
Vector2 strafe = { -dirVec.y, dirVec.x };
tryMoveDungeon(static_cast<int>(strafe.x), static_cast<int>(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<Enemy>(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<Enemy>(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<int>(dirVec.x);
int fy = m_player.getGridY() + static_cast<int>(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<Enemy>(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<float>(modalX + modalW / 2 - 120), static_cast<float>(modalY + modalH - 52), 240.0f, 38.0f };
int logX = 770;
int pvY = 180 + 210 + 12;
Rectangle btnCodexToggle = { static_cast<float>(logX + 460 - 95), static_cast<float>(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<float>(barX + 15), static_cast<float>(barY + 12), 370.0f, 96.0f };
Rectangle btnShield = { static_cast<float>(barX + 405), static_cast<float>(barY + 12), 370.0f, 96.0f };
Rectangle btnTriple = { static_cast<float>(barX + 795), static_cast<float>(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<float>(menuX + 30), static_cast<float>(startY), static_cast<float>(menuW - 60), 48.0f };
Rectangle btnSettings = { static_cast<float>(menuX + 30), static_cast<float>(startY + 58), static_cast<float>(menuW - 60), 48.0f };
Rectangle btnSave = { static_cast<float>(menuX + 30), static_cast<float>(startY + 58 * 2), static_cast<float>(menuW - 60), 48.0f };
Rectangle btnHub = { static_cast<float>(menuX + 30), static_cast<float>(startY + 58 * 3), static_cast<float>(menuW - 60), 48.0f };
Rectangle btnQuit = { static_cast<float>(menuX + 30), static_cast<float>(startY + 58 * 4), static_cast<float>(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<float>(menuX + (menuW - 240) / 2), static_cast<float>(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<float>(sliderX - 10), static_cast<float>(itemY - 6), static_cast<float>(sliderW + 20), static_cast<float>(sliderH + 12) };
if (CheckCollisionPointRec(mouse, sBar)) {
m_selectedSlider = i;
m_isDraggingSlider = true;
float ratio = std::clamp((mouse.x - sliderX) / static_cast<float>(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<float>(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

84
src/core/Game.hpp Normal file
View file

@ -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

84
src/core/SaveSystem.cpp Normal file
View file

@ -0,0 +1,84 @@
#include "SaveSystem.hpp"
#include "dungeon/Player.hpp"
#include <fstream>
#include <iostream>
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<int>(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

31
src/core/SaveSystem.hpp Normal file
View file

@ -0,0 +1,31 @@
#pragma once
#include "core/Common.hpp"
#include <array>
#include <string>
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<int, 6> chambers{};
std::array<int, 7> 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

402
src/core/SoundManager.cpp Normal file
View file

@ -0,0 +1,402 @@
#include "SoundManager.hpp"
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <algorithm>
#include <fstream>
#include <iostream>
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<std::string>& 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<std::string>& 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<unsigned int>(duration * sampleRate);
std::vector<short> buffer(totalFrames);
float phase = 0.0f;
const float twoPi = 6.2831853f;
for (unsigned int i = 0; i < totalFrames; ++i) {
float t = static_cast<float>(i) / static_cast<float>(totalFrames);
float currentFreq = freqStart + (freqEnd - freqStart) * t;
phase += (twoPi * currentFreq) / static_cast<float>(sampleRate);
if (phase > twoPi) phase -= twoPi;
float sample = std::sin(phase);
if (noise) {
float n = (static_cast<float>(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<short>(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

115
src/core/SoundManager.hpp Normal file
View file

@ -0,0 +1,115 @@
#pragma once
#include "raylib.h"
#include <vector>
#include <string>
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<std::string>& candidates);
Sound loadSoundWithCandidates(const std::vector<std::string>& candidates, Sound fallback);
Sound createToneSound(float freqStart, float freqEnd, float duration, float volume, bool noise = false);
};
} // namespace dnr

120
src/dungeon/Dungeon.cpp Normal file
View file

@ -0,0 +1,120 @@
#include "Dungeon.hpp"
#include <algorithm>
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<AmmoType>& 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

81
src/dungeon/Dungeon.hpp Normal file
View file

@ -0,0 +1,81 @@
#pragma once
#include "core/Common.hpp"
#include "combat/Enemy.hpp"
#include <vector>
#include <memory>
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<AmmoType> 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<AmmoType>& 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<DoorData>& getDoors() const { return m_doors; }
const std::vector<LootData>& getLoots() const { return m_loots; }
std::vector<MapEnemy>& getEnemies() { return m_enemies; }
private:
int m_width;
int m_height;
int m_floor = 1;
std::vector<TileType> m_tiles;
std::vector<bool> m_explored;
std::vector<DoorData> m_doors;
std::vector<LootData> m_loots;
std::vector<MapEnemy> m_enemies;
};
} // namespace dnr

239
src/dungeon/DungeonGen.cpp Normal file
View file

@ -0,0 +1,239 @@
#include "DungeonGen.hpp"
#include <cstdlib>
#include <algorithm>
#include <cmath>
#include <queue>
#include <vector>
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<Room> 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<std::pair<int, int>> 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<int>(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<int> dist(W * H, -1);
std::queue<std::pair<int, int>> 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<AmmoType> 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<EnemyType>(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

View file

@ -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

195
src/dungeon/Player.cpp Normal file
View file

@ -0,0 +1,195 @@
#include "Player.hpp"
#include <cmath>
#include <algorithm>
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<float>(m_gridX) * 2.0f, 0.8f, static_cast<float>(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<int>(AmmoType::KINETIC)] = 6;
m_ammoCounts[static_cast<int>(AmmoType::EMP)] = 3;
m_ammoCounts[static_cast<int>(AmmoType::OVERHEAT)] = 3;
m_ammoCounts[static_cast<int>(AmmoType::CORROSIVE)] = 2;
m_ammoCounts[static_cast<int>(AmmoType::GLITCH)] = 2;
m_ammoCounts[static_cast<int>(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<float>(m_gridX) * 2.0f, 0.8f, static_cast<float>(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<int>(m_facing) + 3) % 4;
m_facing = static_cast<Direction>(dirInt);
m_targetYaw -= 90.0f;
}
void Player::turnRight() {
int dirInt = (static_cast<int>(m_facing) + 1) % 4;
m_facing = static_cast<Direction>(dirInt);
m_targetYaw += 90.0f;
}
void Player::update(float dt) {
// Smooth translation
Vector3 targetPos = { static_cast<float>(m_gridX) * 2.0f, 0.8f, static_cast<float>(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<int>(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<std::string>& 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<int>(type);
if (idx >= 0 && idx < static_cast<int>(m_ammoCounts.size())) {
return m_ammoCounts[idx];
}
return 0;
}
void Player::addAmmo(AmmoType type, int count) {
int idx = static_cast<int>(type);
if (idx >= 0 && idx < static_cast<int>(m_ammoCounts.size()) && type != AmmoType::EMPTY) {
m_ammoCounts[idx] += count;
}
}
bool Player::consumeAmmo(AmmoType type) {
int idx = static_cast<int>(type);
if (idx >= 0 && idx < static_cast<int>(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<int>(type);
if (idx >= 0 && idx < static_cast<int>(m_ammoCounts.size())) {
m_ammoCounts[idx] = count;
}
}
} // namespace dnr

86
src/dungeon/Player.hpp Normal file
View file

@ -0,0 +1,86 @@
#pragma once
#include "core/Common.hpp"
#include "combat/Revolver.hpp"
#include <vector>
#include <array>
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<std::string>& 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<int, 7>& getAmmoCounts() const { return m_ammoCounts; }
// Compatibility wrappers
void addAmmoToPouch(AmmoType type) { addAmmo(type, 1); }
bool removeAmmoFromPouch(AmmoType type) { return consumeAmmo(type); }
const std::vector<ActiveStatus>& 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<int, 7> m_ammoCounts{ 0 };
Revolver m_revolver;
std::vector<ActiveStatus> m_statuses;
};
} // namespace dnr

View file

@ -0,0 +1,68 @@
#include "DbdSkillCheck.hpp"
#include "core/SoundManager.hpp"
#include <cstdlib>
#include <algorithm>
namespace dnr {
DbdSkillCheck::DbdSkillCheck() {
init(1.0f);
}
void DbdSkillCheck::init(float speedMultiplier) {
m_needleAngle = 0.0f;
m_speed = (180.0f + static_cast<float>(std::rand() % 40)) * speedMultiplier;
// Randomize success zone between 120 and 260 degrees
m_successStart = 120.0f + static_cast<float>(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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,90 @@
#include "TumblerLock.hpp"
#include "core/SoundManager.hpp"
#include <cstdlib>
#include <algorithm>
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<float>(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<int>(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<int>(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

View file

@ -0,0 +1,44 @@
#pragma once
#include "core/Common.hpp"
#include <vector>
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<int>(m_pins.size()); }
const TumblerPin& getPin(int index) const { return m_pins[index]; }
float getDecayRate() const { return m_decayRate; }
private:
std::vector<TumblerPin> m_pins;
int m_selectedPin = 0;
float m_decayRate = 0.25f;
};
} // namespace dnr

49
src/hub/HubRoom.cpp Normal file
View file

@ -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

33
src/hub/HubRoom.hpp Normal file
View file

@ -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

74
src/hub/RestRoom.cpp Normal file
View file

@ -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

44
src/hub/RestRoom.hpp Normal file
View file

@ -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

View file

@ -0,0 +1,330 @@
#include "InventoryUI.hpp"
#include "core/SoundManager.hpp"
#include <cmath>
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<int>(std::cos(angle) * chamberOrbitR);
int chY = cylCenterY + static_cast<int>(std::sin(angle) * chamberOrbitR);
if (mouseClicked && CheckCollisionPointCircle(mouse, { static_cast<float>(chX), static_cast<float>(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<float>(pouchX + 15), static_cast<float>(startY + i * 50), static_cast<float>(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<int>(std::cos(fluteAngle) * (cylRadius + 2.0f));
int fluteY = cylCenterY + static_cast<int>(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<int>(cylRadius) - 4;
int strikerBaseY = cylCenterY - static_cast<int>(cylRadius) - 28;
DrawTriangle(
{ static_cast<float>(cylCenterX), static_cast<float>(strikerTipY) },
{ static_cast<float>(cylCenterX - 14), static_cast<float>(strikerBaseY) },
{ static_cast<float>(cylCenterX + 14), static_cast<float>(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<int>(std::cos(angleRad) * chamberOrbitR);
int chY = cylCenterY + static_cast<int>(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<int>(chamberR) - 4, chY - static_cast<int>(chamberR) - 4,
static_cast<int>(chamberR * 2) + 8, static_cast<int>(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<int>(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<AmmoType>(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<float>(pouchX + 15), static_cast<float>(startY + i * 50), static_cast<float>(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

View file

@ -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

25
src/main.cpp Normal file
View file

@ -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;
}

1681
src/render/CyberRenderer.cpp Normal file

File diff suppressed because it is too large Load diff

View file

@ -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

1042
src/render/UIRenderer.cpp Normal file

File diff suppressed because it is too large Load diff

35
src/render/UIRenderer.hpp Normal file
View file

@ -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