/* * Curses UI for slide puzzle game * Copyright © 2022-2023 Nick Bowler * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include #include #include #include #include #include #include "help.h" #include "version.h" #include "cursesui.h" #include "cursesopt.h" enum { GAME_YPOS = 1 // top row of game and goal areas. }; static const char *progname = "rrace"; static const struct option lopts[] = { LOPTS_INITIALIZER, {0} }; static struct app_state state; static void print_version(void) { version_print_head("rrace-curses", stdout); puts("License GPLv3+: GNU GPL version 3 or any later version"); puts("This is free software: you are free to change and redistribute it."); puts("There is NO WARRANTY, to the extent permitted by law."); } static void print_usage(FILE *f) { fprintf(f, "Usage: %s [options]\n", progname); if (f != stdout) fprintf(f, "Try %s --help for more information.\n", progname); } static void print_help(void) { struct lopt_help help = {0}; const struct option *opt; print_usage(stdout); putchar('\n'); puts("Options:"); for (opt = lopts; opt->name; opt++) { if (!lopt_get_help(opt, &help)) continue; help_print_option(opt, help.arg, help.desc, 20); } putchar('\n'); printf("Report bugs to <%s>.\n", PACKAGE_BUGREPORT); } static void draw_tile(WINDOW **win, unsigned colour, unsigned selected, unsigned x, unsigned y, unsigned start_column) { WINDOW *border = win[PLAYWIN_TILEBORDER], *fill = win[PLAYWIN_TILEFILL]; int w, h, attr, ch, bc = selected ? '#' : 0; assert(colour < TILE_MAX); attr = COLOR_PAIR(colour); switch (colour) { case TILE_RED: ch = 'X'; break; case TILE_ORANGE: ch = '|'; break; case TILE_GREEN: ch = '+'; break; case TILE_YELLOW: ch = '~'; attr |= A_BOLD; break; case TILE_BLUE: ch = 'o'; attr |= A_BOLD; break; case TILE_WHITE: ch = '.'; attr |= A_BOLD; break; case TILE_EMPTY: attr = A_BOLD|COLOR_PAIR(RR_COLOUR_SHADOW); } getmaxyx(border, h, w); w = 2*(w+1)/2; if (mvwin(border, 1+GAME_YPOS+h*y, start_column+w*x) == ERR) return; if (colour != TILE_EMPTY) { mvderwin(fill, 1, 1); wbkgdset(fill, A_REVERSE|attr|ch); werase(fill); } else { werase(border); } if (bc || colour) { wattrset(border, attr); wborder(border, bc, bc, bc, bc, bc, bc, bc, bc); } wnoutrefresh(border); } static int redraw_tile(WINDOW **win, unsigned x, unsigned y, unsigned start_column, uint_least32_t *gp, unsigned selected) { unsigned tile = board_tile(gp, 5*y+x); assert(tile < TILE_MAX); draw_tile(win, tile, selected, x, y, start_column); return tile; } static void redraw_area_border(WINDOW **win, unsigned x, unsigned sz) { int w, h, tr = 0, rs = 0, br = 0, bs = 0, bl = 0; WINDOW *area = win[PLAYWIN_AREA]; getmaxyx(stdscr, h, w); if (h <= GAME_YPOS+1) { bl = ACS_ULCORNER, br = ACS_URCORNER; } else if (h <= 3*sz+GAME_YPOS+1) { bl = br = ACS_VLINE, bs = ' '; } if (w <= x+1) { tr = ACS_ULCORNER, br = ACS_LLCORNER; } else if (w <= 6*sz+x+2) { tr = ACS_HLINE, rs = ' '; br = br ? ' ' : ACS_HLINE; } wborder(area, 0, rs, 0, bs, 0, tr, bl, br); wnoutrefresh(area); } static void curs_redraw_game(struct app_state *state, uint_fast32_t mask) { uint_least32_t buf[3], *gp = state->board.game; int i; if (mask == -1) redraw_area_border(state->gamewin, 2, 5); if (state->view_goal_on_game) gp = game_overlay_goal(&state->board, buf); for (i = 0; i < 25; i++) { if (mask & 1) { redraw_tile(state->gamewin, i%5, i/5, 4, gp, i == state->cursor); } mask >>= 1; } } static void curs_redraw_goal(struct app_state *state, uint_fast32_t mask) { uint_least32_t gp[3] = { state->board.goal[0], state->board.goal[1], state->board.goal[2] }; int i, x, y; if (!state->goalwin[PLAYWIN_AREA]) return; getbegyx(state->goalwin[PLAYWIN_AREA], y, x); if (mask == -1) redraw_area_border(state->goalwin, x, 3); for (i = 0; i < 9; i++) { if (mask & 1) { redraw_tile(state->goalwin, i%3, i/3, x+2, gp, 0); } mask >>= 1; } } /* Thin wrapper around wresize which returns ERR if support is unavailable. */ static int do_wresize(WINDOW *window, int h, int w) { #if HAVE_CURSES_WRESIZE return wresize(window, h, w); #endif return ERR; } static WINDOW *realloc_area(WINDOW **orig, int h, int w, int y, int x) { WINDOW *win = *orig; if (win) { if (do_wresize(win, h, w) != ERR) { mvwin(win, y, x); return win; } delwin(win); } if (w > 0 && h > 0) return *orig = subwin(stdscr, h, w, y, x); return *orig = NULL; } static void realloc_tiles(WINDOW **win, int h) { WINDOW *border = win[PLAYWIN_TILEBORDER], *fill = win[PLAYWIN_TILEFILL]; int w = 2*h - 1; if (fill && border) { int old_w, old_h; if (do_wresize(fill, h-2, w-2) != ERR && do_wresize(border, h, w) != ERR) return; getmaxyx(border, old_h, old_w); if (old_h == h) return; } if (fill) delwin(fill); if (border) delwin(border); win[PLAYWIN_TILEBORDER] = border = newwin(h, w, 0, 0); win[PLAYWIN_TILEFILL] = derwin(border, h-2, w-2, 1, 1); } static void setup_mainwin(struct app_state *state) { int w, h, gamesz, goalsz, scr_w, scr_h, split; getmaxyx(stdscr, scr_h, scr_w); /* First try to fit the game tiles based on window height. */ gamesz = MAX(3, (scr_h - 4) / 5); /* Adjust downward until we can fit smallest possible goal area. */ for (; split = 5+10*gamesz, gamesz > 3; gamesz--) { if (split + 20 < scr_w) break; } /* Pick a goal size that will fit in the remaining area */ goalsz = MAX(3, (scr_w - split - 4) / 6); if (goalsz >= gamesz) goalsz = MAX(3, gamesz - 1); realloc_tiles(state->gamewin, gamesz); realloc_tiles(state->goalwin, goalsz); /* Frame for game area */ w = MIN(scr_w-2, 3+10*gamesz); h = MIN(scr_h-GAME_YPOS, 2+5*gamesz); realloc_area(&state->gamewin[PLAYWIN_AREA], h, w, GAME_YPOS, 2); /* Frame for goal area */ w = MIN(scr_w-split, 3+6*goalsz); h = MIN(scr_h-GAME_YPOS, 2+3*goalsz); realloc_area(&state->goalwin[PLAYWIN_AREA], h, w, GAME_YPOS, split); /* Status area */ w = MAX(0, scr_w-split-1); realloc_area(&state->timer, 1, w, GAME_YPOS+h, split+1); /* Toolbar */ realloc_area(&state->toolbar, 1, scr_w, scr_h-1, 0); curs_draw_toolbar(state); } static void app_initialize(int argc, char **argv) { int enable_mouse = 1; int opt; if (argc > 0) progname = argv[0]; while ((opt = getopt_long(argc, argv, SOPT_STRING, lopts, 0)) != -1) { switch (opt) { case LOPT_MOUSE: enable_mouse = 2; break; case LOPT_NO_MOUSE: enable_mouse = 0; break; case LOPT_VERSION: print_version(); exit(EXIT_SUCCESS); case LOPT_HELP: print_help(); exit(EXIT_SUCCESS); default: print_usage(stderr); exit(EXIT_FAILURE); } } game_reset(&state.board); state.cursor = -1; initscr(); start_color(); curs_set(0); cbreak(); keypad(stdscr, TRUE); if (enable_mouse) { /* * While we only care about a subset of these events, for * some reason with ncurses failing to enable all of them * causes timeout to stop working when disabled buttons are * pressed (or released). * * It is not known if other curses have this problem; at least * pdcurses does not, but the extra events should be harmless * in any case. */ #if HAVE_CURSES_MOUSE_SUPPORT unsigned long mask = BUTTON1_PRESSED | BUTTON1_RELEASED | BUTTON2_PRESSED | BUTTON2_RELEASED | BUTTON3_PRESSED | BUTTON3_RELEASED #ifdef BUTTON4_PRESSED | BUTTON4_PRESSED | BUTTON4_RELEASED #endif #ifdef BUTTON5_PRESSED | BUTTON5_PRESSED | BUTTON5_RELEASED #endif ; #if HAVE_CURSES_MOUSE_SET mouse_set(mask); #elif HAVE_CURSES_MOUSEMASK mousemask(mask, NULL); #endif #if HAVE_CURSES_MOUSEINTERVAL mouseinterval(0); #endif #endif /* HAVE_CURSES_MOUSE_SUPPORT */ } noecho(); init_pair(TILE_RED, COLOR_RED, COLOR_BLACK); init_pair(TILE_ORANGE, COLOR_YELLOW, COLOR_BLACK); init_pair(TILE_YELLOW, COLOR_YELLOW, COLOR_BLACK); init_pair(TILE_GREEN, COLOR_GREEN, COLOR_BLACK); init_pair(TILE_BLUE, COLOR_BLUE, COLOR_BLACK); init_pair(TILE_WHITE, COLOR_WHITE, COLOR_BLACK); init_pair(RR_COLOUR_SHADOW, COLOR_BLACK, COLOR_BLACK); init_pair(RR_COLOUR_TOOLBAR, COLOR_CYAN, COLOR_BLACK); setup_mainwin(&state); refresh(); } static void update_timer(struct app_state *state, uint_fast32_t ms) { unsigned sec, min; state->timer_ms = ms; sec = ms / 1000; ms %= 1000; min = sec / 60; sec %= 60; mvwprintw(state->timer, 0, 0, "Time: %u:%.2u.%.3u", min, sec, (unsigned)ms); wclrtoeol(state->timer); wnoutrefresh(state->timer); } static uint_fast32_t do_move(struct app_state *state, int x, int y) { uint_fast32_t mask; if ((mask = game_do_move(&state->board, x, y)) != 0) { uint_fast32_t goal = game_check_goal(&state->board); if (state->view_goal_on_game) { state->view_goal_on_game = 0; mask |= goal; } if (goal == 0) { /* Solved! */ update_timer(state, game_finish(&state->board)); mask |= ~GOAL_MASK; state->cursor = -1; timeout(-1); } curs_redraw_game(state, mask); doupdate(); } return mask; } static void do_reset_cursor(struct app_state *state) { state->cursor = 5*state->board.y + state->board.x; } void curs_new_game(struct app_state *state) { game_reset(&state->board); do_reset_cursor(state); state->view_goal_on_game = 0; curs_redraw_game(state, -1); curs_redraw_goal(state, -1); update_timer(state, 0); doupdate(); timeout(33); game_begin(&state->board); } #if HAVE_CURSES_MOUSE_SUPPORT /* * Given x, y as screen coordinates, record which (if any) toolbar function * label is at that position, to be performed later. * * If no function is indicated, returns zero. Otherwise, returns non-zero. */ static int press_toolbar(struct app_state *state, int x, int y) { return state->toolbar_click = curs_toolbar_mouse_func(state, x, y); } /* * Given x, y as screen coordinates, perform the toolbar action. * * Perform the action previously recorded by press_toolbar, if and only if * the x, y screen coordinates correspond to the same function. */ static void release_toolbar(struct app_state *state, int x, int y) { int func = curs_toolbar_mouse_func(state, x, y); if (func && state->toolbar_click == func) { curs_execute_function(state, func); } state->toolbar_click = 0; } /* * Given x, y as screen coordinates, determine which (if any) game tile is * at that position. * * If there is no such tile, performs no action and returns 0. * * Otherwise, attempts to move that tile and returns non-zero. */ static int press_tile(struct app_state *state, int x, int y) { int game_x, game_y, tile_w, tile_h; uint_fast32_t cursor_mask, move_mask; getbegyx(state->gamewin[PLAYWIN_AREA], game_y, game_x); getmaxyx(state->gamewin[PLAYWIN_TILEBORDER], tile_h, tile_w); tile_w += tile_w & 1; /* special case the left spacer column */ if (x == game_x+1) x++; if (x < game_x+2 || (x -= game_x+2)/5 >= tile_w) return 0; if (y < game_y+1 || (y -= game_y+1)/5 >= tile_h) return 0; /* OK, selected a tile. */ x /= tile_w; y /= tile_h; /* Disable the keyboard cursor due to mouse action */ cursor_mask = state->cursor < 0 ? -1 : 1ul << state->cursor; state->cursor = -1; move_mask = do_move(state, x, y); if ((cursor_mask & move_mask) == 0) { curs_redraw_game(state, cursor_mask); doupdate(); } return 1; } static void do_mouse(struct app_state *state) { unsigned long bstate; int x, y; #if HAVE_CURSES_GETMOUSE_NCURSES MEVENT mev; if (getmouse(&mev) == ERR) return; x = mev.x, y = mev.y; bstate = mev.bstate; #elif HAVE_CURSES_REQUEST_MOUSE_POS request_mouse_pos(); x = MOUSE_X_POS; y = MOUSE_Y_POS; bstate = 0; #define set_bstate_helper(button) do { if (BUTTON_CHANGED(button)) \ switch (BUTTON_STATUS(button)) { \ case BUTTON_RELEASED: bstate |= BUTTON ## button ## _RELEASED; break; \ case BUTTON_PRESSED: bstate |= BUTTON ## button ## _PRESSED; break; \ } \ } while (0); set_bstate_helper(1); set_bstate_helper(3); #endif if (bstate & BUTTON3_PRESSED) { state->view_goal_on_game = 1; curs_redraw_game(state, game_check_goal(&state->board)); doupdate(); } if (bstate & BUTTON3_RELEASED) { state->view_goal_on_game = 0; curs_redraw_game(state, game_check_goal(&state->board)); doupdate(); } /* Ignore button1 if holding button3 to view goal */ if (state->view_goal_on_game & 1) return; if (bstate & BUTTON1_PRESSED) { if (press_toolbar(state, x, y)); else if (press_tile(state, x, y)); } if (bstate & BUTTON1_RELEASED) { release_toolbar(state, x, y); } } #endif /* HAVE_CURSES_MOUSE_SUPPORT */ static void do_move_cursor(struct app_state *state, int c) { uint_fast32_t mask = 0; if (state->cursor < 0) { /* Cursor was hidden; reset it */ do_reset_cursor(state); } else { mask = 1ul << state->cursor; } if (state->view_goal_on_game) { state->view_goal_on_game = 0; mask |= game_check_goal(&state->board); } switch (c) { case KEY_UP: if ((state->cursor -= 5) < 0) state->cursor += 25; break; case KEY_DOWN: if ((state->cursor += 5) >= 25) state->cursor -= 25; break; case KEY_LEFT: if ((state->cursor -= 1) % 5 == 4 || state->cursor < 0) state->cursor += 5; break; case KEY_RIGHT: if ((state->cursor += 1) % 5 == 0) state->cursor -= 5; break; } curs_redraw_game(state, mask | 1ul << state->cursor); doupdate(); } static void do_keystroke(struct app_state *state, int c) { int last_input = state->last_input; state->last_input = c; switch (c) { case KEY_DOWN: case KEY_UP: case KEY_LEFT: case KEY_RIGHT: do_move_cursor(state, c); break; case '\t': state->view_goal_on_game ^= 2u; curs_redraw_game(state, game_check_goal(&state->board)); doupdate(); break; case ' ': if (!(state->view_goal_on_game & 1) && state->cursor >= 0) do_move(state, state->cursor%5, state->cursor/5); break; } /* ESC+# keys */ if (last_input == '\33' && c >= '0' && c <= '9') { curs_execute_function(state, (c -= '0') == 0 ? 10 : c); } /* F# keys */ if (c >= KEY_F(1) && c <= KEY_F(10)) { curs_execute_function(state, c - KEY_F0); } } /* One iteration of main input loop */ void do_mainloop(struct app_state *state) { int c = getch(); switch (c) { #ifdef KEY_RESIZE case KEY_RESIZE: setup_mainwin(state); clear(); refresh(); curs_redraw_game(state, -1); curs_redraw_goal(state, -1); curs_draw_toolbar(state); update_timer(state, state->timer_ms); doupdate(); break; #endif #if HAVE_CURSES_MOUSE_SUPPORT case KEY_MOUSE: do_mouse(state); break; #endif default: do_keystroke(state, c); case ERR:; } if (state->board.x <= 4) { update_timer(state, game_elapsed(&state->board)); doupdate(); } } int main(int argc, char **argv) { setlocale(LC_ALL, ""); app_initialize(argc, argv); curs_new_game(&state); while (1) do_mainloop(&state); abort(); }