/** * 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 #include #include 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 default_chart() { std::vector 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 chart = default_chart(); std::vector spawned; std::unordered_set 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(); font = font_manager->get_font("Roboto"); screen_width = static_cast(GetScreenWidth()); screen_height = static_cast(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(lane * lane_width), 0, static_cast(lane_width), static_cast(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(receptor_top), static_cast(screen_width), static_cast(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(lane * lane_width), static_cast(receptor_top), static_cast(lane_width), static_cast(RECEPTOR_HEIGHT), Color{80, 255, 120, static_cast(alpha)}); } else if (press_flash_timer[lane] > 0.0f) { float alpha = 180.0f * (press_flash_timer[lane] / PRESS_FLASH_DURATION); DrawRectangle(static_cast(lane * lane_width), static_cast(receptor_top), static_cast(lane_width), static_cast(RECEPTOR_HEIGHT), Color{255, 255, 255, static_cast(alpha)}); } if (miss_flash_timer[lane] > 0.0f) { float alpha = 200.0f * (miss_flash_timer[lane] / MISS_FLASH_DURATION); DrawRectangle(static_cast(lane * lane_width), static_cast(receptor_top), static_cast(lane_width), static_cast(RECEPTOR_HEIGHT), Color{255, 80, 80, static_cast(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(cx), static_cast(y), 22, Color{220, 100, 100, 255}); DrawCircleLines(static_cast(cx), static_cast(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(screen_width), static_cast(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}); } } };