#pragma once #include "engine/prefabs/includes.h" #include "entities/song.h" #include "rapidjson/filereadstream.h" #include "background.h" #include #include #include #include #include #include #include namespace { constexpr int LANE_COUNT = 12; constexpr int OCTAVE_COUNT = 3; constexpr int MIDI_LANE_MIN = 48; constexpr int MIDI_LANE_MAX = 83; constexpr int MIDI_LANE_COUNT = MIDI_LANE_MAX - MIDI_LANE_MIN + 1; constexpr int MAX_GAMEPADS = 4; constexpr int MAX_INSTRUMENT_TYPES = MAX_GAMEPADS; constexpr float RECEPTOR_HEIGHT = 150.0f; constexpr float HIT_ZONE_MARGIN = 20.0f; constexpr float SIMULTANEOUS_NOTE_Y_TOLERANCE = 2.0f; constexpr float SCROLL_PX_PER_SEC = 350.0f; constexpr float LEAD_OFFSET_SECONDS = 3.0f; constexpr float GLYPH_HEIGHT_FRACTION_OF_LANE = 0.5f; constexpr float MIN_SUSTAIN_FALLBACK_SEC = 0.05f; constexpr float MIN_GLYPH_DURATION_SEC = 0.1f; const int GAMEPAD_BUTTONS[LANE_COUNT] = { GAMEPAD_BUTTON_LEFT_FACE_LEFT, // Left GAMEPAD_BUTTON_LEFT_FACE_UP, // Up GAMEPAD_BUTTON_LEFT_FACE_RIGHT, // Right GAMEPAD_BUTTON_LEFT_FACE_DOWN, // Down GAMEPAD_BUTTON_RIGHT_FACE_LEFT, // X GAMEPAD_BUTTON_RIGHT_FACE_UP, // Y GAMEPAD_BUTTON_RIGHT_FACE_RIGHT, // B GAMEPAD_BUTTON_RIGHT_FACE_DOWN, // A GAMEPAD_BUTTON_LEFT_TRIGGER_2, // LT GAMEPAD_BUTTON_LEFT_TRIGGER_1, // LB GAMEPAD_BUTTON_RIGHT_TRIGGER_1, // RB GAMEPAD_BUTTON_RIGHT_TRIGGER_2, // RT }; const char* const GAMEPAD_BUTTON_LABELS[LANE_COUNT] = { "<", "^", ">", "v", "X", "Y", "B", "A", "LT", "LB", "RB", "RT", }; constexpr int COMBO_TIER_COUNT = 20; constexpr int COMBO_DISPLAY_THRESHOLD = 5; constexpr int COMBO_NOTES_PER_TIER = 10; const char* const COMBO_TIER_LABELS[COMBO_TIER_COUNT] = { "Cool!", "Awesome!", "Radical!", "Amazing!", "Incredible!", "Fantastic!", "Excellent!", "Outstanding!", "Perfect!", "Brilliant!", "Superb!", "Magnificent!", "Phenomenal!", "Splendid!", "Terrific!", "Stellar!", "Legendary!", "Epic!", "Divine!", "Godlike!", }; 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, }; /* One color per track (track index % MAX_INSTRUMENT_TYPES). */ const Color INSTRUMENT_COLORS[MAX_INSTRUMENT_TYPES] = { {220, 100, 100, 255}, // red {100, 160, 255, 255}, // blue {100, 220, 120, 255}, // green {255, 200, 80, 255}, // yellow }; const float INSTRUMENT_VOLUME[MAX_INSTRUMENT_TYPES] = { 0.4f, 0.6f, 1.0f, 0.7f }; /* Index i = MIDI note (48 + i); notes outside [48, 83] are not rendered. */ const char* const INSTRUMENT_LANE_WAV[MAX_INSTRUMENT_TYPES][MIDI_LANE_COUNT] { { // Instrument 0 - Synth "assets/sounds/snes_synth/snes_synth_048.wav", "assets/sounds/snes_synth/snes_synth_049.wav", "assets/sounds/snes_synth/snes_synth_050.wav", "assets/sounds/snes_synth/snes_synth_051.wav", "assets/sounds/snes_synth/snes_synth_052.wav", "assets/sounds/snes_synth/snes_synth_053.wav", "assets/sounds/snes_synth/snes_synth_054.wav", "assets/sounds/snes_synth/snes_synth_055.wav", "assets/sounds/snes_synth/snes_synth_056.wav", "assets/sounds/snes_synth/snes_synth_057.wav", "assets/sounds/snes_synth/snes_synth_058.wav", "assets/sounds/snes_synth/snes_synth_059.wav", "assets/sounds/snes_synth/snes_synth_060.wav", "assets/sounds/snes_synth/snes_synth_061.wav", "assets/sounds/snes_synth/snes_synth_062.wav", "assets/sounds/snes_synth/snes_synth_063.wav", "assets/sounds/snes_synth/snes_synth_064.wav", "assets/sounds/snes_synth/snes_synth_065.wav", "assets/sounds/snes_synth/snes_synth_066.wav", "assets/sounds/snes_synth/snes_synth_067.wav", "assets/sounds/snes_synth/snes_synth_068.wav", "assets/sounds/snes_synth/snes_synth_069.wav", "assets/sounds/snes_synth/snes_synth_070.wav", "assets/sounds/snes_synth/snes_synth_071.wav", "assets/sounds/snes_synth/snes_synth_072.wav", "assets/sounds/snes_synth/snes_synth_073.wav", "assets/sounds/snes_synth/snes_synth_074.wav", "assets/sounds/snes_synth/snes_synth_075.wav", "assets/sounds/snes_synth/snes_synth_076.wav", "assets/sounds/snes_synth/snes_synth_077.wav", "assets/sounds/snes_synth/snes_synth_078.wav", "assets/sounds/snes_synth/snes_synth_079.wav", "assets/sounds/snes_synth/snes_synth_080.wav", "assets/sounds/snes_synth/snes_synth_081.wav", "assets/sounds/snes_synth/snes_synth_082.wav", "assets/sounds/snes_synth/snes_synth_083.wav" }, { // Instrument 1 - Sax "assets/sounds/bari_sax/bari_sax_048.wav", "assets/sounds/bari_sax/bari_sax_049.wav", "assets/sounds/bari_sax/bari_sax_050.wav", "assets/sounds/bari_sax/bari_sax_051.wav", "assets/sounds/bari_sax/bari_sax_052.wav", "assets/sounds/bari_sax/bari_sax_053.wav", "assets/sounds/bari_sax/bari_sax_054.wav", "assets/sounds/bari_sax/bari_sax_055.wav", "assets/sounds/bari_sax/bari_sax_056.wav", "assets/sounds/bari_sax/bari_sax_057.wav", "assets/sounds/bari_sax/bari_sax_058.wav", "assets/sounds/bari_sax/bari_sax_059.wav", "assets/sounds/bari_sax/bari_sax_060.wav", "assets/sounds/bari_sax/bari_sax_061.wav", "assets/sounds/bari_sax/bari_sax_062.wav", "assets/sounds/bari_sax/bari_sax_063.wav", "assets/sounds/bari_sax/bari_sax_064.wav", "assets/sounds/bari_sax/bari_sax_065.wav", "assets/sounds/bari_sax/bari_sax_066.wav", "assets/sounds/bari_sax/bari_sax_067.wav", "assets/sounds/bari_sax/bari_sax_068.wav", "assets/sounds/bari_sax/bari_sax_069.wav", "assets/sounds/bari_sax/bari_sax_070.wav", "assets/sounds/bari_sax/bari_sax_071.wav", "assets/sounds/bari_sax/bari_sax_072.wav", "assets/sounds/bari_sax/bari_sax_073.wav", "assets/sounds/bari_sax/bari_sax_074.wav", "assets/sounds/bari_sax/bari_sax_075.wav", "assets/sounds/bari_sax/bari_sax_076.wav", "assets/sounds/bari_sax/bari_sax_077.wav", "assets/sounds/bari_sax/bari_sax_078.wav", "assets/sounds/bari_sax/bari_sax_079.wav", "assets/sounds/bari_sax/bari_sax_080.wav", "assets/sounds/bari_sax/bari_sax_081.wav", "assets/sounds/bari_sax/bari_sax_082.wav", "assets/sounds/bari_sax/bari_sax_083.wav" }, { // Instrument 2 - Strings "assets/sounds/strings/strings_048.wav", "assets/sounds/strings/strings_049.wav", "assets/sounds/strings/strings_050.wav", "assets/sounds/strings/strings_051.wav", "assets/sounds/strings/strings_052.wav", "assets/sounds/strings/strings_053.wav", "assets/sounds/strings/strings_054.wav", "assets/sounds/strings/strings_055.wav", "assets/sounds/strings/strings_056.wav", "assets/sounds/strings/strings_057.wav", "assets/sounds/strings/strings_058.wav", "assets/sounds/strings/strings_059.wav", "assets/sounds/strings/strings_060.wav", "assets/sounds/strings/strings_061.wav", "assets/sounds/strings/strings_062.wav", "assets/sounds/strings/strings_063.wav", "assets/sounds/strings/strings_064.wav", "assets/sounds/strings/strings_065.wav", "assets/sounds/strings/strings_066.wav", "assets/sounds/strings/strings_067.wav", "assets/sounds/strings/strings_068.wav", "assets/sounds/strings/strings_069.wav", "assets/sounds/strings/strings_070.wav", "assets/sounds/strings/strings_071.wav", "assets/sounds/strings/strings_072.wav", "assets/sounds/strings/strings_073.wav", "assets/sounds/strings/strings_074.wav", "assets/sounds/strings/strings_075.wav", "assets/sounds/strings/strings_076.wav", "assets/sounds/strings/strings_077.wav", "assets/sounds/strings/strings_078.wav", "assets/sounds/strings/strings_079.wav", "assets/sounds/strings/strings_080.wav", "assets/sounds/strings/strings_081.wav", "assets/sounds/strings/strings_082.wav", "assets/sounds/strings/strings_083.wav" }, { // Instrument 3 - Piano "assets/sounds/piano/piano_048.wav", "assets/sounds/piano/piano_049.wav", "assets/sounds/piano/piano_050.wav", "assets/sounds/piano/piano_051.wav", "assets/sounds/piano/piano_052.wav", "assets/sounds/piano/piano_053.wav", "assets/sounds/piano/piano_054.wav", "assets/sounds/piano/piano_055.wav", "assets/sounds/piano/piano_056.wav", "assets/sounds/piano/piano_057.wav", "assets/sounds/piano/piano_058.wav", "assets/sounds/piano/piano_059.wav", "assets/sounds/piano/piano_060.wav", "assets/sounds/piano/piano_061.wav", "assets/sounds/piano/piano_062.wav", "assets/sounds/piano/piano_063.wav", "assets/sounds/piano/piano_064.wav", "assets/sounds/piano/piano_065.wav", "assets/sounds/piano/piano_066.wav", "assets/sounds/piano/piano_067.wav", "assets/sounds/piano/piano_068.wav", "assets/sounds/piano/piano_069.wav", "assets/sounds/piano/piano_070.wav", "assets/sounds/piano/piano_071.wav", "assets/sounds/piano/piano_072.wav", "assets/sounds/piano/piano_073.wav", "assets/sounds/piano/piano_074.wav", "assets/sounds/piano/piano_075.wav", "assets/sounds/piano/piano_076.wav", "assets/sounds/piano/piano_077.wav", "assets/sounds/piano/piano_078.wav", "assets/sounds/piano/piano_079.wav", "assets/sounds/piano/piano_080.wav", "assets/sounds/piano/piano_081.wav", "assets/sounds/piano/piano_082.wav", "assets/sounds/piano/piano_083.wav" } }; struct Glyph { float time = 0.0f; // Time when the BOTTOM of the glyph hits the line (when note should be played) float duration_sec = 0.0f; // Duration extends upward from bottom (top = time + duration) int lane = 0; int instrument_slot = 0; int octave = 0; // Returns Y position of the bottom of the glyph float y_position(float song_time, float hit_line_y) const { return hit_line_y - (time - song_time) * SCROLL_PX_PER_SEC; } }; struct PendingSound { float play_time = 0.0f; float duration_sec = 0.0f; int lane = 0; int instrument_slot = 0; int octave = 0; }; struct ActiveSustainedSound { float start_time = 0.0f; float end_time = 0.0f; int lane = 0; int instrument_slot = 0; }; static size_t pick_track_by_note_count(const Song& song) { size_t best = 0; size_t best_count = 0; for (size_t i = 0; i < song.tracks.size(); i++) { size_t n = song.tracks[i].notes.size(); if (n > best_count) { best_count = n; best = i; } } return best; } std::vector chart_from_song(const Song& song, int track_override) { std::vector glyphs; if (song.tracks.empty()) return glyphs; size_t track_idx; if (track_override >= 0 && static_cast(track_override) < song.tracks.size()) { track_idx = static_cast(track_override); } else { track_idx = pick_track_by_note_count(song); } const Track& track = song.tracks[track_idx]; int ppq = song.header.ppq > 0 ? song.header.ppq : 480; float bpm = song.header.bpm > 0.0f ? song.header.bpm : 120.0f; float ticks_per_sec = ppq * (bpm / 60.0f); std::vector> timed_notes; for (const Note& note : track.notes) { if (note.midi >= MIDI_LANE_MIN && note.midi <= MIDI_LANE_MAX) timed_notes.push_back({note.ticks, ¬e}); } std::sort(timed_notes.begin(), timed_notes.end(), [](const auto& a, const auto& b) { if (a.first != b.first) return a.first < b.first; return a.second->midi < b.second->midi; }); int note_index = 0; for (const auto& pair : timed_notes) { const Note& note = *pair.second; float time_sec = note.ticks / ticks_per_sec; // This is now the BOTTOM time float duration_sec = note.duration_ticks / ticks_per_sec; int wav_index = note.midi - MIDI_LANE_MIN; int lane = wav_index % LANE_COUNT; int octave = wav_index; int instrument_slot = note_index % MAX_INSTRUMENT_TYPES; // Log original glyph timing (time is bottom, time + duration is top) float new_bottom_time = time_sec; float new_top_time = time_sec + duration_sec; Glyph* last_in_lane = nullptr; for (size_t i = glyphs.size(); i-- > 0;) { if (glyphs[i].lane == lane) { last_in_lane = &glyphs[i]; break; } } if (last_in_lane != nullptr) { // Check if existing glyph's top overlaps with new glyph's bottom float existing_bottom = last_in_lane->time; float existing_top = last_in_lane->time + last_in_lane->duration_sec; if (existing_top > new_bottom_time) { // Truncate existing glyph so its top doesn't go past new glyph's bottom float shortened_duration = new_bottom_time - existing_bottom; if (shortened_duration < MIN_GLYPH_DURATION_SEC) { note_index++; continue; } float old_duration = last_in_lane->duration_sec; last_in_lane->duration_sec = shortened_duration; } } glyphs.push_back(Glyph{time_sec, duration_sec, lane, instrument_slot, octave}); note_index++; } return glyphs; } std::vector load_chart(const char* path, int track_override) { std::vector empty; std::FILE* fp = std::fopen(path, "rb"); if (!fp) return empty; char read_buf[65536]; rapidjson::FileReadStream is(fp, read_buf, sizeof(read_buf)); rapidjson::Document doc; if (!doc.ParseStream(is).HasParseError()) { Song song = parseSong(doc); std::fclose(fp); return chart_from_song(song, track_override); } std::fclose(fp); return empty; } } // namespace extern std::string SELECTED_SONG_PATH; extern int SELECTED_TRACK_OVERRIDE; extern int INSTRUMENT_GAMEPAD_INDEX[MAX_INSTRUMENT_TYPES]; extern int INSTRUMENT_PHYSICAL_GAMEPAD[MAX_INSTRUMENT_TYPES]; class GHHBScene : public Scene { public: Font font = {0}; Music music = {0}; bool music_loaded = false; std::vector chart; std::vector spawned; std::unordered_set completed_notes; std::unordered_set missed_notes; std::vector pending_sounds; std::vector active_sustained; bool instrument_has_hits[MAX_INSTRUMENT_TYPES] = {false}; float song_time = 0.0f; float chart_time_offset = 0.0f; int score = 0; int combo = 0; float upper_bar_y = 0.0f; float hit_line_y = 0.0f; float lane_width = 0.0f; float screen_width = 0.0f; float screen_height = 0.0f; Sound note_sounds[MAX_INSTRUMENT_TYPES][MIDI_LANE_COUNT] = {{0}}; bool note_sounds_loaded[MAX_INSTRUMENT_TYPES][MIDI_LANE_COUNT] = {{false}}; std::deque note_sounds_playing[LANE_COUNT][MAX_INSTRUMENT_TYPES]; static constexpr float PRESS_FLASH_DURATION = 0.12f; float press_flash_timer[LANE_COUNT] = {0}; float hit_flash_timer[LANE_COUNT] = {0}; bool game_ended = false; bool dev_auto_hit_mode = false; static constexpr float RESULTS_DELAY_AFTER_LAST_NOTE = 1.0f; std::shared_ptr background; void on_enter() override { chart = load_chart(SELECTED_SONG_PATH.c_str(), SELECTED_TRACK_OVERRIDE); float first_note_time = 0.0f; if (!chart.empty()) { first_note_time = chart.front().time; float lead_seconds = (hit_line_y - 60.0f) / SCROLL_PX_PER_SEC; chart_time_offset = lead_seconds + LEAD_OFFSET_SECONDS - first_note_time; } song_time = 0.0f; score = 0; combo = 0; game_ended = false; dev_auto_hit_mode = false; spawned.clear(); completed_notes.clear(); missed_notes.clear(); pending_sounds.clear(); active_sustained.clear(); for (int i = 0; i < MAX_INSTRUMENT_TYPES; i++) { instrument_has_hits[i] = false; } for (int i = 0; i < LANE_COUNT; i++) { press_flash_timer[i] = 0.0f; hit_flash_timer[i] = 0.0f; } if (music_loaded) { StopMusicStream(music); PlayMusicStream(music); if (chart_time_offset < 0.0f) { SeekMusicStream(music, -chart_time_offset); } } } void on_exit() override { if (music_loaded) { StopMusicStream(music); } } ~GHHBScene() override { if (music_loaded) { StopMusicStream(music); UnloadMusicStream(music); } for (int slot = 0; slot < MAX_INSTRUMENT_TYPES; slot++) { for (int lane = 0; lane < MIDI_LANE_COUNT; lane++) { if (note_sounds_loaded[slot][lane]) { UnloadSound(note_sounds[slot][lane]); } } } } void init_services() override { add_service(); } void init() override { auto font_manager = game->get_manager(); font = font_manager->get_font("Roboto"); update_layout(); for (int slot = 0; slot < MAX_INSTRUMENT_TYPES; slot++) { for (int lane = 0; lane < MIDI_LANE_COUNT; lane++) { const char* path = INSTRUMENT_LANE_WAV[slot][lane]; if (FileExists(path)) { note_sounds[slot][lane] = LoadSound(path); note_sounds_loaded[slot][lane] = true; } } } background = add_game_object(); background->add_tag("background"); } 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_instrument_auto_played(int instrument_slot) const { return INSTRUMENT_PHYSICAL_GAMEPAD[instrument_slot] < 0; } bool is_lane_held_by_instrument(int lane, int instrument_slot) const { int physical_id = INSTRUMENT_PHYSICAL_GAMEPAD[instrument_slot]; if (physical_id < 0) return false; return IsGamepadAvailable(physical_id) && IsGamepadButtonDown(physical_id, GAMEPAD_BUTTONS[lane]); } void stop_playing_released_notes(int lane, float min_sustain_sec) { for (int slot = 0; slot < MAX_INSTRUMENT_TYPES; slot++) { if (is_instrument_auto_played(slot)) continue; if (is_lane_held_by_instrument(lane, slot)) continue; auto it = std::find_if(active_sustained.begin(), active_sustained.end(), [lane, slot](const ActiveSustainedSound& a) { return a.lane == lane && a.instrument_slot == slot; }); if (it == active_sustained.end()) continue; if (song_time < it->start_time + min_sustain_sec) continue; if (!note_sounds_playing[lane][slot].empty()) { StopSound(note_sounds_playing[lane][slot].front()); note_sounds_playing[lane][slot].pop_front(); } active_sustained.erase(it); } } bool lane_pressed_by_instrument_owner(int lane, int instrument_slot) const { int physical_id = INSTRUMENT_PHYSICAL_GAMEPAD[instrument_slot]; if (physical_id < 0) return false; return IsGamepadAvailable(physical_id) && IsGamepadButtonPressed(physical_id, GAMEPAD_BUTTONS[lane]); } 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_select_pressed() const { for (int i = 0; i < MAX_GAMEPADS; i++) { if (IsGamepadAvailable(i) && IsGamepadButtonPressed(i, GAMEPAD_BUTTON_MIDDLE_LEFT)) { return true; } } return false; } // Returns the Y position of the BOTTOM of the glyph // n.time represents when the bottom should reach the hit line float glyph_bottom_y(const Glyph& n) const { return hit_line_y - (n.time + chart_time_offset - song_time) * SCROLL_PX_PER_SEC; } static float glyph_height_px(const Glyph& n, float lane_width_val) { float min_height = lane_width_val * GLYPH_HEIGHT_FRACTION_OF_LANE; float duration_height = n.duration_sec * SCROLL_PX_PER_SEC; return std::max(min_height, duration_height); } bool is_note_hittable(const Glyph& n) const { float bottom_y = glyph_bottom_y(n); return bottom_y >= upper_bar_y - HIT_ZONE_MARGIN && bottom_y <= hit_line_y + HIT_ZONE_MARGIN; } void consume_note(Glyph* n, bool is_player_input = false) { 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; completed_notes.insert(n); // n.time is when the bottom should hit the line float bottom_hits_line_time = n->time + chart_time_offset; if (note_sounds_loaded[n->instrument_slot][n->octave]) pending_sounds.push_back( {bottom_hits_line_time, n->duration_sec, n->lane, n->instrument_slot, n->octave}); float bottom_y_n = glyph_bottom_y(*n); for (Glyph* other : spawned) { if (other == n) continue; if (other->lane != n->lane || other->instrument_slot != n->instrument_slot) continue; float bottom_y_other = glyph_bottom_y(*other); if (fabsf(bottom_y_other - bottom_y_n) <= SIMULTANEOUS_NOTE_Y_TOLERANCE) { completed_notes.insert(other); float other_bottom_hits_line_time = other->time + chart_time_offset; if (note_sounds_loaded[other->instrument_slot][other->octave]) pending_sounds.push_back({other_bottom_hits_line_time, other->duration_sec, other->lane, other->instrument_slot, other->octave}); } } } combo++; score += 100 + std::min(combo * 10, 50); // Only mark as "has hits" if this was actual player input if (is_player_input) { if (!instrument_has_hits[n->instrument_slot]) { TraceLog(LOG_INFO, "Marking instrument %d as actively played (PHYSICAL_GP=%d)", n->instrument_slot, INSTRUMENT_PHYSICAL_GAMEPAD[n->instrument_slot]); } instrument_has_hits[n->instrument_slot] = true; } TraceLog(LOG_INFO, "COMBO++ -> %d (lane %d, inst %d, player_input=%d)", combo, n->lane, n->instrument_slot, is_player_input); } void update_layout() { screen_width = static_cast(GetScreenWidth()); screen_height = static_cast(GetScreenHeight()); upper_bar_y = screen_height - RECEPTOR_HEIGHT; hit_line_y = screen_height - RECEPTOR_HEIGHT / 2.0f; lane_width = screen_width / LANE_COUNT; } void update(float delta_time) override { update_layout(); { static bool debug_bottom_printed = false; if (!debug_bottom_printed && !chart.empty() && lane_width > 0.0f) { for (const Glyph& g : chart) { float bottom_hits_line_time = g.time + chart_time_offset; float top_hits_line_time = bottom_hits_line_time + g.duration_sec; } debug_bottom_printed = true; } } if (is_select_pressed()) { dev_auto_hit_mode = !dev_auto_hit_mode; } 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 min_glyph_height_px = lane_width * GLYPH_HEIGHT_FRACTION_OF_LANE; float time_per_glyph_height = min_glyph_height_px > 0.f ? min_glyph_height_px / SCROLL_PX_PER_SEC : MIN_SUSTAIN_FALLBACK_SEC; for (auto it = pending_sounds.begin(); it != pending_sounds.end();) { if (song_time >= it->play_time) { Sound s = note_sounds[it->instrument_slot][it->octave]; SetSoundVolume(s, INSTRUMENT_VOLUME[it->instrument_slot]); PlaySound(s); note_sounds_playing[it->lane][it->instrument_slot].push_back(s); float sustain_sec = std::max(it->duration_sec, time_per_glyph_height); active_sustained.push_back( {it->play_time, it->play_time + sustain_sec, it->lane, it->instrument_slot}); it = pending_sounds.erase(it); } else { ++it; } } for (auto it = active_sustained.begin(); it != active_sustained.end();) { if (song_time >= it->end_time) { if (!note_sounds_playing[it->lane][it->instrument_slot].empty()) { StopSound(note_sounds_playing[it->lane][it->instrument_slot].front()); note_sounds_playing[it->lane][it->instrument_slot].pop_front(); } it = active_sustained.erase(it); } else { ++it; } } float last_note_time = 0.0f; for (const auto& n : chart) { float t = n.time + chart_time_offset; if (t > last_note_time) { last_note_time = t; } } float note_exit_seconds = (hit_line_y - upper_bar_y) / 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 + chart_time_offset - lead_seconds) { spawned.push_back(&n); } } for (Glyph* n : spawned) { if (completed_notes.count(n) != 0) continue; if (missed_notes.count(n) != 0) continue; bool is_auto = is_instrument_auto_played(n->instrument_slot); if (is_auto) continue; // Only reset combo for instruments that the player is actively playing bool has_hits = instrument_has_hits[n->instrument_slot]; if (!has_hits) continue; float bottom_y = glyph_bottom_y(*n); if (bottom_y > hit_line_y + HIT_ZONE_MARGIN) { missed_notes.insert(n); TraceLog(LOG_WARNING, "COMBO RESET -> 0 (missed note: lane %d, inst %d, is_auto=%d, has_hits=%d, PHYSICAL_GP[%d]=%d)", n->lane, n->instrument_slot, is_auto, has_hits, n->instrument_slot, INSTRUMENT_PHYSICAL_GAMEPAD[n->instrument_slot]); combo = 0; } } for (auto it = spawned.begin(); it != spawned.end();) { Glyph* n = *it; float bottom_y = glyph_bottom_y(*n); if (bottom_y > screen_height + 40.0f) { completed_notes.erase(n); missed_notes.erase(n); it = spawned.erase(it); } 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; } } std::vector to_auto_consume; for (Glyph* n : spawned) { if (completed_notes.count(n) != 0) continue; if (!is_note_hittable(*n)) continue; if (dev_auto_hit_mode || is_instrument_auto_played(n->instrument_slot)) to_auto_consume.push_back(n); } for (Glyph* n : to_auto_consume) { consume_note(n); } for (int lane = 0; lane < LANE_COUNT; lane++) { stop_playing_released_notes(lane, time_per_glyph_height); bool pressed = is_lane_pressed(lane); if (pressed) press_flash_timer[lane] = PRESS_FLASH_DURATION; 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; if (is_instrument_auto_played(n->instrument_slot)) continue; if (!lane_pressed_by_instrument_owner(lane, n->instrument_slot)) continue; float bottom_y = glyph_bottom_y(*n); float d = fabsf(bottom_y - hit_line_y); if (d < best_dist) { best_dist = d; best = n; } } if (best != nullptr && pressed) { consume_note(best, true); // Player input } else { TraceLog(LOG_WARNING, "COMBO RESET -> 0 (lane %d pressed but no valid note found)", lane); combo = 0; score = std::max(0, score - 25); } } if (is_menu_pressed()) { game->go_to_scene("title"); } } void draw_scene() override { if (background) { background->draw_object(); } draw(); } void draw() override { for (int lane = 0; lane < LANE_COUNT; lane++) { float cx = lane_center_x(lane); DrawLineEx(Vector2{cx, 0}, Vector2{cx, screen_height}, 2.0f, Color{70, 70, 90, 255}); } DrawRectangle(0, static_cast(upper_bar_y), 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(upper_bar_y), 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(upper_bar_y), static_cast(lane_width), static_cast(RECEPTOR_HEIGHT), Color{255, 255, 255, static_cast(alpha)}); } } DrawLineEx(Vector2{0, upper_bar_y}, Vector2{screen_width, upper_bar_y}, 3.0f, WHITE); DrawLineEx(Vector2{0, hit_line_y}, Vector2{screen_width, hit_line_y}, 3.0f, WHITE); const float button_label_font_size = 28.0f; const float button_label_y = hit_line_y + 18.0f; for (int lane = 0; lane < LANE_COUNT; lane++) { const char* label = GAMEPAD_BUTTON_LABELS[lane]; float label_w = MeasureTextEx(font, label, button_label_font_size, 1).x; float cx = lane_center_x(lane); DrawTextEx(font, label, {cx - label_w / 2.0f, button_label_y}, button_label_font_size, 1, Color{220, 220, 240, 255}); } const Color MISSED_GLYPH_COLOR = {140, 140, 140, 255}; std::vector> by_lane(static_cast(LANE_COUNT)); for (Glyph* n : spawned) { float height = glyph_height_px(*n, lane_width); float bottom = glyph_bottom_y(*n); float top = bottom - height; if (bottom < -40.0f || top > screen_height + 40.0f) { continue; } by_lane[static_cast(n->lane)].push_back(n); } for (int lane = 0; lane < LANE_COUNT; lane++) { std::vector& list = by_lane[static_cast(lane)]; // Sort by top Y (which is bottom - height, i.e., earlier in time) std::sort(list.begin(), list.end(), [this](Glyph* a, Glyph* b) { float top_a = glyph_bottom_y(*a) - glyph_height_px(*a, lane_width); float top_b = glyph_bottom_y(*b) - glyph_height_px(*b, lane_width); return top_a < top_b; }); float left_base = lane * lane_width; for (size_t i = 0; i < list.size();) { size_t j = i; float top_y0 = glyph_bottom_y(*list[i]) - glyph_height_px(*list[i], lane_width); while (j < list.size()) { float top_y_j = glyph_bottom_y(*list[j]) - glyph_height_px(*list[j], lane_width); if (top_y_j - top_y0 > SIMULTANEOUS_NOTE_Y_TOLERANCE) break; j++; } std::vector group(list.begin() + static_cast(i), list.begin() + static_cast(j)); std::sort(group.begin(), group.end(), [](Glyph* a, Glyph* b) { return a->instrument_slot < b->instrument_slot; }); int instrument_count = 0; for (size_t g = 0; g < group.size(); g++) { if (g == 0 || group[g]->instrument_slot != group[g - 1]->instrument_slot) { instrument_count++; } } float slice_width = lane_width / static_cast(instrument_count); int column = 0; for (size_t g = 0; g < group.size();) { int slot = group[g]->instrument_slot; float left = left_base + column * slice_width; Glyph* n = group[g]; float glyph_height = glyph_height_px(*n, lane_width); float bottom = glyph_bottom_y(*n); float top = bottom - glyph_height; bool missed = missed_notes.count(n) != 0; Color fill = missed ? MISSED_GLYPH_COLOR : INSTRUMENT_COLORS[n->instrument_slot]; Color edge = Color{ static_cast(std::min(255, fill.r + 35)), static_cast(std::min(255, fill.g + 50)), static_cast(std::min(255, fill.b + 50)), 255}; DrawRectangle(static_cast(left), static_cast(top), static_cast(slice_width), static_cast(glyph_height), fill); DrawRectangleLinesEx( Rectangle{left, top, slice_width, glyph_height}, 2.0f, edge); while (g < group.size() && group[g]->instrument_slot == slot) { g++; } column++; } i = j; } } std::string score_text = "Score: " + std::to_string(score); DrawTextEx(font, score_text.c_str(), {20, 16}, 28, 1, WHITE); if (combo >= COMBO_DISPLAY_THRESHOLD) { int tier_index = std::min(COMBO_TIER_COUNT - 1, (combo - COMBO_DISPLAY_THRESHOLD) / COMBO_NOTES_PER_TIER); int size_tier = (combo - COMBO_DISPLAY_THRESHOLD) / COMBO_NOTES_PER_TIER; std::string combo_popup = std::string(COMBO_TIER_LABELS[tier_index]) + " " + std::to_string(combo); const float combo_font_size = 72.0f + size_tier * 6.0f; float combo_w = MeasureTextEx(font, combo_popup.c_str(), combo_font_size, 1).x; const float combo_margin = 24.0f; DrawTextEx(font, combo_popup.c_str(), {screen_width - combo_w - combo_margin, combo_margin}, combo_font_size, 1, Color{255, 220, 100, 255}); } DrawTextEx(font, "Arrows / D-pad / X Y A B / LB LT RB RT: hit when note is between the two white lines", {20, upper_bar_y - 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}); std::string longest_combo_text = "Longest combo: " + std::to_string(longest_combo); float longest_combo_w = MeasureTextEx(font, longest_combo_text.c_str(), score_font_size, 1).x; DrawTextEx(font, longest_combo_text.c_str(), {screen_width / 2.0f - longest_combo_w / 2.0f, screen_height / 2.0f + 10}, score_font_size, 1, Color{220, 220, 255, 255}); int max_notes = 0; for (int i = 0; i < MAX_GAMEPADS; i++) { if (notes_hit_by_player[i] > max_notes) max_notes = notes_hit_by_player[i]; } if (max_notes > 0) { std::vector leaders; for (int i = 0; i < MAX_GAMEPADS; i++) { if (notes_hit_by_player[i] == max_notes) leaders.push_back(i + 1); } std::string most_notes_text; if (leaders.size() == 1) most_notes_text = "Most notes: Player " + std::to_string(leaders[0]) + " (" + std::to_string(max_notes) + ")"; else { most_notes_text = "Most notes: Players "; for (size_t i = 0; i < leaders.size(); i++) { if (i > 0) most_notes_text += " & "; most_notes_text += std::to_string(leaders[i]); } most_notes_text += " (" + std::to_string(max_notes) + ")"; } float most_notes_w = MeasureTextEx(font, most_notes_text.c_str(), score_font_size, 1).x; DrawTextEx(font, most_notes_text.c_str(), {screen_width / 2.0f - most_notes_w / 2.0f, screen_height / 2.0f + 40}, 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; float prompt_y = (max_notes > 0) ? (screen_height / 2.0f + 70) : (screen_height / 2.0f + 40); DrawTextEx(font, prompt, {screen_width / 2.0f - prompt_w / 2.0f, prompt_y}, prompt_font_size, 1, Color{180, 180, 200, 255}); } } };