493 lines
15 KiB
C++
493 lines
15 KiB
C++
/**
|
||
* DDR / Guitar Hero style rhythm game: 12 lanes, pre-coded notes.
|
||
* Supports multiple controllers: any connected gamepad can hit notes (shared chart/score).
|
||
* Controller: D-pad (lanes 0-3), face X/Y/A/B (4-7), LB/LT/RB/RT (8-11). 8bitdo compatible.
|
||
* Keyboard (Q/W/E/R/A/S/D/F/Z/X/C/V) remains supported.
|
||
*/
|
||
|
||
#pragma once
|
||
|
||
#include "engine/prefabs/includes.h"
|
||
#include <algorithm>
|
||
#include <unordered_set>
|
||
#include <vector>
|
||
|
||
namespace
|
||
{
|
||
constexpr int LANE_COUNT = 12;
|
||
constexpr int MAX_GAMEPADS = 4;
|
||
constexpr float HIT_WINDOW_PX = 80.0f;
|
||
constexpr float RECEPTOR_HEIGHT = 100.0f;
|
||
constexpr float SCROLL_PX_PER_SEC = 350.0f;
|
||
|
||
const int GAMEPAD_BUTTONS[LANE_COUNT] = {
|
||
GAMEPAD_BUTTON_LEFT_FACE_LEFT,
|
||
GAMEPAD_BUTTON_LEFT_FACE_UP,
|
||
GAMEPAD_BUTTON_LEFT_FACE_RIGHT,
|
||
GAMEPAD_BUTTON_LEFT_FACE_DOWN,
|
||
GAMEPAD_BUTTON_RIGHT_FACE_LEFT,
|
||
GAMEPAD_BUTTON_RIGHT_FACE_UP,
|
||
GAMEPAD_BUTTON_RIGHT_FACE_RIGHT,
|
||
GAMEPAD_BUTTON_RIGHT_FACE_DOWN,
|
||
GAMEPAD_BUTTON_LEFT_TRIGGER_2,
|
||
GAMEPAD_BUTTON_LEFT_TRIGGER_1,
|
||
GAMEPAD_BUTTON_RIGHT_TRIGGER_1,
|
||
GAMEPAD_BUTTON_RIGHT_TRIGGER_2,
|
||
};
|
||
|
||
const int KEY_KEYS[LANE_COUNT] = {
|
||
KEY_Q,
|
||
KEY_W,
|
||
KEY_E,
|
||
KEY_R,
|
||
KEY_A,
|
||
KEY_S,
|
||
KEY_D,
|
||
KEY_F,
|
||
KEY_Z,
|
||
KEY_X,
|
||
KEY_C,
|
||
KEY_V,
|
||
};
|
||
|
||
/* Chromatic scale C4–B4: one .wav per lane/button (assets/sounds/nes_harp, copied to build output). */
|
||
const char* const LANE_NOTE_WAV[LANE_COUNT] = {
|
||
"assets/sounds/nes_harp/nes_harp_C4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_Cs4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_D4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_Ds4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_E4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_F4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_Fs4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_G4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_Gs4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_A4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_As4.wav",
|
||
"assets/sounds/nes_harp/nes_harp_B4.wav",
|
||
};
|
||
|
||
struct Glyph
|
||
{
|
||
float time = 0.0f;
|
||
int lane = 0;
|
||
|
||
float y_position(float song_time, float hit_line_y) const
|
||
{
|
||
return hit_line_y - (time - song_time) * SCROLL_PX_PER_SEC;
|
||
}
|
||
};
|
||
|
||
std::vector<Glyph> default_chart()
|
||
{
|
||
std::vector<Glyph> glyphs;
|
||
float t = 2.0f;
|
||
for (int i = 0; i < 4; i++)
|
||
{
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
glyphs.push_back(Glyph{t, lane});
|
||
t += 0.4f;
|
||
}
|
||
t += 0.6f;
|
||
}
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
glyphs.push_back(Glyph{t, lane});
|
||
t += 0.2f;
|
||
}
|
||
t += 0.5f;
|
||
for (int i = 0; i < 3; i++)
|
||
{
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
glyphs.push_back(Glyph{t + lane * 0.08f, lane});
|
||
}
|
||
t += 0.8f;
|
||
}
|
||
return glyphs;
|
||
}
|
||
} // namespace
|
||
|
||
static const char* const GHHB_MUSIC_PATHS[] = {"assets/music/background.ogg", "assets/music/background.mp3"};
|
||
|
||
class GHHBScene : public Scene
|
||
{
|
||
public:
|
||
Font font = {0};
|
||
Music music = {0};
|
||
bool music_loaded = false;
|
||
std::vector<Glyph> chart = default_chart();
|
||
std::vector<Glyph*> spawned;
|
||
std::unordered_set<Glyph*> completed_notes;
|
||
float song_time = 0.0f;
|
||
int score = 0;
|
||
int combo = 0;
|
||
float hit_line_y = 0.0f;
|
||
float lane_width = 0.0f;
|
||
float screen_width = 0.0f;
|
||
float screen_height = 0.0f;
|
||
Sound note_sounds[LANE_COUNT] = {0};
|
||
bool note_sounds_loaded[LANE_COUNT] = {false};
|
||
static constexpr float PRESS_FLASH_DURATION = 0.12f;
|
||
static constexpr float MISS_FLASH_DURATION = 0.15f;
|
||
float press_flash_timer[LANE_COUNT] = {0};
|
||
float hit_flash_timer[LANE_COUNT] = {0};
|
||
float miss_flash_timer[LANE_COUNT] = {0};
|
||
bool game_ended = false;
|
||
static constexpr float RESULTS_DELAY_AFTER_LAST_NOTE = 1.0f;
|
||
|
||
void on_enter() override
|
||
{
|
||
song_time = 0.0f;
|
||
score = 0;
|
||
combo = 0;
|
||
game_ended = false;
|
||
spawned.clear();
|
||
completed_notes.clear();
|
||
for (int i = 0; i < LANE_COUNT; i++)
|
||
{
|
||
press_flash_timer[i] = 0.0f;
|
||
hit_flash_timer[i] = 0.0f;
|
||
miss_flash_timer[i] = 0.0f;
|
||
}
|
||
if (music_loaded)
|
||
{
|
||
StopMusicStream(music);
|
||
PlayMusicStream(music);
|
||
}
|
||
}
|
||
|
||
void on_exit() override
|
||
{
|
||
if (music_loaded)
|
||
{
|
||
StopMusicStream(music);
|
||
}
|
||
}
|
||
|
||
~GHHBScene() override
|
||
{
|
||
if (music_loaded)
|
||
{
|
||
StopMusicStream(music);
|
||
UnloadMusicStream(music);
|
||
}
|
||
for (int i = 0; i < LANE_COUNT; i++)
|
||
{
|
||
if (note_sounds_loaded[i])
|
||
{
|
||
UnloadSound(note_sounds[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
void init() override
|
||
{
|
||
auto font_manager = game->get_manager<FontManager>();
|
||
font = font_manager->get_font("Roboto");
|
||
screen_width = static_cast<float>(GetScreenWidth());
|
||
screen_height = static_cast<float>(GetScreenHeight());
|
||
hit_line_y = screen_height - RECEPTOR_HEIGHT / 2.0f;
|
||
lane_width = screen_width / LANE_COUNT;
|
||
for (const char* path : GHHB_MUSIC_PATHS)
|
||
{
|
||
if (FileExists(path))
|
||
{
|
||
music = LoadMusicStream(path);
|
||
music_loaded = true;
|
||
break;
|
||
}
|
||
}
|
||
for (int i = 0; i < LANE_COUNT; i++)
|
||
{
|
||
if (FileExists(LANE_NOTE_WAV[i]))
|
||
{
|
||
note_sounds[i] = LoadSound(LANE_NOTE_WAV[i]);
|
||
note_sounds_loaded[i] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
float lane_center_x(int lane) const
|
||
{
|
||
return (lane + 0.5f) * lane_width;
|
||
}
|
||
|
||
bool is_lane_pressed(int lane) const
|
||
{
|
||
if (IsKeyPressed(KEY_KEYS[lane]))
|
||
{
|
||
return true;
|
||
}
|
||
for (int i = 0; i < MAX_GAMEPADS; i++)
|
||
{
|
||
if (IsGamepadAvailable(i) && IsGamepadButtonPressed(i, GAMEPAD_BUTTONS[lane]))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool is_menu_pressed() const
|
||
{
|
||
if (IsKeyPressed(KEY_ENTER))
|
||
{
|
||
return true;
|
||
}
|
||
for (int i = 0; i < MAX_GAMEPADS; i++)
|
||
{
|
||
if (IsGamepadAvailable(i) && IsGamepadButtonPressed(i, GAMEPAD_BUTTON_MIDDLE_RIGHT))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool is_note_hittable(const Glyph& n) const
|
||
{
|
||
float y = n.y_position(song_time, hit_line_y);
|
||
return fabsf(y - hit_line_y) <= HIT_WINDOW_PX;
|
||
}
|
||
|
||
void consume_note(Glyph* n)
|
||
{
|
||
auto it = std::find_if(spawned.begin(), spawned.end(), [n](Glyph* p) { return p == n; });
|
||
if (it != spawned.end())
|
||
{
|
||
hit_flash_timer[n->lane] = PRESS_FLASH_DURATION;
|
||
spawned.erase(it);
|
||
completed_notes.insert(n);
|
||
}
|
||
combo++;
|
||
score += 100 + std::min(combo * 10, 50);
|
||
}
|
||
|
||
void update(float delta_time) override
|
||
{
|
||
if (game_ended)
|
||
{
|
||
if (is_menu_pressed())
|
||
{
|
||
game->go_to_scene("title");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (music_loaded)
|
||
{
|
||
UpdateMusicStream(music);
|
||
song_time = GetMusicTimePlayed(music);
|
||
}
|
||
else
|
||
{
|
||
song_time += delta_time;
|
||
}
|
||
|
||
float last_note_time = 0.0f;
|
||
for (const auto& n : chart)
|
||
{
|
||
if (n.time > last_note_time)
|
||
{
|
||
last_note_time = n.time;
|
||
}
|
||
}
|
||
float note_exit_seconds = HIT_WINDOW_PX / SCROLL_PX_PER_SEC;
|
||
if (song_time >= last_note_time + note_exit_seconds + RESULTS_DELAY_AFTER_LAST_NOTE)
|
||
{
|
||
game_ended = true;
|
||
return;
|
||
}
|
||
|
||
float lead_seconds = (hit_line_y - 60.0f) / SCROLL_PX_PER_SEC;
|
||
for (auto& n : chart)
|
||
{
|
||
if (completed_notes.count(&n) != 0)
|
||
{
|
||
continue;
|
||
}
|
||
bool already = std::find_if(spawned.begin(), spawned.end(),
|
||
[&n](Glyph* p) { return p == &n; }) != spawned.end();
|
||
if (!already && song_time >= n.time - lead_seconds)
|
||
{
|
||
spawned.push_back(&n);
|
||
}
|
||
}
|
||
|
||
for (auto it = spawned.begin(); it != spawned.end();)
|
||
{
|
||
Glyph* n = *it;
|
||
float y = n->y_position(song_time, hit_line_y);
|
||
if (y > hit_line_y)
|
||
{
|
||
miss_flash_timer[n->lane] = MISS_FLASH_DURATION;
|
||
completed_notes.insert(n);
|
||
it = spawned.erase(it);
|
||
combo = 0;
|
||
}
|
||
else
|
||
{
|
||
++it;
|
||
}
|
||
}
|
||
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
press_flash_timer[lane] -= delta_time;
|
||
if (press_flash_timer[lane] < 0.0f)
|
||
{
|
||
press_flash_timer[lane] = 0.0f;
|
||
}
|
||
hit_flash_timer[lane] -= delta_time;
|
||
if (hit_flash_timer[lane] < 0.0f)
|
||
{
|
||
hit_flash_timer[lane] = 0.0f;
|
||
}
|
||
miss_flash_timer[lane] -= delta_time;
|
||
if (miss_flash_timer[lane] < 0.0f)
|
||
{
|
||
miss_flash_timer[lane] = 0.0f;
|
||
}
|
||
bool pressed = is_lane_pressed(lane);
|
||
if (pressed)
|
||
{
|
||
press_flash_timer[lane] = PRESS_FLASH_DURATION;
|
||
if (note_sounds_loaded[lane])
|
||
{
|
||
PlaySound(note_sounds[lane]);
|
||
}
|
||
}
|
||
if (!pressed)
|
||
{
|
||
continue;
|
||
}
|
||
Glyph* best = nullptr;
|
||
float best_dist = 1e9f;
|
||
for (Glyph* n : spawned)
|
||
{
|
||
if (n->lane != lane)
|
||
{
|
||
continue;
|
||
}
|
||
if (!is_note_hittable(*n))
|
||
{
|
||
continue;
|
||
}
|
||
float y = n->y_position(song_time, hit_line_y);
|
||
float d = fabsf(y - hit_line_y);
|
||
if (d < best_dist)
|
||
{
|
||
best_dist = d;
|
||
best = n;
|
||
}
|
||
}
|
||
if (best != nullptr)
|
||
{
|
||
consume_note(best);
|
||
}
|
||
}
|
||
|
||
if (is_menu_pressed())
|
||
{
|
||
game->go_to_scene("title");
|
||
}
|
||
}
|
||
|
||
void draw() override
|
||
{
|
||
ClearBackground(Color{30, 30, 46, 255});
|
||
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
float cx = lane_center_x(lane);
|
||
DrawRectangle(static_cast<int>(lane * lane_width),
|
||
0,
|
||
static_cast<int>(lane_width),
|
||
static_cast<int>(screen_height),
|
||
lane % 2 == 0 ? Color{50, 50, 70, 255} : Color{45, 45, 65, 255});
|
||
DrawLineEx(Vector2{cx, 0}, Vector2{cx, screen_height}, 2.0f, Color{70, 70, 90, 255});
|
||
}
|
||
|
||
float receptor_top = screen_height - RECEPTOR_HEIGHT;
|
||
DrawRectangle(0,
|
||
static_cast<int>(receptor_top),
|
||
static_cast<int>(screen_width),
|
||
static_cast<int>(RECEPTOR_HEIGHT),
|
||
Color{60, 60, 100, 255});
|
||
for (int lane = 0; lane < LANE_COUNT; lane++)
|
||
{
|
||
if (hit_flash_timer[lane] > 0.0f)
|
||
{
|
||
float alpha = 180.0f * (hit_flash_timer[lane] / PRESS_FLASH_DURATION);
|
||
DrawRectangle(static_cast<int>(lane * lane_width),
|
||
static_cast<int>(receptor_top),
|
||
static_cast<int>(lane_width),
|
||
static_cast<int>(RECEPTOR_HEIGHT),
|
||
Color{80, 255, 120, static_cast<unsigned char>(alpha)});
|
||
}
|
||
else if (press_flash_timer[lane] > 0.0f)
|
||
{
|
||
float alpha = 180.0f * (press_flash_timer[lane] / PRESS_FLASH_DURATION);
|
||
DrawRectangle(static_cast<int>(lane * lane_width),
|
||
static_cast<int>(receptor_top),
|
||
static_cast<int>(lane_width),
|
||
static_cast<int>(RECEPTOR_HEIGHT),
|
||
Color{255, 255, 255, static_cast<unsigned char>(alpha)});
|
||
}
|
||
if (miss_flash_timer[lane] > 0.0f)
|
||
{
|
||
float alpha = 200.0f * (miss_flash_timer[lane] / MISS_FLASH_DURATION);
|
||
DrawRectangle(static_cast<int>(lane * lane_width),
|
||
static_cast<int>(receptor_top),
|
||
static_cast<int>(lane_width),
|
||
static_cast<int>(RECEPTOR_HEIGHT),
|
||
Color{255, 80, 80, static_cast<unsigned char>(alpha)});
|
||
}
|
||
}
|
||
DrawLineEx(Vector2{0, hit_line_y}, Vector2{screen_width, hit_line_y}, 3.0f, WHITE);
|
||
|
||
for (Glyph* n : spawned)
|
||
{
|
||
float y = n->y_position(song_time, hit_line_y);
|
||
if (y < -40.0f || y > screen_height + 40.0f)
|
||
{
|
||
continue;
|
||
}
|
||
float cx = lane_center_x(n->lane);
|
||
DrawCircle(static_cast<int>(cx), static_cast<int>(y), 22, Color{220, 100, 100, 255});
|
||
DrawCircleLines(static_cast<int>(cx), static_cast<int>(y), 22,
|
||
Color{255, 150, 150, 255});
|
||
}
|
||
|
||
std::string score_text = "Score: " + std::to_string(score) + " Combo: " + std::to_string(combo);
|
||
DrawTextEx(font, score_text.c_str(), {20, 16}, 28, 1, WHITE);
|
||
DrawTextEx(font, "Arrows / D-pad / X Y A B / LB LT RB RT: hit when note reaches white line",
|
||
{20, receptor_top - 28}, 18, 1, Color{200, 200, 200, 255});
|
||
|
||
if (game_ended)
|
||
{
|
||
DrawRectangle(0, 0, static_cast<int>(screen_width), static_cast<int>(screen_height),
|
||
Color{0, 0, 0, 200});
|
||
const char* title_text = "Song complete!";
|
||
const float title_font_size = 42.0f;
|
||
float title_w = MeasureTextEx(font, title_text, title_font_size, 1).x;
|
||
DrawTextEx(font, title_text,
|
||
{screen_width / 2.0f - title_w / 2.0f, screen_height / 2.0f - 80},
|
||
title_font_size, 1, WHITE);
|
||
std::string final_score_text = "Final score: " + std::to_string(score);
|
||
const float score_font_size = 32.0f;
|
||
float score_w = MeasureTextEx(font, final_score_text.c_str(), score_font_size, 1).x;
|
||
DrawTextEx(font, final_score_text.c_str(),
|
||
{screen_width / 2.0f - score_w / 2.0f, screen_height / 2.0f - 20},
|
||
score_font_size, 1, Color{220, 220, 255, 255});
|
||
const char* prompt = "Press Enter to return to menu";
|
||
const float prompt_font_size = 20.0f;
|
||
float prompt_w = MeasureTextEx(font, prompt, prompt_font_size, 1).x;
|
||
DrawTextEx(font, prompt,
|
||
{screen_width / 2.0f - prompt_w / 2.0f, screen_height / 2.0f + 40},
|
||
prompt_font_size, 1, Color{180, 180, 200, 255});
|
||
}
|
||
}
|
||
};
|