Initial commit: Cyberpunk 2.5D Dungeon Crawler DoorNroll
This commit is contained in:
commit
5a76e602f1
60 changed files with 7166 additions and 0 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
53
CMakeLists.txt
Normal 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
76
README.md
Normal 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
|
||||
```
|
||||
83
assets/shaders/postprocess.fs
Normal file
83
assets/shaders/postprocess.fs
Normal 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;
|
||||
}
|
||||
20
assets/shaders/postprocess.vs
Normal file
20
assets/shaders/postprocess.vs
Normal 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);
|
||||
}
|
||||
BIN
assets/sounds/Revolver/empty.mp3
Normal file
BIN
assets/sounds/Revolver/empty.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/empty.wav
Normal file
BIN
assets/sounds/Revolver/empty.wav
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/loadshell.mp3
Normal file
BIN
assets/sounds/Revolver/loadshell.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/loadshell.wav
Normal file
BIN
assets/sounds/Revolver/loadshell.wav
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/one_shot.mp3
Normal file
BIN
assets/sounds/Revolver/one_shot.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/one_shot.wav
Normal file
BIN
assets/sounds/Revolver/one_shot.wav
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/roller.mp3
Normal file
BIN
assets/sounds/Revolver/roller.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/roller.wav
Normal file
BIN
assets/sounds/Revolver/roller.wav
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/three_shots.mp3
Normal file
BIN
assets/sounds/Revolver/three_shots.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/Revolver/three_shots.wav
Normal file
BIN
assets/sounds/Revolver/three_shots.wav
Normal file
Binary file not shown.
BIN
assets/sounds/ost/Kaito Shoma - Rush.m4a
Normal file
BIN
assets/sounds/ost/Kaito Shoma - Rush.m4a
Normal file
Binary file not shown.
BIN
assets/sounds/ost/Spiral Drive - Space Train.m4a
Normal file
BIN
assets/sounds/ost/Spiral Drive - Space Train.m4a
Normal file
Binary file not shown.
BIN
assets/sounds/ost/Tame Impala - Breathe Deeper.mp3
Normal file
BIN
assets/sounds/ost/Tame Impala - Breathe Deeper.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/ost/breathe_deeper.ogg
Normal file
BIN
assets/sounds/ost/breathe_deeper.ogg
Normal file
Binary file not shown.
BIN
assets/sounds/ost/i9incher - Zoom Noir.m4a
Normal file
BIN
assets/sounds/ost/i9incher - Zoom Noir.m4a
Normal file
Binary file not shown.
BIN
assets/sounds/ost/rush.ogg
Normal file
BIN
assets/sounds/ost/rush.ogg
Normal file
Binary file not shown.
BIN
assets/sounds/ost/space_train.ogg
Normal file
BIN
assets/sounds/ost/space_train.ogg
Normal file
Binary file not shown.
BIN
assets/sounds/ost/zoom_noir.ogg
Normal file
BIN
assets/sounds/ost/zoom_noir.ogg
Normal file
Binary file not shown.
2
run.bat
Normal file
2
run.bat
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@echo off
|
||||
start "" "%~dp0build\Release\DoorNroll.exe"
|
||||
301
src/combat/CombatSystem.cpp
Normal file
301
src/combat/CombatSystem.cpp
Normal 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
|
||||
77
src/combat/CombatSystem.hpp
Normal file
77
src/combat/CombatSystem.hpp
Normal 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
140
src/combat/Enemy.cpp
Normal 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
60
src/combat/Enemy.hpp
Normal 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
108
src/combat/Revolver.cpp
Normal 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
52
src/combat/Revolver.hpp
Normal 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
130
src/core/Common.hpp
Normal 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
795
src/core/Game.cpp
Normal 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
84
src/core/Game.hpp
Normal 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
84
src/core/SaveSystem.cpp
Normal 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
31
src/core/SaveSystem.hpp
Normal 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
402
src/core/SoundManager.cpp
Normal 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
115
src/core/SoundManager.hpp
Normal 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
120
src/dungeon/Dungeon.cpp
Normal 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
81
src/dungeon/Dungeon.hpp
Normal 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
239
src/dungeon/DungeonGen.cpp
Normal 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
|
||||
19
src/dungeon/DungeonGen.hpp
Normal file
19
src/dungeon/DungeonGen.hpp
Normal 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
195
src/dungeon/Player.cpp
Normal 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
86
src/dungeon/Player.hpp
Normal 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
|
||||
68
src/hacking/DbdSkillCheck.cpp
Normal file
68
src/hacking/DbdSkillCheck.cpp
Normal 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
|
||||
45
src/hacking/DbdSkillCheck.hpp
Normal file
45
src/hacking/DbdSkillCheck.hpp
Normal 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
|
||||
80
src/hacking/HackingGame.cpp
Normal file
80
src/hacking/HackingGame.cpp
Normal 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
|
||||
52
src/hacking/HackingGame.hpp
Normal file
52
src/hacking/HackingGame.hpp
Normal 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
|
||||
90
src/hacking/TumblerLock.cpp
Normal file
90
src/hacking/TumblerLock.cpp
Normal 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
|
||||
44
src/hacking/TumblerLock.hpp
Normal file
44
src/hacking/TumblerLock.hpp
Normal 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
49
src/hub/HubRoom.cpp
Normal 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
33
src/hub/HubRoom.hpp
Normal 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
74
src/hub/RestRoom.cpp
Normal 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
44
src/hub/RestRoom.hpp
Normal 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
|
||||
330
src/inventory/InventoryUI.cpp
Normal file
330
src/inventory/InventoryUI.cpp
Normal 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
|
||||
23
src/inventory/InventoryUI.hpp
Normal file
23
src/inventory/InventoryUI.hpp
Normal 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
25
src/main.cpp
Normal 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
1681
src/render/CyberRenderer.cpp
Normal file
File diff suppressed because it is too large
Load diff
69
src/render/CyberRenderer.hpp
Normal file
69
src/render/CyberRenderer.hpp
Normal 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
1042
src/render/UIRenderer.cpp
Normal file
File diff suppressed because it is too large
Load diff
35
src/render/UIRenderer.hpp
Normal file
35
src/render/UIRenderer.hpp
Normal 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
|
||||
Loading…
Add table
Reference in a new issue