diff --git a/README.md b/README.md index dc39f48..a23e64c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,20 @@ My take on the classic snake game, built using C99, CMake and SDL3. - Avoid running into yourself, or it's game over! - Walls will wrap around to the other side of the screen. - Use `esc` to pause and unpause the game. +- Use the Options button on the start or pause menus to adjust volume or mute. + +## Config + +The game writes a `config.ini` file next to the executable on all platforms. If the file is missing or invalid, it is +recreated with defaults. + +Example: + +``` +high_score=12 +mute=0 +volume=0.80 +``` ## Building diff --git a/src/game/snake_input.c b/src/game/snake_input.c index d2ba546..eb8cdd4 100644 --- a/src/game/snake_input.c +++ b/src/game/snake_input.c @@ -7,6 +7,229 @@ #include "snake_text.h" #include "../modules/ui.h" +static void snake_options_set_volume(snake_t* snake, float volume) { + SDL_assert(snake != NULL); + + if (volume < 0.0f) { + volume = 0.0f; + } else if (volume > 1.0f) { + volume = 1.0f; + } + + snake->config.volume = volume; + if (snake_apply_audio_settings(snake) == false) { + SDL_Log("Failed to apply audio volume settings"); + } + if (snake_text_update_options_volume(snake) == false) { + snake->window.is_running = false; + return; + } + if (snake_save_config(snake) == false) { + SDL_Log("Failed to save config after volume change"); + } +} + +static int snake_options_get_resume_delay_from_mouse(const ui_slider_int_t* slider, float mouse_x) { + SDL_assert(slider != NULL); + + return ui_slider_int_get_value(slider, mouse_x); +} + +static void snake_options_toggle_mute(snake_t* snake) { + SDL_assert(snake != NULL); + + snake->config.mute = !snake->config.mute; + if (snake_apply_audio_settings(snake) == false) { + SDL_Log("Failed to apply mute setting"); + } + if (snake_save_config(snake) == false) { + SDL_Log("Failed to save config after mute toggle"); + } +} + +static void snake_options_set_resume_delay(snake_t* snake, int seconds) { + SDL_assert(snake != NULL); + + if (seconds < CONFIG_RESUME_DELAY_MIN) { + seconds = CONFIG_RESUME_DELAY_MIN; + } else if (seconds > CONFIG_RESUME_DELAY_MAX) { + seconds = CONFIG_RESUME_DELAY_MAX; + } + + snake->config.resume_delay_seconds = seconds; + if (snake_text_update_options_resume_delay(snake) == false) { + snake->window.is_running = false; + return; + } + if (snake_save_config(snake) == false) { + SDL_Log("Failed to save config after resume delay change"); + } +} + +static bool snake_options_handle_mouse(snake_t* snake, float mouse_x, float mouse_y, bool pressed) { + SDL_assert(snake != NULL); + + vector2i_t screen_size; + if (SDL_GetCurrentRenderOutputSize(snake->window.sdl_renderer, &screen_size.x, &screen_size.y) == false) { + SDL_Log("Failed to query render output size: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t title_size; + if (TTF_GetTextSize(snake->text_options_title, &title_size.x, &title_size.y) == false) { + SDL_Log("Failed to measure options title text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t volume_label_size; + if (TTF_GetTextSize(snake->text_options_volume_label, &volume_label_size.x, &volume_label_size.y) == false) { + SDL_Log("Failed to measure volume label text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t volume_value_size; + if (TTF_GetTextSize(snake->text_options_volume_value, &volume_value_size.x, &volume_value_size.y) == false) { + SDL_Log("Failed to measure volume value text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t mute_label_size; + if (TTF_GetTextSize(snake->text_options_mute_label, &mute_label_size.x, &mute_label_size.y) == false) { + SDL_Log("Failed to measure mute label text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t resume_label_size; + if (TTF_GetTextSize(snake->text_options_resume_label, &resume_label_size.x, &resume_label_size.y) == false) { + SDL_Log("Failed to measure resume delay label text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t resume_value_size; + if (TTF_GetTextSize(snake->text_options_resume_value, &resume_value_size.x, &resume_value_size.y) == false) { + SDL_Log("Failed to measure resume delay value text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + vector2i_t back_label_size; + if (TTF_GetTextSize(snake->text_options_back_button, &back_label_size.x, &back_label_size.y) == false) { + SDL_Log("Failed to measure back button text: %s", SDL_GetError()); + snake->window.is_running = false; + return false; + } + + const float slider_width = 220.f; + const float slider_height = 10.f; + const float knob_width = 14.f; + const float checkbox_size = 20.f; + const float content_gap = 16.f; + const float row_gap = 14.f; + const float row_height = SDL_max(slider_height + 8.f, checkbox_size); + + const float volume_row_width = (float)volume_label_size.x + content_gap + slider_width + content_gap + + (float)volume_value_size.x; + const float mute_row_width = (float)mute_label_size.x + content_gap + checkbox_size; + const float resume_row_width = (float)resume_label_size.x + content_gap + slider_width + content_gap + + (float)resume_value_size.x; + const float back_button_width = (float)back_label_size.x + 56.f; + + float content_width = SDL_max((float)title_size.x, volume_row_width); + content_width = SDL_max(content_width, mute_row_width); + content_width = SDL_max(content_width, resume_row_width); + content_width = SDL_max(content_width, back_button_width); + + const float content_height = (float)title_size.y + row_gap + row_height + row_gap + row_height + row_gap + + row_height + row_gap + (float)back_label_size.y + 24.f; + + ui_panel_t panel; + ui_panel_init(&panel, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}); + vector2i_t content_size = {(int)(content_width + 0.5f), (int)(content_height + 0.5f)}; + ui_panel_layout_from_content(&panel, &screen_size, &content_size, 20.f, 20.f); + + const float row_left = panel.rect.x + 20.f; + const float center_x = panel.rect.x + panel.rect.w * 0.5f; + float cursor_y = panel.rect.y + 20.f + (float)title_size.y + row_gap; + const float row_center_y = cursor_y + row_height * 0.5f; + float cursor_x = row_left + (float)volume_label_size.x + content_gap; + + ui_slider_t slider; + ui_slider_init(&slider, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}, + (SDL_Color){0, 0, 0, 0}); + ui_slider_layout(&slider, cursor_x + slider_width * 0.5f, row_center_y, slider_width, slider_height, knob_width); + + ui_checkbox_t checkbox; + ui_checkbox_init(&checkbox, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}); + cursor_y += row_height + row_gap; + const float mute_row_center_y = cursor_y + row_height * 0.5f; + cursor_x = row_left + (float)mute_label_size.x + content_gap + checkbox_size * 0.5f; + ui_checkbox_layout(&checkbox, cursor_x, mute_row_center_y, checkbox_size); + + ui_slider_int_t resume_slider; + ui_slider_int_init(&resume_slider, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}, + (SDL_Color){0, 0, 0, 0}, CONFIG_RESUME_DELAY_MIN, CONFIG_RESUME_DELAY_MAX); + cursor_y += row_height + row_gap; + const float resume_row_center_y = cursor_y + row_height * 0.5f; + cursor_x = row_left + (float)resume_label_size.x + content_gap; + ui_slider_int_layout(&resume_slider, cursor_x + slider_width * 0.5f, resume_row_center_y, slider_width, slider_height, + knob_width); + + ui_button_t back_button; + ui_button_init(&back_button, (SDL_Color){0, 0, 0, 0}, (SDL_Color){0, 0, 0, 0}); + cursor_y += row_height + row_gap; + ui_button_layout_from_label(&back_button, &back_label_size, center_x, cursor_y + back_label_size.y * 0.5f, 28.f, + 12.f); + + if (pressed == true) { + if (ui_slider_contains(&slider, mouse_x, mouse_y) == true) { + snake->options_dragging_volume = true; + snake->options_dragging_resume = false; + snake_options_set_volume(snake, ui_slider_get_value(&slider, mouse_x)); + return true; + } + + if (ui_checkbox_contains(&checkbox, mouse_x, mouse_y) == true) { + snake_options_toggle_mute(snake); + return true; + } + + if (ui_slider_int_contains(&resume_slider, mouse_x, mouse_y) == true) { + snake->options_dragging_resume = true; + snake->options_dragging_volume = false; + snake_options_set_resume_delay(snake, snake_options_get_resume_delay_from_mouse(&resume_slider, mouse_x)); + return true; + } + + if (ui_button_contains(&back_button, mouse_x, mouse_y) == true) { + snake->state = snake->options_return_state; + if (snake->state == SNAKE_STATE_PAUSED) { + if (snake_text_update_pause(snake) == false) { + snake->window.is_running = false; + } + } + snake->options_dragging_volume = false; + snake->options_dragging_resume = false; + return true; + } + } + + if (snake->options_dragging_volume == true && pressed == true) { + snake_options_set_volume(snake, ui_slider_get_value(&slider, mouse_x)); + } + + if (snake->options_dragging_resume == true && pressed == true) { + snake_options_set_resume_delay(snake, snake_options_get_resume_delay_from_mouse(&resume_slider, mouse_x)); + } + + return true; +} + void snake_handle_events(snake_t* snake) { SDL_assert(snake != NULL); @@ -25,6 +248,8 @@ void snake_handle_events(snake_t* snake) { } } else if (snake->state == SNAKE_STATE_PAUSED) { snake_state_begin_resume(snake); + } else if (snake->state == SNAKE_STATE_OPTIONS) { + snake->state = snake->options_return_state; } else if (snake->state == SNAKE_STATE_RESUMING) { snake->state = SNAKE_STATE_PAUSED; } @@ -37,8 +262,9 @@ void snake_handle_events(snake_t* snake) { event.button.down == true) { if (snake->state == SNAKE_STATE_PAUSED) { snake_menu_layout_t layout; - if (snake_menu_get_layout(snake, snake->text_pause, NULL, false, snake->text_resume, true, &layout) == - false) { + if (snake_menu_get_layout_with_secondary_button(snake, snake->text_pause, NULL, false, + snake->text_resume, true, snake->text_options_button, + true, &layout) == false) { return; } @@ -47,10 +273,18 @@ void snake_handle_events(snake_t* snake) { if (ui_button_contains(&button, event.button.x, event.button.y) == true) { snake_state_begin_resume(snake); } + + ui_button_t options_button = {0}; + options_button.rect = layout.secondary_button_rect; + if (ui_button_contains(&options_button, event.button.x, event.button.y) == true) { + snake_state_begin_options(snake, SNAKE_STATE_PAUSED); + } } else if (snake->state == SNAKE_STATE_START) { snake_menu_layout_t layout; - if (snake_menu_get_layout(snake, snake->text_start_title, NULL, false, snake->text_start_button, true, - &layout) == false) { + if (snake_menu_get_layout_with_secondary_button(snake, snake->text_start_title, + snake->text_start_high_score, true, + snake->text_start_button, true, snake->text_options_button, + true, &layout) == false) { return; } @@ -63,6 +297,12 @@ void snake_handle_events(snake_t* snake) { } snake->state = SNAKE_STATE_PLAYING; } + + ui_button_t options_button = {0}; + options_button.rect = layout.secondary_button_rect; + if (ui_button_contains(&options_button, event.button.x, event.button.y) == true) { + snake_state_begin_options(snake, SNAKE_STATE_START); + } } else if (snake->state == SNAKE_STATE_GAME_OVER) { snake_menu_layout_t layout; if (snake_menu_get_layout(snake, snake->text_game_over_title, snake->text_game_over_score, true, @@ -79,7 +319,20 @@ void snake_handle_events(snake_t* snake) { } snake->state = SNAKE_STATE_PLAYING; } + } else if (snake->state == SNAKE_STATE_OPTIONS) { + snake_options_handle_mouse(snake, event.button.x, event.button.y, true); } } + + if (event.type == SDL_EVENT_MOUSE_MOTION && snake->state == SNAKE_STATE_OPTIONS) { + const bool dragging_slider = snake->options_dragging_volume || snake->options_dragging_resume; + snake_options_handle_mouse(snake, event.motion.x, event.motion.y, dragging_slider); + } + + if (event.type == SDL_EVENT_MOUSE_BUTTON_UP && event.button.button == SDL_BUTTON_LEFT && + event.button.down == false && snake->state == SNAKE_STATE_OPTIONS) { + snake->options_dragging_volume = false; + snake->options_dragging_resume = false; + } } } diff --git a/src/game/snake_menu.c b/src/game/snake_menu.c index 6c3456b..bb716db 100644 --- a/src/game/snake_menu.c +++ b/src/game/snake_menu.c @@ -39,7 +39,8 @@ static bool get_text_size(snake_t* snake, TTF_Text* text, vector2i_t* out_size, static void compute_menu_layout(const vector2i_t* screen_size, const vector2i_t* title_size, const vector2i_t* subtitle_size, bool has_subtitle, const vector2i_t* button_label_size, - bool has_button, snake_menu_layout_t* out_layout) { + bool has_button, const vector2i_t* secondary_button_size, bool has_secondary, + snake_menu_layout_t* out_layout) { SDL_assert(screen_size != NULL); SDL_assert(title_size != NULL); SDL_assert(button_label_size != NULL); @@ -57,6 +58,15 @@ static void compute_menu_layout(const vector2i_t* screen_size, const vector2i_t* button_height = button.rect.h; } + float secondary_button_width = 0.f; + float secondary_button_height = 0.f; + if (has_secondary == true && secondary_button_size != NULL) { + ui_button_layout_from_label(&button, secondary_button_size, 0.f, 0.f, k_menu_button_padding_x, + k_menu_button_padding_y); + secondary_button_width = button.rect.w; + secondary_button_height = button.rect.h; + } + float content_width = (float)title_size->x; if (has_subtitle == true && subtitle_size != NULL) { content_width = SDL_max(content_width, (float)subtitle_size->x); @@ -64,6 +74,9 @@ static void compute_menu_layout(const vector2i_t* screen_size, const vector2i_t* if (has_button == true) { content_width = SDL_max(content_width, button_width); } + if (has_secondary == true) { + content_width = SDL_max(content_width, secondary_button_width); + } float content_height = (float)title_size->y; if (has_subtitle == true && subtitle_size != NULL) { @@ -72,6 +85,9 @@ static void compute_menu_layout(const vector2i_t* screen_size, const vector2i_t* if (has_button == true) { content_height += k_menu_text_gap + button_height; } + if (has_secondary == true) { + content_height += k_menu_text_gap + secondary_button_height; + } vector2i_t content_size = {(int)(content_width + 0.5f), (int)(content_height + 0.5f)}; @@ -101,9 +117,23 @@ static void compute_menu_layout(const vector2i_t* screen_size, const vector2i_t* ui_button_layout_from_label(&button, button_label_size, button_center_x, button_center_y, k_menu_button_padding_x, k_menu_button_padding_y); out_layout->button_rect = button.rect; + cursor_y += button_height; } else { out_layout->button_rect = (SDL_FRect){0.f, 0.f, 0.f, 0.f}; } + + out_layout->has_secondary_button = has_secondary; + if (has_secondary == true && secondary_button_size != NULL) { + cursor_y += k_menu_text_gap; + const float button_center_x = panel.rect.x + panel.rect.w * 0.5f; + const float button_center_y = cursor_y + secondary_button_height * 0.5f; + ui_button_layout_from_label(&button, secondary_button_size, button_center_x, button_center_y, + k_menu_button_padding_x, k_menu_button_padding_y); + out_layout->secondary_button_rect = button.rect; + cursor_y += secondary_button_height; + } else { + out_layout->secondary_button_rect = (SDL_FRect){0.f, 0.f, 0.f, 0.f}; + } } bool snake_menu_get_layout(snake_t* snake, TTF_Text* title_text, TTF_Text* subtitle_text, bool has_subtitle, @@ -140,6 +170,59 @@ bool snake_menu_get_layout(snake_t* snake, TTF_Text* title_text, TTF_Text* subti } } - compute_menu_layout(&screen_size, &title_size, &subtitle_size, has_subtitle, &button_size, has_button, out_layout); + vector2i_t secondary_size = {0, 0}; + compute_menu_layout(&screen_size, &title_size, &subtitle_size, has_subtitle, &button_size, has_button, + &secondary_size, false, out_layout); + return true; +} + +bool snake_menu_get_layout_with_secondary_button(snake_t* snake, TTF_Text* title_text, TTF_Text* subtitle_text, + bool has_subtitle, TTF_Text* primary_button_text, bool has_primary, + TTF_Text* secondary_button_text, bool has_secondary, + snake_menu_layout_t* out_layout) { + SDL_assert(snake != NULL); + SDL_assert(title_text != NULL); + SDL_assert(out_layout != NULL); + if (has_primary == true) { + SDL_assert(primary_button_text != NULL); + } + if (has_secondary == true) { + SDL_assert(secondary_button_text != NULL); + } + + vector2i_t screen_size; + if (get_screen_size(snake, &screen_size) == false) { + return false; + } + + vector2i_t title_size; + if (get_text_size(snake, title_text, &title_size, "title") == false) { + return false; + } + + vector2i_t subtitle_size = {0, 0}; + if (has_subtitle == true) { + SDL_assert(subtitle_text != NULL); + if (get_text_size(snake, subtitle_text, &subtitle_size, "subtitle") == false) { + return false; + } + } + + vector2i_t primary_size = {0, 0}; + if (has_primary == true) { + if (get_text_size(snake, primary_button_text, &primary_size, "primary button") == false) { + return false; + } + } + + vector2i_t secondary_size = {0, 0}; + if (has_secondary == true) { + if (get_text_size(snake, secondary_button_text, &secondary_size, "secondary button") == false) { + return false; + } + } + + compute_menu_layout(&screen_size, &title_size, &subtitle_size, has_subtitle, &primary_size, has_primary, + &secondary_size, has_secondary, out_layout); return true; } diff --git a/src/game/snake_menu.h b/src/game/snake_menu.h index 826d647..ccc922e 100644 --- a/src/game/snake_menu.h +++ b/src/game/snake_menu.h @@ -8,13 +8,19 @@ typedef struct { SDL_FRect panel_rect; SDL_FRect button_rect; + SDL_FRect secondary_button_rect; SDL_FPoint title_pos; SDL_FPoint subtitle_pos; bool has_subtitle; bool has_button; + bool has_secondary_button; } snake_menu_layout_t; bool snake_menu_get_layout(snake_t* snake, TTF_Text* title_text, TTF_Text* subtitle_text, bool has_subtitle, TTF_Text* button_text, bool has_button, snake_menu_layout_t* out_layout); +bool snake_menu_get_layout_with_secondary_button(snake_t* snake, TTF_Text* title_text, TTF_Text* subtitle_text, + bool has_subtitle, TTF_Text* primary_button_text, bool has_primary, + TTF_Text* secondary_button_text, bool has_secondary, + snake_menu_layout_t* out_layout); #endif // SNAKE_MENU_H diff --git a/src/game/snake_render.c b/src/game/snake_render.c index 0df8d9a..7492c76 100644 --- a/src/game/snake_render.c +++ b/src/game/snake_render.c @@ -10,6 +10,12 @@ static const SDL_Color k_color_menu_panel = {25, 25, 25, 220}; static const SDL_Color k_color_menu_panel_border = {80, 80, 80, 255}; static const SDL_Color k_color_menu_button = {220, 220, 220, 255}; static const SDL_Color k_color_menu_button_border = {180, 180, 180, 255}; +static const SDL_Color k_color_menu_checkbox = {30, 30, 30, 255}; +static const SDL_Color k_color_menu_checkbox_border = {180, 180, 180, 255}; +static const SDL_Color k_color_menu_checkbox_check = {220, 220, 220, 255}; +static const SDL_Color k_color_menu_slider_track = {40, 40, 40, 255}; +static const SDL_Color k_color_menu_slider_fill = {100, 200, 120, 255}; +static const SDL_Color k_color_menu_slider_knob = {230, 230, 230, 255}; static bool get_text_size(snake_t* snake, TTF_Text* text, vector2i_t* out_size, const char* label) { SDL_assert(snake != NULL); @@ -90,6 +96,8 @@ void snake_render_frame(snake_t* snake) { has_button = true; } else if (snake->state == SNAKE_STATE_START) { title_text = snake->text_start_title; + subtitle_text = snake->text_start_high_score; + has_subtitle = true; button_text = snake->text_start_button; has_button = true; } else if (snake->state == SNAKE_STATE_RESUMING) { @@ -105,9 +113,17 @@ void snake_render_frame(snake_t* snake) { } snake_menu_layout_t layout; - if (snake_menu_get_layout(snake, title_text, subtitle_text, has_subtitle, button_text, has_button, &layout) == - false) { - return; + if (snake->state == SNAKE_STATE_PAUSED || snake->state == SNAKE_STATE_START) { + if (snake_menu_get_layout_with_secondary_button(snake, title_text, subtitle_text, has_subtitle, button_text, + has_button, snake->text_options_button, true, &layout) == + false) { + return; + } + } else { + if (snake_menu_get_layout(snake, title_text, subtitle_text, has_subtitle, button_text, has_button, &layout) == + false) { + return; + } } if (SDL_SetRenderDrawBlendMode(snake->window.sdl_renderer, SDL_BLENDMODE_BLEND) == false) { @@ -184,6 +200,243 @@ void snake_render_frame(snake_t* snake) { } } + if (snake->state == SNAKE_STATE_PAUSED || snake->state == SNAKE_STATE_START) { + vector2i_t options_text_size; + if (get_text_size(snake, snake->text_options_button, &options_text_size, "options button") == false) { + return; + } + + ui_button_t options_button; + ui_button_init(&options_button, k_color_menu_button, k_color_menu_button_border); + options_button.rect = layout.secondary_button_rect; + if (ui_button_render(snake->window.sdl_renderer, &options_button) == false) { + SDL_Log("Failed to render options button: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + float options_text_x = 0.f; + float options_text_y = 0.f; + ui_button_get_label_position(&options_button, &options_text_size, &options_text_x, &options_text_y); + if (TTF_DrawRendererText(snake->text_options_button, options_text_x, options_text_y) == false) { + SDL_Log("Failed to render options button text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + } + + if (SDL_SetRenderDrawBlendMode(snake->window.sdl_renderer, SDL_BLENDMODE_NONE) == false) { + SDL_Log("Failed to reset blend mode: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + } + + if (snake->state == SNAKE_STATE_OPTIONS) { + vector2i_t screen_size_options; + if (SDL_GetCurrentRenderOutputSize(snake->window.sdl_renderer, &screen_size_options.x, + &screen_size_options.y) == false) { + SDL_Log("Failed to query render output size: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + if (SDL_SetRenderDrawBlendMode(snake->window.sdl_renderer, SDL_BLENDMODE_BLEND) == false) { + SDL_Log("Failed to set blend mode: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + SDL_SetRenderDrawColor(snake->window.sdl_renderer, k_color_menu_overlay.r, k_color_menu_overlay.g, + k_color_menu_overlay.b, k_color_menu_overlay.a); + SDL_FRect overlay_rect = {0.f, 0.f, (float)screen_size_options.x, (float)screen_size_options.y}; + SDL_RenderFillRect(snake->window.sdl_renderer, &overlay_rect); + + vector2i_t title_size; + if (get_text_size(snake, snake->text_options_title, &title_size, "options title") == false) { + return; + } + + vector2i_t volume_label_size; + if (get_text_size(snake, snake->text_options_volume_label, &volume_label_size, "volume label") == false) { + return; + } + + vector2i_t volume_value_size; + if (get_text_size(snake, snake->text_options_volume_value, &volume_value_size, "volume value") == false) { + return; + } + + vector2i_t mute_label_size; + if (get_text_size(snake, snake->text_options_mute_label, &mute_label_size, "mute label") == false) { + return; + } + + vector2i_t resume_label_size; + if (get_text_size(snake, snake->text_options_resume_label, &resume_label_size, "resume label") == false) { + return; + } + + vector2i_t resume_value_size; + if (get_text_size(snake, snake->text_options_resume_value, &resume_value_size, "resume value") == false) { + return; + } + + vector2i_t back_label_size; + if (get_text_size(snake, snake->text_options_back_button, &back_label_size, "back button") == false) { + return; + } + + const float slider_width = 220.f; + const float slider_height = 10.f; + const float knob_width = 14.f; + const float checkbox_size = 20.f; + const float content_gap = 16.f; + const float row_gap = 14.f; + const float row_height = SDL_max(slider_height + 8.f, checkbox_size); + + const float volume_row_width = (float)volume_label_size.x + content_gap + slider_width + content_gap + + (float)volume_value_size.x; + const float mute_row_width = (float)mute_label_size.x + content_gap + checkbox_size; + const float resume_row_width = (float)resume_label_size.x + content_gap + slider_width + content_gap + + (float)resume_value_size.x; + const float back_button_width = (float)back_label_size.x + 56.f; + + float content_width = SDL_max((float)title_size.x, volume_row_width); + content_width = SDL_max(content_width, mute_row_width); + content_width = SDL_max(content_width, resume_row_width); + content_width = SDL_max(content_width, back_button_width); + + const float content_height = (float)title_size.y + row_gap + row_height + row_gap + row_height + row_gap + + row_height + row_gap + (float)back_label_size.y + 24.f; + + ui_panel_t panel; + ui_panel_init(&panel, k_color_menu_panel, k_color_menu_panel_border); + vector2i_t content_size = {(int)(content_width + 0.5f), (int)(content_height + 0.5f)}; + ui_panel_layout_from_content(&panel, &screen_size_options, &content_size, 20.f, 20.f); + + if (ui_panel_render(snake->window.sdl_renderer, &panel) == false) { + SDL_Log("Failed to render options panel: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + float cursor_y = panel.rect.y + 20.f; + const float center_x = panel.rect.x + panel.rect.w * 0.5f; + + if (TTF_DrawRendererText(snake->text_options_title, center_x - (float)title_size.x * 0.5f, cursor_y) == + false) { + SDL_Log("Failed to render options title text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_y += (float)title_size.y + row_gap; + + const float row_left = center_x - content_width * 0.5f; + const float row_center_y = cursor_y + row_height * 0.5f; + float cursor_x = row_left; + + if (TTF_DrawRendererText(snake->text_options_volume_label, cursor_x, row_center_y - volume_label_size.y * 0.5f) == + false) { + SDL_Log("Failed to render volume label text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_x += (float)volume_label_size.x + content_gap; + + ui_slider_t slider; + ui_slider_init(&slider, k_color_menu_slider_track, k_color_menu_slider_fill, k_color_menu_slider_knob, + k_color_menu_button_border); + ui_slider_layout(&slider, cursor_x + slider_width * 0.5f, row_center_y, slider_width, slider_height, knob_width); + if (ui_slider_render(snake->window.sdl_renderer, &slider, snake->config.volume) == false) { + SDL_Log("Failed to render volume slider: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_x += slider_width + content_gap; + if (TTF_DrawRendererText(snake->text_options_volume_value, cursor_x, + row_center_y - volume_value_size.y * 0.5f) == false) { + SDL_Log("Failed to render volume value text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_y += row_height + row_gap; + const float mute_row_center_y = cursor_y + row_height * 0.5f; + cursor_x = row_left; + + if (TTF_DrawRendererText(snake->text_options_mute_label, cursor_x, mute_row_center_y - mute_label_size.y * 0.5f) == + false) { + SDL_Log("Failed to render mute label text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_x += (float)mute_label_size.x + content_gap + checkbox_size * 0.5f; + ui_checkbox_t checkbox; + ui_checkbox_init(&checkbox, k_color_menu_checkbox, k_color_menu_checkbox_border, k_color_menu_checkbox_check); + ui_checkbox_layout(&checkbox, cursor_x, mute_row_center_y, checkbox_size); + if (ui_checkbox_render(snake->window.sdl_renderer, &checkbox, snake->config.mute) == false) { + SDL_Log("Failed to render mute checkbox: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_y += row_height + row_gap; + const float resume_row_center_y = cursor_y + row_height * 0.5f; + cursor_x = row_left; + + if (TTF_DrawRendererText(snake->text_options_resume_label, cursor_x, + resume_row_center_y - resume_label_size.y * 0.5f) == false) { + SDL_Log("Failed to render resume label text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_x += (float)resume_label_size.x + content_gap; + ui_slider_int_t resume_slider; + ui_slider_int_init(&resume_slider, k_color_menu_slider_track, k_color_menu_slider_fill, k_color_menu_slider_knob, + k_color_menu_button_border, CONFIG_RESUME_DELAY_MIN, CONFIG_RESUME_DELAY_MAX); + ui_slider_int_layout(&resume_slider, cursor_x + slider_width * 0.5f, resume_row_center_y, slider_width, + slider_height, knob_width); + if (ui_slider_int_render(snake->window.sdl_renderer, &resume_slider, snake->config.resume_delay_seconds) == + false) { + SDL_Log("Failed to render resume delay slider: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_x += slider_width + content_gap; + if (TTF_DrawRendererText(snake->text_options_resume_value, cursor_x, + resume_row_center_y - resume_value_size.y * 0.5f) == false) { + SDL_Log("Failed to render resume value text: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + cursor_y += row_height + row_gap; + ui_button_t back_button; + ui_button_init(&back_button, k_color_menu_button, k_color_menu_button_border); + ui_button_layout_from_label(&back_button, &back_label_size, center_x, cursor_y + back_label_size.y * 0.5f, + 28.f, 12.f); + if (ui_button_render(snake->window.sdl_renderer, &back_button) == false) { + SDL_Log("Failed to render options back button: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + + float back_label_x = 0.f; + float back_label_y = 0.f; + ui_button_get_label_position(&back_button, &back_label_size, &back_label_x, &back_label_y); + if (TTF_DrawRendererText(snake->text_options_back_button, back_label_x, back_label_y) == false) { + SDL_Log("Failed to render options back label: %s", SDL_GetError()); + snake->window.is_running = false; + return; + } + if (SDL_SetRenderDrawBlendMode(snake->window.sdl_renderer, SDL_BLENDMODE_NONE) == false) { SDL_Log("Failed to reset blend mode: %s", SDL_GetError()); snake->window.is_running = false; diff --git a/src/game/snake_state.c b/src/game/snake_state.c index 9b22c34..81e7f01 100644 --- a/src/game/snake_state.c +++ b/src/game/snake_state.c @@ -317,7 +317,13 @@ void snake_state_begin_resume(snake_t* snake) { SDL_assert(snake != NULL); snake->state = SNAKE_STATE_RESUMING; - snake->resume_countdown_end_ms = SDL_GetTicks() + 3000u; + if (snake->config.resume_delay_seconds <= 0) { + snake->state = SNAKE_STATE_PLAYING; + return; + } + + snake->resume_countdown_end_ms = + SDL_GetTicks() + (Uint64)(snake->config.resume_delay_seconds * 1000); snake->resume_countdown_value = -1; const int seconds = get_resume_seconds_remaining(SDL_GetTicks(), snake->resume_countdown_end_ms); @@ -328,6 +334,19 @@ void snake_state_begin_resume(snake_t* snake) { } } +void snake_state_begin_options(snake_t* snake, snake_game_state_t return_state) { + SDL_assert(snake != NULL); + + snake->options_return_state = return_state; + snake->options_dragging_volume = false; + snake->options_dragging_resume = false; + if (snake_text_update_options_labels(snake) == false) { + snake->window.is_running = false; + return; + } + snake->state = SNAKE_STATE_OPTIONS; +} + void snake_update_fixed(snake_t* snake) { SDL_assert(snake != NULL); @@ -357,6 +376,16 @@ void snake_update_fixed(snake_t* snake) { if (test_body_collision(snake) == true) { SDL_Log("Collision detected! Score: %zu", snake->array_body.size); snake->state = SNAKE_STATE_GAME_OVER; + if (snake->array_body.size > snake->config.high_score) { + snake->config.high_score = snake->array_body.size; + if (snake_save_config(snake) == false) { + SDL_Log("Failed to save config after new high score"); + } + if (snake_text_update_start_high_score(snake) == false) { + snake->window.is_running = false; + return; + } + } if (snake_text_update_game_over(snake) == false) { snake->window.is_running = false; } diff --git a/src/game/snake_state.h b/src/game/snake_state.h index 8942918..1bea34a 100644 --- a/src/game/snake_state.h +++ b/src/game/snake_state.h @@ -8,5 +8,6 @@ bool snake_state_reset(snake_t* snake); void snake_state_handle_movement_key(snake_t* snake, SDL_Scancode scancode); void snake_state_begin_resume(snake_t* snake); +void snake_state_begin_options(snake_t* snake, snake_game_state_t return_state); #endif // SNAKE_STATE_H diff --git a/src/game/snake_text.c b/src/game/snake_text.c index 3e2be70..85e8802 100644 --- a/src/game/snake_text.c +++ b/src/game/snake_text.c @@ -136,6 +136,23 @@ bool snake_text_create(snake_t* snake) { return false; } + const char* options_label = "Options"; + snake->text_options_button = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + options_label, SDL_strlen(options_label)); + if (snake->text_options_button == NULL) { + SDL_Log("Failed to create options button text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_button, 20, 20, 20, 255) == false) { + SDL_Log("Failed to set options button text color: %s", SDL_GetError()); + return false; + } + + if (snake_text_update_start_high_score(snake) == false) { + return false; + } + const char* game_over_title = "Game Over"; snake->text_game_over_title = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, game_over_title, SDL_strlen(game_over_title)); @@ -168,6 +185,10 @@ bool snake_text_create(snake_t* snake) { return false; } + if (snake_text_update_high_score(snake) == false) { + return false; + } + const char* restart_label = "Restart"; snake->text_restart_button = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, restart_label, SDL_strlen(restart_label)); @@ -205,6 +226,76 @@ bool snake_text_create(snake_t* snake) { return false; } + const char* options_title = "Options"; + snake->text_options_title = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + options_title, SDL_strlen(options_title)); + if (snake->text_options_title == NULL) { + SDL_Log("Failed to create options title text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_title, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set options title text color: %s", SDL_GetError()); + return false; + } + + const char* volume_label = "Volume"; + snake->text_options_volume_label = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + volume_label, SDL_strlen(volume_label)); + if (snake->text_options_volume_label == NULL) { + SDL_Log("Failed to create volume label text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_volume_label, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set volume label text color: %s", SDL_GetError()); + return false; + } + + const char* mute_label = "Mute"; + snake->text_options_mute_label = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + mute_label, SDL_strlen(mute_label)); + if (snake->text_options_mute_label == NULL) { + SDL_Log("Failed to create mute label text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_mute_label, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set mute label text color: %s", SDL_GetError()); + return false; + } + + const char* resume_delay_label = "Resume Delay"; + snake->text_options_resume_label = + TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, resume_delay_label, + SDL_strlen(resume_delay_label)); + if (snake->text_options_resume_label == NULL) { + SDL_Log("Failed to create resume delay label text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_resume_label, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set resume delay label text color: %s", SDL_GetError()); + return false; + } + + const char* back_label = "Back"; + snake->text_options_back_button = TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + back_label, SDL_strlen(back_label)); + if (snake->text_options_back_button == NULL) { + SDL_Log("Failed to create back button text object: %s", SDL_GetError()); + return false; + } + + if (TTF_SetTextColor(snake->text_options_back_button, 20, 20, 20, 255) == false) { + SDL_Log("Failed to set back button text color: %s", SDL_GetError()); + return false; + } + + if (snake_text_update_options_labels(snake) == false) { + return false; + } + return true; } @@ -236,6 +327,16 @@ void snake_text_destroy(snake_t* snake) { snake->text_start_button = NULL; } + if (snake->text_start_high_score != NULL) { + TTF_DestroyText(snake->text_start_high_score); + snake->text_start_high_score = NULL; + } + + if (snake->text_options_button != NULL) { + TTF_DestroyText(snake->text_options_button); + snake->text_options_button = NULL; + } + if (snake->text_game_over_title != NULL) { TTF_DestroyText(snake->text_game_over_title); snake->text_game_over_title = NULL; @@ -266,6 +367,41 @@ void snake_text_destroy(snake_t* snake) { } snake->text_resume_countdown_size.x = 0; snake->text_resume_countdown_size.y = 0; + + if (snake->text_options_title != NULL) { + TTF_DestroyText(snake->text_options_title); + snake->text_options_title = NULL; + } + + if (snake->text_options_volume_label != NULL) { + TTF_DestroyText(snake->text_options_volume_label); + snake->text_options_volume_label = NULL; + } + + if (snake->text_options_mute_label != NULL) { + TTF_DestroyText(snake->text_options_mute_label); + snake->text_options_mute_label = NULL; + } + + if (snake->text_options_resume_label != NULL) { + TTF_DestroyText(snake->text_options_resume_label); + snake->text_options_resume_label = NULL; + } + + if (snake->text_options_back_button != NULL) { + TTF_DestroyText(snake->text_options_back_button); + snake->text_options_back_button = NULL; + } + + if (snake->text_options_volume_value != NULL) { + TTF_DestroyText(snake->text_options_volume_value); + snake->text_options_volume_value = NULL; + } + + if (snake->text_options_resume_value != NULL) { + TTF_DestroyText(snake->text_options_resume_value); + snake->text_options_resume_value = NULL; + } } bool snake_text_update_score(snake_t* snake) { @@ -310,8 +446,9 @@ bool snake_text_update_game_over(snake_t* snake) { SDL_assert(snake != NULL); SDL_assert(snake->text_game_over_score != NULL); - const int written = snprintf(snake->text_game_over_score_buffer, sizeof(snake->text_game_over_score_buffer), - "Final Score: %zu", snake->array_body.size); + const int written = + snprintf(snake->text_game_over_score_buffer, sizeof(snake->text_game_over_score_buffer), + "Final Score: %zu | High Score: %zu", snake->array_body.size, snake->config.high_score); if (written < 0 || (size_t)written >= sizeof(snake->text_game_over_score_buffer)) { SDL_Log("Failed to format game over score text."); return false; @@ -325,6 +462,127 @@ bool snake_text_update_game_over(snake_t* snake) { return true; } +bool snake_text_update_high_score(snake_t* snake) { + SDL_assert(snake != NULL); + SDL_assert(snake->text_game_over_score != NULL); + + return snake_text_update_game_over(snake); +} + +bool snake_text_update_start_high_score(snake_t* snake) { + SDL_assert(snake != NULL); + + const int written = snprintf(snake->text_start_high_score_buffer, sizeof(snake->text_start_high_score_buffer), + "High Score: %zu", snake->config.high_score); + if (written < 0 || (size_t)written >= sizeof(snake->text_start_high_score_buffer)) { + SDL_Log("Failed to format start high score text."); + return false; + } + + if (snake->text_start_high_score == NULL) { + snake->text_start_high_score = + TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + snake->text_start_high_score_buffer, (size_t)written); + if (snake->text_start_high_score == NULL) { + SDL_Log("Failed to create start high score text object: %s", SDL_GetError()); + return false; + } + if (TTF_SetTextColor(snake->text_start_high_score, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set start high score text color: %s", SDL_GetError()); + return false; + } + } + + if (TTF_SetTextString(snake->text_start_high_score, snake->text_start_high_score_buffer, (size_t)written) == + false) { + SDL_Log("Failed to update start high score text: %s", SDL_GetError()); + return false; + } + + return true; +} + +bool snake_text_update_options_labels(snake_t* snake) { + SDL_assert(snake != NULL); + SDL_assert(snake->text_options_volume_label != NULL); + SDL_assert(snake->text_options_mute_label != NULL); + SDL_assert(snake->text_options_title != NULL); + SDL_assert(snake->text_options_back_button != NULL); + + if (snake_text_update_options_volume(snake) == false) { + return false; + } + + return snake_text_update_options_resume_delay(snake); +} + +bool snake_text_update_options_volume(snake_t* snake) { + SDL_assert(snake != NULL); + + const int written = snprintf(snake->text_options_volume_value_buffer, + sizeof(snake->text_options_volume_value_buffer), "%.2f", snake->config.volume); + if (written < 0 || (size_t)written >= sizeof(snake->text_options_volume_value_buffer)) { + SDL_Log("Failed to format options volume value."); + return false; + } + + if (snake->text_options_volume_value == NULL) { + snake->text_options_volume_value = + TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + snake->text_options_volume_value_buffer, (size_t)written); + if (snake->text_options_volume_value == NULL) { + SDL_Log("Failed to create options volume value text: %s", SDL_GetError()); + return false; + } + if (TTF_SetTextColor(snake->text_options_volume_value, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set options volume value text color: %s", SDL_GetError()); + return false; + } + } + + if (TTF_SetTextString(snake->text_options_volume_value, snake->text_options_volume_value_buffer, (size_t)written) == + false) { + SDL_Log("Failed to update options volume value text: %s", SDL_GetError()); + return false; + } + + return true; +} + +bool snake_text_update_options_resume_delay(snake_t* snake) { + SDL_assert(snake != NULL); + + const int written = snprintf(snake->text_options_resume_value_buffer, + sizeof(snake->text_options_resume_value_buffer), "%d", + snake->config.resume_delay_seconds); + if (written < 0 || (size_t)written >= sizeof(snake->text_options_resume_value_buffer)) { + SDL_Log("Failed to format options resume delay."); + return false; + } + + if (snake->text_options_resume_value == NULL) { + snake->text_options_resume_value = + TTF_CreateText(snake->window.ttf_text_engine, snake->window.ttf_font_default, + snake->text_options_resume_value_buffer, (size_t)written); + if (snake->text_options_resume_value == NULL) { + SDL_Log("Failed to create resume delay value text: %s", SDL_GetError()); + return false; + } + if (TTF_SetTextColor(snake->text_options_resume_value, 255, 255, 255, 255) == false) { + SDL_Log("Failed to set resume delay value text color: %s", SDL_GetError()); + return false; + } + } + + if (TTF_SetTextString(snake->text_options_resume_value, snake->text_options_resume_value_buffer, (size_t)written) == + false) { + SDL_Log("Failed to update resume delay value text: %s", SDL_GetError()); + return false; + } + + return true; +} + bool snake_text_update_resume_countdown(snake_t* snake, int seconds) { SDL_assert(snake != NULL); diff --git a/src/game/snake_text.h b/src/game/snake_text.h index 1e0bcaa..39f002c 100644 --- a/src/game/snake_text.h +++ b/src/game/snake_text.h @@ -10,5 +10,10 @@ bool snake_text_update_score(snake_t* snake); bool snake_text_update_pause(snake_t* snake); bool snake_text_update_game_over(snake_t* snake); bool snake_text_update_resume_countdown(snake_t* snake, int seconds); +bool snake_text_update_high_score(snake_t* snake); +bool snake_text_update_start_high_score(snake_t* snake); +bool snake_text_update_options_labels(snake_t* snake); +bool snake_text_update_options_volume(snake_t* snake); +bool snake_text_update_options_resume_delay(snake_t* snake); #endif // SNAKE_TEXT_H diff --git a/src/modules/audio.c b/src/modules/audio.c index 3addd05..e69f645 100644 --- a/src/modules/audio.c +++ b/src/modules/audio.c @@ -4,6 +4,32 @@ #include #include +static float clamp_volume(float volume) { + if (volume < 0.0f) { + return 0.0f; + } + if (volume > 1.0f) { + return 1.0f; + } + return volume; +} + +static bool apply_gain(audio_manager_t* manager) { + SDL_assert(manager != NULL); + + if (manager->stream == NULL) { + return false; + } + + const float gain = manager->is_muted ? 0.0f : manager->volume; + if (SDL_SetAudioStreamGain(manager->stream, gain) == false) { + SDL_Log("Failed to set audio stream gain: %s", SDL_GetError()); + return false; + } + + return true; +} + bool audio_manager_create(audio_manager_t* manager) { SDL_assert(manager != NULL); @@ -32,6 +58,15 @@ bool audio_manager_create(audio_manager_t* manager) { } manager->is_initialized = true; + manager->volume = 1.0f; + manager->is_muted = false; + if (apply_gain(manager) == false) { + SDL_DestroyAudioStream(manager->stream); + manager->stream = NULL; + SDL_QuitSubSystem(SDL_INIT_AUDIO); + manager->is_initialized = false; + return false; + } return true; } @@ -140,3 +175,35 @@ bool audio_manager_play_sound(audio_manager_t* manager, sound_id_t id) { return true; } + +bool audio_manager_set_volume(audio_manager_t* manager, float volume) { + SDL_assert(manager != NULL); + + if (manager->is_initialized == false) { + return false; + } + + manager->volume = clamp_volume(volume); + return apply_gain(manager); +} + +bool audio_manager_set_muted(audio_manager_t* manager, bool muted) { + SDL_assert(manager != NULL); + + if (manager->is_initialized == false) { + return false; + } + + manager->is_muted = muted; + return apply_gain(manager); +} + +float audio_manager_get_volume(const audio_manager_t* manager) { + SDL_assert(manager != NULL); + return manager->volume; +} + +bool audio_manager_is_muted(const audio_manager_t* manager) { + SDL_assert(manager != NULL); + return manager->is_muted; +} diff --git a/src/modules/audio.h b/src/modules/audio.h index 0c44556..6a26844 100644 --- a/src/modules/audio.h +++ b/src/modules/audio.h @@ -20,6 +20,8 @@ typedef struct { SDL_AudioStream* stream; sound_data_t sounds[SOUND_COUNT]; bool is_initialized; + bool is_muted; + float volume; } audio_manager_t; bool audio_manager_create(audio_manager_t* manager); @@ -27,5 +29,9 @@ void audio_manager_destroy(audio_manager_t* manager); bool audio_manager_load_sound(audio_manager_t* manager, sound_id_t id, const char* filepath); bool audio_manager_play_sound(audio_manager_t* manager, sound_id_t id); +bool audio_manager_set_volume(audio_manager_t* manager, float volume); +bool audio_manager_set_muted(audio_manager_t* manager, bool muted); +float audio_manager_get_volume(const audio_manager_t* manager); +bool audio_manager_is_muted(const audio_manager_t* manager); #endif // AUDIO_H diff --git a/src/modules/config.c b/src/modules/config.c new file mode 100644 index 0000000..c36f296 --- /dev/null +++ b/src/modules/config.c @@ -0,0 +1,296 @@ +#include "config.h" + +#include +#include +#include + +#include + +static const char* k_config_filename = "config.ini"; + +static void config_clamp(game_config_t* config) { + SDL_assert(config != NULL); + + if (config->volume < 0.0f) { + config->volume = 0.0f; + } else if (config->volume > 1.0f) { + config->volume = 1.0f; + } + + if (config->resume_delay_seconds < CONFIG_RESUME_DELAY_MIN) { + config->resume_delay_seconds = CONFIG_RESUME_DELAY_MIN; + } else if (config->resume_delay_seconds > CONFIG_RESUME_DELAY_MAX) { + config->resume_delay_seconds = CONFIG_RESUME_DELAY_MAX; + } +} + +void config_set_defaults(game_config_t* config) { + SDL_assert(config != NULL); + + config->high_score = 0; + config->mute = false; + config->volume = 1.0f; + config->resume_delay_seconds = CONFIG_RESUME_DELAY_DEFAULT; +} + +static bool config_build_path_from_base(const char* base_path, char* out_path, size_t path_size) { + SDL_assert(base_path != NULL); + SDL_assert(out_path != NULL); + SDL_assert(path_size > 0); + + const int written = snprintf(out_path, path_size, "%s%s", base_path, k_config_filename); + if (written < 0 || (size_t)written >= path_size) { + SDL_Log("Config path is too long"); + return false; + } + + return true; +} + +static bool config_get_paths(char* out_primary, size_t primary_size, char* out_fallback, size_t fallback_size) { + SDL_assert(out_primary != NULL); + SDL_assert(out_fallback != NULL); + SDL_assert(primary_size > 0); + SDL_assert(fallback_size > 0); + + char* base_path = SDL_GetBasePath(); + const char* base_path_to_use = base_path; + if (base_path_to_use == NULL || base_path_to_use[0] == '\0') { + SDL_Log("Failed to get base path for config: %s", SDL_GetError()); + base_path_to_use = "./"; + } + + bool success = config_build_path_from_base(base_path_to_use, out_primary, primary_size); + if (success == true) { + success = config_build_path_from_base("./", out_fallback, fallback_size); + } + + if (base_path != NULL) { + SDL_free(base_path); + } + + return success; +} + +static bool config_parse_bool(const char* value, bool* out_value) { + SDL_assert(value != NULL); + SDL_assert(out_value != NULL); + + if (SDL_strcasecmp(value, "true") == 0 || SDL_strcasecmp(value, "1") == 0 || + SDL_strcasecmp(value, "yes") == 0) { + *out_value = true; + return true; + } + + if (SDL_strcasecmp(value, "false") == 0 || SDL_strcasecmp(value, "0") == 0 || + SDL_strcasecmp(value, "no") == 0) { + *out_value = false; + return true; + } + + return false; +} + +static bool config_parse_float(const char* value, float* out_value) { + SDL_assert(value != NULL); + SDL_assert(out_value != NULL); + + char* end_ptr = NULL; + const float parsed = strtof(value, &end_ptr); + if (end_ptr == value || *end_ptr != '\0') { + return false; + } + + *out_value = parsed; + return true; +} + +static bool config_parse_size(const char* value, size_t* out_value) { + SDL_assert(value != NULL); + SDL_assert(out_value != NULL); + + char* end_ptr = NULL; + const unsigned long long parsed = strtoull(value, &end_ptr, 10); + if (end_ptr == value || *end_ptr != '\0') { + return false; + } + + *out_value = (size_t)parsed; + return true; +} + +static bool config_write_file(const char* path, const game_config_t* config) { + SDL_assert(path != NULL); + SDL_assert(config != NULL); + + char temp_path[512]; + const int temp_written = snprintf(temp_path, sizeof(temp_path), "%s.tmp", path); + if (temp_written < 0 || (size_t)temp_written >= sizeof(temp_path)) { + SDL_Log("Config temp path is too long"); + return false; + } + + FILE* file = fopen(temp_path, "wb"); + if (file == NULL) { + SDL_Log("Failed to open config for writing: %s", temp_path); + return false; + } + + const int written = fprintf(file, "high_score=%zu\nmute=%d\nvolume=%.3f\nresume_delay=%d\n", config->high_score, + config->mute ? 1 : 0, config->volume, config->resume_delay_seconds); + if (written <= 0) { + SDL_Log("Failed to write config contents"); + fclose(file); + return false; + } + + if (fclose(file) != 0) { + SDL_Log("Failed to close config after writing"); + return false; + } + + if (rename(temp_path, path) != 0) { + SDL_Log("Failed to replace config file"); + if (remove(path) != 0 || rename(temp_path, path) != 0) { + return false; + } + } + + SDL_Log("Config saved to %s", path); + return true; +} + +bool config_save(const game_config_t* config) { + SDL_assert(config != NULL); + + char primary_path[512]; + char fallback_path[512]; + if (config_get_paths(primary_path, sizeof(primary_path), fallback_path, sizeof(fallback_path)) == false) { + return false; + } + + if (config_write_file(primary_path, config) == true) { + return true; + } + + if (SDL_strcmp(primary_path, fallback_path) != 0) { + SDL_Log("Falling back to config path: %s", fallback_path); + return config_write_file(fallback_path, config); + } + + return false; +} + +static bool config_read_file(const char* path, game_config_t* config, bool* out_invalid) { + SDL_assert(path != NULL); + SDL_assert(config != NULL); + SDL_assert(out_invalid != NULL); + + *out_invalid = false; + FILE* file = fopen(path, "rb"); + if (file == NULL) { + return false; + } + + char line[256]; + while (fgets(line, (int)sizeof(line), file) != NULL) { + char* newline = strchr(line, '\n'); + if (newline != NULL) { + *newline = '\0'; + } + + if (line[0] == '\0' || line[0] == '#') { + continue; + } + + char* separator = strchr(line, '='); + if (separator == NULL) { + SDL_Log("Invalid config line (missing '='): %s", line); + *out_invalid = true; + break; + } + + *separator = '\0'; + const char* key = line; + const char* value = separator + 1; + + if (SDL_strcasecmp(key, "high_score") == 0) { + size_t parsed = 0; + if (config_parse_size(value, &parsed) == false) { + SDL_Log("Invalid high_score value: %s", value); + *out_invalid = true; + break; + } + config->high_score = parsed; + } else if (SDL_strcasecmp(key, "mute") == 0) { + bool parsed = false; + if (config_parse_bool(value, &parsed) == false) { + SDL_Log("Invalid mute value: %s", value); + *out_invalid = true; + break; + } + config->mute = parsed; + } else if (SDL_strcasecmp(key, "volume") == 0) { + float parsed = 0.0f; + if (config_parse_float(value, &parsed) == false) { + SDL_Log("Invalid volume value: %s", value); + *out_invalid = true; + break; + } + config->volume = parsed; + } else if (SDL_strcasecmp(key, "resume_delay") == 0) { + size_t parsed = 0; + if (config_parse_size(value, &parsed) == false) { + SDL_Log("Invalid resume_delay value: %s", value); + *out_invalid = true; + break; + } + config->resume_delay_seconds = (int)parsed; + } + } + + fclose(file); + return *out_invalid == false; +} + +bool config_load(game_config_t* config) { + SDL_assert(config != NULL); + + config_set_defaults(config); + + char primary_path[512]; + char fallback_path[512]; + if (config_get_paths(primary_path, sizeof(primary_path), fallback_path, sizeof(fallback_path)) == false) { + return false; + } + + bool invalid = false; + const char* loaded_path = primary_path; + if (config_read_file(primary_path, config, &invalid) == false) { + if (invalid == true) { + config_set_defaults(config); + return config_save(config); + } + + if (SDL_strcmp(primary_path, fallback_path) != 0 && + config_read_file(fallback_path, config, &invalid) == true) { + SDL_Log("Loaded config from fallback path: %s", fallback_path); + loaded_path = fallback_path; + } else if (invalid == true) { + config_set_defaults(config); + return config_save(config); + } else { + SDL_Log("Config file missing, creating defaults: %s", primary_path); + return config_save(config); + } + } + + config_clamp(config); + if (config_save(config) == false) { + SDL_Log("Failed to rewrite config with normalized values"); + return false; + } + + SDL_Log("Config loaded from %s", loaded_path); + return true; +} diff --git a/src/modules/config.h b/src/modules/config.h new file mode 100644 index 0000000..c9bea96 --- /dev/null +++ b/src/modules/config.h @@ -0,0 +1,22 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include +#include + +#define CONFIG_RESUME_DELAY_MIN 0 +#define CONFIG_RESUME_DELAY_MAX 3 +#define CONFIG_RESUME_DELAY_DEFAULT 2 + +typedef struct { + size_t high_score; + bool mute; + float volume; + int resume_delay_seconds; +} game_config_t; + +void config_set_defaults(game_config_t* config); +bool config_load(game_config_t* config); +bool config_save(const game_config_t* config); + +#endif // CONFIG_H diff --git a/src/modules/ui.c b/src/modules/ui.c index 9bafcf9..a4bb5a7 100644 --- a/src/modules/ui.c +++ b/src/modules/ui.c @@ -171,3 +171,163 @@ bool ui_checkbox_render(SDL_Renderer* renderer, const ui_checkbox_t* checkbox, b return true; } + +void ui_slider_init(ui_slider_t* slider, SDL_Color track_color, SDL_Color fill_color, SDL_Color knob_color, + SDL_Color border_color) { + SDL_assert(slider != NULL); + + slider->track_rect = (SDL_FRect){0.f, 0.f, 0.f, 0.f}; + slider->knob_rect = (SDL_FRect){0.f, 0.f, 0.f, 0.f}; + slider->track_color = track_color; + slider->fill_color = fill_color; + slider->knob_color = knob_color; + slider->border_color = border_color; +} + +void ui_slider_layout(ui_slider_t* slider, float center_x, float center_y, float width, float height, float knob_width) { + SDL_assert(slider != NULL); + + slider->track_rect.w = width; + slider->track_rect.h = height; + slider->track_rect.x = center_x - width * 0.5f; + slider->track_rect.y = center_y - height * 0.5f; + + slider->knob_rect.w = knob_width; + slider->knob_rect.h = height + 8.f; + slider->knob_rect.y = center_y - slider->knob_rect.h * 0.5f; +} + +bool ui_slider_contains(const ui_slider_t* slider, float x, float y) { + SDL_assert(slider != NULL); + + const float min_x = slider->track_rect.x - slider->knob_rect.w * 0.5f; + const float max_x = slider->track_rect.x + slider->track_rect.w + slider->knob_rect.w * 0.5f; + const float min_y = SDL_min(slider->track_rect.y, slider->knob_rect.y); + const float max_y = SDL_max(slider->track_rect.y + slider->track_rect.h, + slider->knob_rect.y + slider->knob_rect.h); + return x >= min_x && x <= max_x && y >= min_y && y <= max_y; +} + +float ui_slider_get_value(const ui_slider_t* slider, float x) { + SDL_assert(slider != NULL); + + const float start = slider->track_rect.x; + const float end = slider->track_rect.x + slider->track_rect.w; + if (end <= start) { + return 0.0f; + } + + float value = (x - start) / (end - start); + if (value < 0.0f) { + value = 0.0f; + } else if (value > 1.0f) { + value = 1.0f; + } + return value; +} + +bool ui_slider_render(SDL_Renderer* renderer, const ui_slider_t* slider, float value) { + SDL_assert(renderer != NULL); + SDL_assert(slider != NULL); + + ui_slider_t mutable_slider = *slider; + + if (value < 0.0f) { + value = 0.0f; + } else if (value > 1.0f) { + value = 1.0f; + } + + SDL_SetRenderDrawColor(renderer, slider->track_color.r, slider->track_color.g, slider->track_color.b, + slider->track_color.a); + if (SDL_RenderFillRect(renderer, &slider->track_rect) == false) { + return false; + } + + SDL_SetRenderDrawColor(renderer, slider->fill_color.r, slider->fill_color.g, slider->fill_color.b, + slider->fill_color.a); + SDL_FRect fill_rect = slider->track_rect; + fill_rect.w = slider->track_rect.w * value; + if (SDL_RenderFillRect(renderer, &fill_rect) == false) { + return false; + } + + if (slider->border_color.a > 0) { + SDL_SetRenderDrawColor(renderer, slider->border_color.r, slider->border_color.g, slider->border_color.b, + slider->border_color.a); + if (SDL_RenderRect(renderer, &slider->track_rect) == false) { + return false; + } + } + + mutable_slider.knob_rect.x = + slider->track_rect.x + slider->track_rect.w * value - slider->knob_rect.w * 0.5f; + SDL_SetRenderDrawColor(renderer, slider->knob_color.r, slider->knob_color.g, slider->knob_color.b, + slider->knob_color.a); + if (SDL_RenderFillRect(renderer, &mutable_slider.knob_rect) == false) { + return false; + } + + if (slider->border_color.a > 0) { + SDL_SetRenderDrawColor(renderer, slider->border_color.r, slider->border_color.g, slider->border_color.b, + slider->border_color.a); + if (SDL_RenderRect(renderer, &mutable_slider.knob_rect) == false) { + return false; + } + } + + return true; +} + +void ui_slider_int_init(ui_slider_int_t* slider, SDL_Color track_color, SDL_Color fill_color, SDL_Color knob_color, + SDL_Color border_color, int min_value, int max_value) { + SDL_assert(slider != NULL); + + ui_slider_init(&slider->slider, track_color, fill_color, knob_color, border_color); + slider->min_value = min_value; + slider->max_value = max_value; +} + +void ui_slider_int_layout(ui_slider_int_t* slider, float center_x, float center_y, float width, float height, + float knob_width) { + SDL_assert(slider != NULL); + + ui_slider_layout(&slider->slider, center_x, center_y, width, height, knob_width); +} + +bool ui_slider_int_contains(const ui_slider_int_t* slider, float x, float y) { + SDL_assert(slider != NULL); + + return ui_slider_contains(&slider->slider, x, y); +} + +int ui_slider_int_get_value(const ui_slider_int_t* slider, float x) { + SDL_assert(slider != NULL); + + const int range = slider->max_value - slider->min_value; + if (range <= 0) { + return slider->min_value; + } + + const float value = ui_slider_get_value(&slider->slider, x); + return slider->min_value + (int)((value * (float)range) + 0.5f); +} + +bool ui_slider_int_render(SDL_Renderer* renderer, const ui_slider_int_t* slider, int value) { + SDL_assert(renderer != NULL); + SDL_assert(slider != NULL); + + const int range = slider->max_value - slider->min_value; + if (range <= 0) { + return ui_slider_render(renderer, &slider->slider, 0.0f); + } + + float normalized = (float)(value - slider->min_value) / (float)range; + if (normalized < 0.0f) { + normalized = 0.0f; + } else if (normalized > 1.0f) { + normalized = 1.0f; + } + + return ui_slider_render(renderer, &slider->slider, normalized); +} diff --git a/src/modules/ui.h b/src/modules/ui.h index ea8529c..c0ec26b 100644 --- a/src/modules/ui.h +++ b/src/modules/ui.h @@ -25,6 +25,20 @@ typedef struct { SDL_Color check_color; } ui_checkbox_t; +typedef struct { + SDL_FRect track_rect; + SDL_FRect knob_rect; + SDL_Color track_color; + SDL_Color fill_color; + SDL_Color knob_color; + SDL_Color border_color; +} ui_slider_t; + +typedef struct { + ui_slider_t slider; + int min_value; + int max_value; +} ui_slider_int_t; /** * @brief Initialize a button with colors and zeroed geometry. * @@ -146,4 +160,19 @@ bool ui_checkbox_contains(const ui_checkbox_t* checkbox, float x, float y); */ bool ui_checkbox_render(SDL_Renderer* renderer, const ui_checkbox_t* checkbox, bool is_checked); +void ui_slider_init(ui_slider_t* slider, SDL_Color track_color, SDL_Color fill_color, SDL_Color knob_color, + SDL_Color border_color); +void ui_slider_layout(ui_slider_t* slider, float center_x, float center_y, float width, float height, float knob_width); +bool ui_slider_contains(const ui_slider_t* slider, float x, float y); +float ui_slider_get_value(const ui_slider_t* slider, float x); +bool ui_slider_render(SDL_Renderer* renderer, const ui_slider_t* slider, float value); + +void ui_slider_int_init(ui_slider_int_t* slider, SDL_Color track_color, SDL_Color fill_color, SDL_Color knob_color, + SDL_Color border_color, int min_value, int max_value); +void ui_slider_int_layout(ui_slider_int_t* slider, float center_x, float center_y, float width, float height, + float knob_width); +bool ui_slider_int_contains(const ui_slider_int_t* slider, float x, float y); +int ui_slider_int_get_value(const ui_slider_int_t* slider, float x); +bool ui_slider_int_render(SDL_Renderer* renderer, const ui_slider_int_t* slider, int value); + #endif // UI_H diff --git a/src/snake.c b/src/snake.c index f96d5c9..94f2a74 100644 --- a/src/snake.c +++ b/src/snake.c @@ -6,6 +6,7 @@ #include "game/snake_state.h" #include "game/snake_text.h" +#include "modules/config.h" bool snake_create(snake_t* snake, const char* title) { SDL_assert(snake != NULL); @@ -20,9 +21,15 @@ bool snake_create(snake_t* snake, const char* title) { return false; } + if (config_load(&snake->config) == false) { + SDL_Log("Warning: Failed to load config, continuing with defaults"); + config_set_defaults(&snake->config); + } + if (audio_manager_create(&snake->audio) == false) { SDL_Log("Warning: Failed to initialize audio, continuing without sound"); } else { + snake_apply_audio_settings(snake); if (audio_manager_load_sound(&snake->audio, SOUND_EAT_FOOD, "assets/sounds/bubble-pop.wav") == false) { SDL_Log("Warning: Failed to load eating sound effect"); } @@ -51,6 +58,9 @@ bool snake_create(snake_t* snake, const char* title) { } snake->state = SNAKE_STATE_START; + snake->options_return_state = SNAKE_STATE_START; + snake->options_dragging_volume = false; + snake->options_dragging_resume = false; SDL_Log("Snake game initialized successfully"); return snake->window.is_running; @@ -77,3 +87,21 @@ void snake_destroy(snake_t* snake) { dynamic_array_destroy(&snake->array_food); dynamic_array_destroy(&snake->array_body); } + +bool snake_apply_audio_settings(snake_t* snake) { + SDL_assert(snake != NULL); + + if (audio_manager_set_volume(&snake->audio, snake->config.volume) == false) { + return false; + } + if (audio_manager_set_muted(&snake->audio, snake->config.mute) == false) { + return false; + } + + return true; +} + +bool snake_save_config(snake_t* snake) { + SDL_assert(snake != NULL); + return config_save(&snake->config); +} diff --git a/src/snake.h b/src/snake.h index 002c3b7..284cbba 100644 --- a/src/snake.h +++ b/src/snake.h @@ -3,6 +3,7 @@ #include "modules/window.h" #include "modules/audio.h" +#include "modules/config.h" #include "utils/vector.h" #include "utils/dynamic_array.h" @@ -23,7 +24,8 @@ typedef enum { SNAKE_STATE_PLAYING, SNAKE_STATE_PAUSED, SNAKE_STATE_RESUMING, - SNAKE_STATE_GAME_OVER + SNAKE_STATE_GAME_OVER, + SNAKE_STATE_OPTIONS } snake_game_state_t; typedef enum { SNAKE_CELL_EMPTY, SNAKE_CELL_WALL, SNAKE_CELL_FOOD, SNAKE_CELL_SNAKE } snake_cell_state_t; @@ -37,8 +39,12 @@ typedef struct { typedef struct { window_t window; audio_manager_t audio; + game_config_t config; snake_game_state_t state; + snake_game_state_t options_return_state; + bool options_dragging_volume; + bool options_dragging_resume; snake_direction_t current_direction; @@ -61,10 +67,13 @@ typedef struct { TTF_Text* text_start_title; TTF_Text* text_start_button; + TTF_Text* text_start_high_score; + char text_start_high_score_buffer[48]; + TTF_Text* text_options_button; TTF_Text* text_game_over_title; TTF_Text* text_game_over_score; - char text_game_over_score_buffer[48]; + char text_game_over_score_buffer[80]; TTF_Text* text_restart_button; TTF_Text* text_resume_title; @@ -73,12 +82,25 @@ typedef struct { SDL_Texture* text_resume_countdown_texture; vector2i_t text_resume_countdown_size; + TTF_Text* text_options_title; + TTF_Text* text_options_volume_label; + TTF_Text* text_options_mute_label; + TTF_Text* text_options_resume_label; + TTF_Text* text_options_back_button; + + TTF_Text* text_options_volume_value; + char text_options_volume_value_buffer[16]; + TTF_Text* text_options_resume_value; + char text_options_resume_value_buffer[8]; + Uint64 resume_countdown_end_ms; int resume_countdown_value; } snake_t; bool snake_create(snake_t* snake, const char* title); void snake_destroy(snake_t* snake); +bool snake_apply_audio_settings(snake_t* snake); +bool snake_save_config(snake_t* snake); void snake_handle_events(snake_t* snake); void snake_update_fixed(snake_t* snake);