/* * MIT License * * Copyright (c) 2024 * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ #include #include #include "ping_face.h" #include "delay.h" #include "watch_common_display.h" typedef enum { PADDLE_RETRACTED = 0, PADDLE_EXTENDING, PADDLE_EXTENDED, PADDLE_RETRACTING, } PingPaddleState; typedef enum { SCREEN_TITLE = 0, SCREEN_SCORE, SCREEN_PLAYING, SCREEN_LOSE, SCREEN_COUNT } PingCurrScreen; typedef enum { DIFF_BABY = 0, // FREQ_BABY FPS DIFF_EASY, // FREQ_EASY FPS DIFF_NORM, // FREQ_NORM FPS DIFF_HARD, // FREQ_NORM FPS, smaller travel-distance for ball DIFF_COUNT } PingDifficulty; typedef enum { RESULT_LOSE = -1, RESULT_NONE = 0, RESULT_HIT = 1, RESULT_FIRST_HIT = 2, } PingResult; #define FREQ_BABY 2 #define FREQ_EASY 4 #define FREQ_NORM 8 #define BALL_POS_MAX 11 #define BALL_OFF_SCREEN 100 #define MAX_HI_SCORE 9999 // Max hi score to store and display on the title screen. #define MAX_DISP_SCORE 39 // The top-right digits can't properly display above 39 typedef struct { uint8_t ball_pos; // 0 to 11; 0 is the bottom-right and 11 is the top right. // | 6 | 7 | 8 | 9 | 10 | 11 | // | 5 | 4 | 3 | 2 | 1 | 0 | PingPaddleState paddle_pos; uint8_t ball_char_pos; // Derived from ball_pos bool ball_is_clockwise; bool ball_is_moving; uint16_t curr_score; PingCurrScreen curr_screen; bool paddle_hit; bool paddle_released; uint8_t curr_freq; bool moving_from_tap; } game_state_t; static game_state_t game_state; static int8_t _ticks_show_title = 0; static bool _is_custom_lcd; static int8_t start_tune[] = { BUZZER_NOTE_C5, 15, BUZZER_NOTE_E5, 15, BUZZER_NOTE_G5, 15, 0 }; static int8_t lose_tune[] = { BUZZER_NOTE_D3, 10, BUZZER_NOTE_C3SHARP_D3FLAT, 10, BUZZER_NOTE_C3, 10, 0 }; static uint8_t ball_pos_to_char_pos(uint8_t ball_pos) { switch (ball_pos) { case 5: case 6: return 4; case 4: case 7: return 5; case 3: case 8: return 6; case 2: case 9: return 7; case 1: case 10: return 8; case 0: case 11: return 9; default: return BALL_OFF_SCREEN; } } static bool paddle_and_ball_on_same_segment(void) { if (game_state.paddle_pos == PADDLE_EXTENDED) { if (game_state.ball_pos == 9 || game_state.ball_pos == 2) { return true; } } else if (game_state.paddle_pos == PADDLE_EXTENDING || game_state.paddle_pos == PADDLE_RETRACTING) { if (game_state.ball_pos == 10 || game_state.ball_pos == 1) { return true; } } else if (game_state.paddle_pos == PADDLE_RETRACTED) { if (game_state.ball_pos == 11 || game_state.ball_pos == 0) { return true; } } return false; } static bool paddle_hit_ball(void) { if (game_state.paddle_pos == PADDLE_EXTENDED) { if (game_state.ball_pos >= 9 && game_state.ball_is_clockwise) { return true; } if (game_state.ball_pos <= 2 && !game_state.ball_is_clockwise) { return true; } } else if (game_state.paddle_pos == PADDLE_EXTENDING) { if (game_state.ball_pos >= 10 && game_state.ball_is_clockwise) { return true; } if (game_state.ball_pos <= 1 && !game_state.ball_is_clockwise) { return true; } } return false; } static uint8_t get_next_ball_pos(bool ball_hit, uint8_t difficulty) { int8_t offset_next; if (ball_hit) { bool ball_on_top = game_state.ball_pos > 5; game_state.ball_is_clockwise = !ball_on_top; // ball is at the same frame as the paddle if (game_state.paddle_pos == PADDLE_EXTENDED) { return ball_on_top ? 9 : 2; } else if (game_state.paddle_pos == PADDLE_EXTENDING) { return ball_on_top ? 10 : 1; } } if (game_state.ball_is_clockwise) { offset_next = 1; } else { offset_next = -1; } int8_t next_pos = game_state.ball_pos + offset_next; if (next_pos > BALL_POS_MAX || next_pos < 0) { return BALL_OFF_SCREEN; } if (difficulty == DIFF_HARD) { if (next_pos == 4) { next_pos = 8; } else if (next_pos == 7) { next_pos = 3; } } return next_pos; } static void display_ball(void) { uint8_t char_pos = ball_pos_to_char_pos(game_state.ball_pos); uint8_t char_display; bool overlap = paddle_and_ball_on_same_segment(); if (game_state.ball_pos > 5) { if (overlap) { char_display = 'q'; } else { char_display = '#'; } } else { if (!_is_custom_lcd && (char_pos == 4 || char_pos == 6)) { char_display = 'n'; // No need to check for overlap on these segments } else { if (overlap) { char_display = 'd'; } else { char_display = 'o'; } } } watch_display_character(char_display, char_pos); } static PingResult update_ball(uint8_t difficulty) { bool ball_hit = paddle_hit_ball(); bool first_hit = false; if (!game_state.ball_is_moving) { if (ball_hit) { game_state.ball_is_moving = true; first_hit = true; } else { return RESULT_NONE; } } game_state.ball_pos = get_next_ball_pos(ball_hit, difficulty); if (game_state.ball_pos == BALL_OFF_SCREEN) { return RESULT_LOSE; } display_ball(); if (ball_hit) { return first_hit ? RESULT_FIRST_HIT : RESULT_HIT; } else { return RESULT_NONE; } } static void display_paddle(void) { switch (game_state.paddle_pos) { case PADDLE_EXTENDING: case PADDLE_RETRACTING: watch_display_character('-', 9); watch_display_character('1', 8); break; case PADDLE_EXTENDED: watch_display_character('-', 9); watch_display_character('-', 8); watch_display_character('1', 7); break; case PADDLE_RETRACTED: default: watch_display_character('1', 9); break; } } static void update_paddle(void) { switch (game_state.paddle_pos) { case PADDLE_RETRACTED: if (game_state.paddle_hit) { game_state.paddle_pos = PADDLE_EXTENDING; } break; case PADDLE_EXTENDING: if (!game_state.moving_from_tap && !HAL_GPIO_BTN_ALARM_read()) { game_state.paddle_pos = PADDLE_RETRACTED; watch_display_character(' ', 8); game_state.moving_from_tap = false; } else { game_state.paddle_pos = PADDLE_EXTENDED; } break; case PADDLE_EXTENDED: game_state.paddle_pos = PADDLE_RETRACTING; watch_display_character(' ', 7); break; case PADDLE_RETRACTING: game_state.paddle_pos = PADDLE_RETRACTED; watch_display_character(' ', 8); game_state.moving_from_tap = false; break; default: break; } game_state.paddle_hit = false; display_paddle(); } static inline bool paddle_is_extending(void) { return game_state.paddle_pos == PADDLE_EXTENDING || game_state.paddle_pos == PADDLE_EXTENDED; } static void display_score(uint8_t score) { char buf[3]; score %= (MAX_DISP_SCORE + 1); sprintf(buf, "%2d", score); watch_display_text(WATCH_POSITION_TOP_RIGHT, buf); } static void add_to_score(ping_state_t *state) { if (game_state.curr_score <= MAX_HI_SCORE) { game_state.curr_score++; if (game_state.curr_score > state -> hi_score) state -> hi_score = game_state.curr_score; } display_score(game_state.curr_score); } static void check_and_reset_hi_score(ping_state_t *state) { // Resets the hi score at the beginning of each month. watch_date_time_t date_time = movement_get_local_date_time(); if ((state -> year_last_hi_score != date_time.unit.year) || (state -> month_last_hi_score != date_time.unit.month)) { // The high score resets itself every new month. state -> hi_score = 0; state -> year_last_hi_score = date_time.unit.year; state -> month_last_hi_score = date_time.unit.month; } } static void display_difficulty(uint16_t difficulty) { static const char *labels[] = { [DIFF_BABY] = " b", [DIFF_EASY] = " E", [DIFF_NORM] = " N", [DIFF_HARD] = " H" }; watch_display_text(WATCH_POSITION_TOP_RIGHT, labels[difficulty]); } static void change_difficulty(ping_state_t *state) { state -> difficulty = (state -> difficulty + 1) % DIFF_COUNT; display_difficulty(state -> difficulty); if (state -> soundOn) { if (state -> difficulty == 0) watch_buzzer_play_note(BUZZER_NOTE_B4, 30); else watch_buzzer_play_note(BUZZER_NOTE_C5, 30); } } static void display_sound_indicator(bool soundOn) { if (soundOn) { watch_set_indicator(WATCH_INDICATOR_BELL); } else { watch_clear_indicator(WATCH_INDICATOR_BELL); } } static void toggle_sound(ping_state_t *state) { state -> soundOn = !state -> soundOn; display_sound_indicator(state -> soundOn); if (state -> soundOn) { watch_buzzer_play_note(BUZZER_NOTE_C5, 30); } } static void enable_tap_control(ping_state_t *state) { if (!state->tap_control_on) { movement_enable_tap_detection_if_available(); state->tap_control_on = true; } } static void disable_tap_control(ping_state_t *state) { if (state->tap_control_on) { movement_disable_tap_detection_if_available(); state->tap_control_on = false; } } static void display_title(ping_state_t *state) { movement_request_tick_frequency(1); game_state.curr_screen = SCREEN_TITLE; watch_clear_colon(); watch_display_text_with_fallback(WATCH_POSITION_TOP, "Ping", "PI "); watch_display_text(WATCH_POSITION_BOTTOM, " Ping "); display_sound_indicator(state -> soundOn); _ticks_show_title = 1; } static void display_score_screen(ping_state_t *state) { uint16_t hi_score = state -> hi_score; uint8_t difficulty = state -> difficulty; movement_request_tick_frequency(1); bool sound_on = state -> soundOn; memset(&game_state, 0, sizeof(game_state)); game_state.curr_screen = SCREEN_SCORE; watch_set_colon(); watch_display_text_with_fallback(WATCH_POSITION_TOP, "PI ", "PI "); if (hi_score > MAX_HI_SCORE) { watch_display_text(WATCH_POSITION_BOTTOM, "HS --"); } else { char buf[10]; sprintf(buf, "HS%4d", hi_score); watch_display_text(WATCH_POSITION_BOTTOM, buf); } display_difficulty(difficulty); display_sound_indicator(sound_on); } static void begin_playing(ping_state_t *state) { game_state.curr_screen = SCREEN_PLAYING; watch_clear_colon(); display_sound_indicator(state -> soundOn); switch (state -> difficulty) { case DIFF_BABY: game_state.curr_freq = FREQ_BABY; break; case DIFF_EASY: game_state.curr_freq = FREQ_EASY; break; case DIFF_NORM: case DIFF_HARD: default: game_state.curr_freq = FREQ_NORM; break; } movement_request_tick_frequency(game_state.curr_freq); watch_display_text(WATCH_POSITION_TOP_RIGHT, " "); watch_display_text(WATCH_POSITION_BOTTOM, " "); game_state.paddle_pos = PADDLE_RETRACTED; game_state.ball_pos = 1; game_state.paddle_hit = false; game_state.ball_is_moving = false; game_state.ball_is_clockwise = false; game_state.curr_score = 0; display_paddle(); display_ball(); display_score( game_state.curr_score); } static void display_lose_screen(ping_state_t *state) { game_state.curr_screen = SCREEN_LOSE; game_state.curr_score = 0; watch_clear_display(); watch_display_text(WATCH_POSITION_BOTTOM, " LOSE "); if (state -> soundOn) { watch_buzzer_play_sequence(lose_tune, NULL); delay_ms(600); } } static void update_game(ping_state_t *state) { if (game_state.ball_is_moving) { watch_display_character(' ', ball_pos_to_char_pos(game_state.ball_pos)); // remove the old ball. } update_paddle(); int game_result = update_ball(state -> difficulty); if (game_result == RESULT_LOSE) { display_lose_screen(state); } else if (game_result == RESULT_HIT) { add_to_score(state); if (state -> soundOn) { watch_buzzer_play_note(BUZZER_NOTE_C5, 60); } } else if (game_result == RESULT_FIRST_HIT && state -> soundOn) { watch_buzzer_play_sequence(start_tune, NULL); } } void ping_face_setup(uint8_t watch_face_index, void ** context_ptr) { (void) watch_face_index; if (*context_ptr == NULL) { *context_ptr = malloc(sizeof(ping_state_t)); memset(*context_ptr, 0, sizeof(ping_state_t)); ping_state_t *state = (ping_state_t *)*context_ptr; state->difficulty = DIFF_NORM; state->tap_control_on = false; } } void ping_face_activate(void *context) { (void) context; _is_custom_lcd = watch_get_lcd_type() == WATCH_LCD_TYPE_CUSTOM; if (watch_sleep_animation_is_running()) { watch_stop_blink(); } } bool ping_face_loop(movement_event_t event, void *context) { ping_state_t *state = (ping_state_t *)context; switch (event.event_type) { case EVENT_ACTIVATE: disable_tap_control(state); check_and_reset_hi_score(state); display_title(state); break; case EVENT_TICK: switch (game_state.curr_screen) { case SCREEN_TITLE: if (_ticks_show_title > 0) {_ticks_show_title--;} else { watch_clear_display(); display_score_screen(state); } case SCREEN_SCORE: case SCREEN_LOSE: break; case SCREEN_PLAYING: default: update_game(state); break; } break; case EVENT_ALARM_BUTTON_UP: case EVENT_LIGHT_BUTTON_UP: switch (game_state.curr_screen) { case SCREEN_SCORE: enable_tap_control(state); begin_playing(state); break; case SCREEN_TITLE: enable_tap_control(state); // fall through case SCREEN_LOSE: watch_clear_display(); display_score_screen(state); default: break; } break; case EVENT_LIGHT_LONG_PRESS: if (game_state.curr_screen == SCREEN_SCORE) change_difficulty(state); break; case EVENT_SINGLE_TAP: case EVENT_DOUBLE_TAP: // Allow starting a new game by tapping. if (game_state.curr_screen == SCREEN_SCORE) { begin_playing(state); break; } else if (game_state.curr_screen == SCREEN_LOSE) { display_score_screen(state); break; } else if (game_state.curr_screen == SCREEN_PLAYING) { game_state.moving_from_tap = true; game_state.paddle_hit = true; } break; case EVENT_ALARM_BUTTON_DOWN: if (game_state.curr_screen == SCREEN_PLAYING) { game_state.moving_from_tap = false; game_state.paddle_hit = true; } break; case EVENT_ALARM_LONG_PRESS: if (game_state.curr_screen == SCREEN_TITLE || game_state.curr_screen == SCREEN_SCORE) toggle_sound(state); break; case EVENT_TIMEOUT: disable_tap_control(state); if (game_state.curr_screen != SCREEN_SCORE) { display_score_screen(state); } break; case EVENT_LIGHT_BUTTON_DOWN: break; default: return movement_default_loop_handler(event); } return true; } void ping_face_resign(void *context) { ping_state_t *state = (ping_state_t *)context; disable_tap_control(state); }