guitarHeroButBetter/src/samples/ghhb_game.h
2026-01-31 01:01:20 -08:00

493 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 C4B4: 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});
}
}
};