/*
* Curses UI for slide puzzle game
* Copyright © 2022 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 "cursesopt.h"
#include "game.h"
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
enum {
GAME_YPOS = 1 // top row of game and goal areas.
};
static const char *progname = "rrace";
static const struct option lopts[] = { LOPTS_INITIALIZER, {0} };
enum {
WINDOW_TILEBORDER,
WINDOW_TILEFILL,
WINDOW_AREA,
WINDOW_MAX,
};
/* Colour pair enumeration */
enum {
/* Pairs 1-6 correspond to tile colours */
RR_COLOUR_CURSOR = TILE_MAX, // black on black, for the cursor
RR_COLOUR_TOOLBAR, // cyan on black (use reverse video)
RR_COLOUR_MAX
};
static struct app_state {
struct board board;
WINDOW *gamewin[WINDOW_MAX], *goalwin[WINDOW_MAX];
WINDOW *toolbar, *timer;
int last_input;
/* Most recently displayed timer value, for screen redraw. */
uint_least32_t timer_ms;
/* Location of the keyboard cursor */
int_least8_t cursor;
/* If true, the goal will be displayed over the main play area. */
uint_least8_t view_goal_on_game;
/* Clicked toolbar item */
uint_least8_t toolbar_click;
} 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[WINDOW_TILEBORDER], *fill = win[WINDOW_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_CURSOR);
}
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_fast32_t bit0, uint_fast32_t bit1, uint_fast32_t bit2,
unsigned selected)
{
uint_fast32_t pos = board_position(x, y);
unsigned char tile = 0;
if (bit0 & pos) tile |= 1;
if (bit1 & pos) tile |= 2;
if (bit2 & pos) tile |= 4;
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[WINDOW_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) {
for (i = 0; i < 3; i++) {
buf[i] = state->board.goal[i];
buf[i] = (gp[i] & ~GOAL_MASK) | (buf[i] << GOAL_SHIFT);
}
gp = buf;
}
for (i = 0; i < 25; i++) {
if (mask & 1) {
redraw_tile(state->gamewin, i%5, i/5,
4, gp[0], gp[1], gp[2],
i == state->cursor);
}
mask >>= 1;
}
}
static void curs_redraw_goal(struct app_state *state, uint_fast32_t mask)
{
uint_least16_t *gp = state->board.goal;
int i, x, y;
if (!state->goalwin[WINDOW_AREA])
return;
getbegyx(state->goalwin[WINDOW_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], gp[1], gp[2], 0);
}
mask >>= 1;
}
}
static WINDOW *realloc_area(WINDOW **orig, int h, int w, int y, int x)
{
WINDOW *win = *orig;
if (win) {
#if HAVE_CURSES_WRESIZE
if (wresize(win, h, w) != ERR) {
mvwin(win, y, x);
return win;
}
#endif
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[WINDOW_TILEBORDER], *fill = win[WINDOW_TILEFILL];
int w = 2*h - 1;
if (fill && border) {
int old_w, old_h;
#if HAVE_CURSES_WRESIZE
if (wresize(fill, h-2, w-2) != ERR
&& wresize(border, h, w) != ERR)
{
return;
}
#endif
getmaxyx(border, old_h, old_w);
if (old_h == h)
return;
}
if (fill)
delwin(fill);
if (border)
delwin(border);
win[WINDOW_TILEBORDER] = border = newwin(h, w, 0, 0);
win[WINDOW_TILEFILL] = derwin(border, h-2, w-2, 1, 1);
}
/*
* Given the toolbar function number (between 1 and 10, inclusive), and the
* total width of the screen, return the character position for the start of
* its label display.
*
* The intention is to divide the width of the screen into 10 roughly
* equally-sized areas, spreading out the remainder so that the width of
* each label is a monotone increasing function of the total width.
*
* The minimum size of a label is 6 characters (2 of which are used for the
* number indicator)
*/
static int toolbar_xpos(int i, int total_width)
{
int button_width = MAX(6, total_width/10);
int rem = total_width - 10*button_width;
int pos = (i-1)*button_width;
switch (rem) {
case 9: pos += i > 6;
case 8: pos += i > 2;
case 7: pos += i > 7;
case 6: pos += i > 3;
case 5: pos += i > 8;
case 4: pos += i > 4;
case 3: pos += i > 9;
case 2: pos += i > 5;
}
return pos;
}
static void draw_toolbar(struct app_state *state)
{
WINDOW *toolbar = state->toolbar;
int i, w, lw;
getmaxyx(toolbar, i, w);
werase(toolbar);
lw = MAX(6, w/10);
mvwprintw(toolbar, 0, toolbar_xpos( 1, w)+2, "%.*s", lw, "Help");
mvwprintw(toolbar, 0, toolbar_xpos( 2, w)+2, "%.*s", lw, "NewGame");
mvwprintw(toolbar, 0, toolbar_xpos(10, w)+2, "%.*s", lw, "Exit");
mvwchgat(toolbar, 0, 0, -1, A_REVERSE, RR_COLOUR_TOOLBAR, 0);
for (i = 1; i <= 10; i++)
mvwprintw(toolbar, 0, toolbar_xpos(i, w), "%2d", i);
wnoutrefresh(state->toolbar);
}
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[WINDOW_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[WINDOW_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);
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_CURSOR, 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);
wrefresh(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;
}
static void do_new_game(struct app_state *state)
{
game_reset(&state->board);
do_reset_cursor(state);
curs_redraw_game(state, -1);
curs_redraw_goal(state, -1);
update_timer(state, 0);
doupdate();
timeout(33);
game_begin(&state->board);
}
static void do_function(struct app_state *state, unsigned func)
{
switch (func) {
case 2:
do_new_game(state);
break;
case 10:
endwin();
exit(0);
}
}
#if HAVE_CURSES_MOUSE_SUPPORT
/*
* Returns the toolbar function (1 through 10) under the given x, y screen
* coordinates, or 0 if the coordinates are outside of the toolbar.
*/
static int mouse_toolbar_function(struct app_state *state, int x, int y)
{
int toolbar_x, toolbar_y, toolbar_w, toolbar_h, i;
getbegyx(state->toolbar, toolbar_y, toolbar_x);
getmaxyx(state->toolbar, toolbar_h, toolbar_w);
if ((void)toolbar_x, y != toolbar_y)
return 0;
/* OK, selected a button, determine which one */
for (i = 10; i > 1; i--) {
if ((void)toolbar_h, x >= toolbar_xpos(i, toolbar_w))
break;
}
return i;
}
/*
* 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 = mouse_toolbar_function(state, x, y);
}
/*
* 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 = mouse_toolbar_function(state, x, y);
if (func && state->toolbar_click == func) {
do_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[WINDOW_AREA], game_y, game_x);
getmaxyx(state->gamewin[WINDOW_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') {
do_function(state, (c -= '0') == 0 ? 10 : c);
}
/* F# keys */
if (c >= KEY_F(1) && c <= KEY_F(10)) {
do_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);
draw_toolbar(state);
update_timer(state, state->timer_ms);
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));
}
int main(int argc, char **argv)
{
setlocale(LC_ALL, "");
app_initialize(argc, argv);
do_new_game(&state);
while (1)
do_mainloop(&state);
abort();
}