diff --git a/movement_faces.h b/movement_faces.h index eef0e3ac..b75d5d7e 100644 --- a/movement_faces.h +++ b/movement_faces.h @@ -78,4 +78,5 @@ #include "higher_lower_game_face.h" #include "lander_face.h" #include "simon_face.h" +#include "ping_face.h" // New includes go above this line. diff --git a/watch-faces.mk b/watch-faces.mk index 3c424923..5fd366a0 100644 --- a/watch-faces.mk +++ b/watch-faces.mk @@ -53,4 +53,5 @@ SRCS += \ ./watch-faces/complication/higher_lower_game_face.c \ ./watch-faces/complication/lander_face.c \ ./watch-faces/complication/simon_face.c \ + ./watch-faces/complication/ping_face.c \ # New watch faces go above this line. diff --git a/watch-faces/complication/ping_face.c b/watch-faces/complication/ping_face.c new file mode 100644 index 00000000..a0591ba3 --- /dev/null +++ b/watch-faces/complication/ping_face.c @@ -0,0 +1,583 @@ +/* + * 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); +} diff --git a/watch-faces/complication/ping_face.h b/watch-faces/complication/ping_face.h new file mode 100644 index 00000000..2207bec3 --- /dev/null +++ b/watch-faces/complication/ping_face.h @@ -0,0 +1,71 @@ +/* + * MIT License + * + * Copyright (c) 2025 + * + * 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. + */ + +#ifndef PING_FACE_H_ +#define PING_FACE_H_ + +#include "movement.h" + +/* + PING face + I saw the face made on the Ollee watch and thought it'd be fun to have on my Sensorwatch. + https://www.instagram.com/reel/DNlTb-ERE1F/ + On the title screen, you can select a difficulty by long-pressing LIGHT or toggle sound by long-pressing ALARM. + ALARM are used to paddle. Holding the ALARM button longer makes the paddle travel further. + If the accelerometer is installed, you can tap the screen to move the paddle. Paddle will travel its full distance when tapping is used. + High-score is displayed on the top-right on the title screen. During a game, the current score is displayed. + + Difficulties: + Baby: 2 FPS + Easy: 4 FPS + Normal: 8 FPS + Hard: 8 FPS and the ball travels half the half the board. + +*/ + +typedef struct { + uint16_t hi_score : 10; + uint8_t difficulty : 3; + uint8_t month_last_hi_score : 4; + uint8_t year_last_hi_score : 6; + uint8_t soundOn : 1; + uint8_t tap_control_on : 1; + uint8_t unused : 7; +} ping_state_t; + +void ping_face_setup(uint8_t watch_face_index, void ** context_ptr); +void ping_face_activate(void *context); +bool ping_face_loop(movement_event_t event, void *context); +void ping_face_resign(void *context); + +#define ping_face ((const watch_face_t){ \ + ping_face_setup, \ + ping_face_activate, \ + ping_face_loop, \ + ping_face_resign, \ + NULL, \ +}) + +#endif // ping_FACE_H_ +