C Games in Ncurses: Using Multiple Windows
Patrick Reagan, Former Development Director
Article Category:
Posted on
You've got an ice cold Tab sitting next to you as you hack away on your Pong clone while blasting Wham! on your Walkman. You finally got a ball bouncing back and forth across your terminal window (see my previous post) and you're wondering "what's next?"
Let's split our Pong game into two parts: the playing field and the score display. We'll plan for the playing field to be at the top portion of the screen, and the player's current score will be displayed at the bottom. This makes sense, but how do we do it?
A simple implementation might be to just move the current drawing position to a location on the bottom of the screen and print out the current score value (using mvwprintw
), but that would require you to bounce the ball before it overlaps the score display. While that would work, it's less than ideal for a couple of reasons:
-
You can't update the score display independently from the main playing field. Each time the playing field needs to be redrawn you need to also redraw the score display, even if it hasn't changed.
-
More importantly, your code is now littered with confusing collision detection logic. No longer are you checking if
y >= max_y
, you now need to see if(y + score_height) >= max_y
.
Fortunately, ncurses provides the ability to split these two concerns into separate windows, each updated independently. It might be a little more work to manage multiple windows in your program, but it's the right thing to do. In the end, you envision splitting the windows into something like this:
Let's see how we would implement that.
Window Basics
You're already familiar with initscr()
and the global variable stdscr
that it initializes. Here are four functions that allow you to create and manipulate additional windows:
newwin(lines, columns, y, x)
- creates a new window with the specified dimensionswresize(window, lines, colums)
- resizes an existing window to the specified dimensionsmvwin(window, y, x)
- moves a window to the specified locationdelwin(window)
- frees all memory associated with the specified window
We already know how to output text on the screen, but there are some differences when printing to other windows. To do this, you'll need the w*
variants:
wclear(window)
- clears the specified windowmvwprintw(window y, x, text)
- prints text at the specified coordinates on the windowwrefresh(window)
- refresh the specified window (displays any text written since the last call towrefresh
)
Again, create a Makefile:
# Makefile LDFLAGS=-lncurses all: demo
And a source file:
// demo.c #include <ncurses.h> #include <unistd.h> int main(int argc, char *argv[]) { int parent_x, parent_y; int score_size = 3; initscr(); noecho(); curs_set(FALSE); // get our maximum window dimensions getmaxyx(stdscr, parent_y, parent_x); // set up initial windows WINDOW *field = newwin(parent_y - score_size, parent_x, 0, 0); WINDOW *score = newwin(score_size, parent_x, parent_y - score_size, 0); // draw to our windows mvwprintw(field, 0, 0, "Field"); mvwprintw(score, 0, 0, "Score"); // refresh each window wrefresh(field); wrefresh(score); sleep(5); // clean up delwin(field); delwin(score); endwin(); return 0; }
Then compile and run the program:
make && ./demo
You'll see your terminal enter curses mode and the text 'Field' will appear in the uppper window, while 'Score' appears at the bottom. This isn't much different from what we did in the previous post, but you'll notice that the coordinates specified with mvwprintw
are relative to each window and not stdscr
.
Printing text to each window is a good way of testing how the windowing functions work, but it makes it difficult to see where our windows are. Let's draw a border around them.
Drawing Borders
Since we'll want to draw a border around both our field and score display in this example, let's create a function that will draw a border around an arbitrary window:
void draw_borders(WINDOW *screen) { int x, y, i; getmaxyx(screen, y, x); // 4 corners mvwprintw(screen, 0, 0, "+"); mvwprintw(screen, y - 1, 0, "+"); mvwprintw(screen, 0, x - 1, "+"); mvwprintw(screen, y - 1, x - 1, "+"); // sides for (i = 1; i < (y - 1); i++) { mvwprintw(screen, i, 0, "|"); mvwprintw(screen, i, x - 1, "|"); } // top and bottom for (i = 1; i < (x - 1); i++) { mvwprintw(screen, 0, i, "-"); mvwprintw(screen, y - 1, i, "-"); } }
Nothing complicated about that -- we draw a '+'
in the corners of the window, with a series of '|'
and '-'
characters for the top and bottom as appropriate. Now let's make it easier to see the "split" window:
int main(int argc, char *argv[]) { // ... // draw our borders draw_borders(field); draw_borders(score); // simulate the game loop while(1) { // draw to our windows mvwprintw(field, 1, 1, "Field"); mvwprintw(score, 1, 1, "Score"); // refresh each window wrefresh(field); wrefresh(score); } // clean up delwin(field); delwin(score); // ... }
Now you'll be able to see the two windows more easily. Notice that I had to tweak the offset on the call to mvwprintw
to prevent overwriting the top borders of the windows.
This version still suffers from problems I mentioned in my previous post -- when you resize your terminal window, the borders either disappear or don't snap to the edges of the screen depending on which way you resize.
Handling Window Resizing
Drawing borders around each window wasn't totally necessary, but it does allow us to see exactly how resizing the terminal window affects our two sub-windows. The process for redrawing the windows is straightforward, but detecting a resize event is a little tricky -- here's what we do:
- In the main game loop, check to see if the window dimensions have changed from the original.
- If changed, reset the original dimensions to the new ones.
- Resize the main playing field window, leaving room for the score display window.
- Reposition the score display window beneath the playing field and resize the width.
- Redraw all the window borders for great justice.
Here's what it looks like:
int main(int argc, char *argv[]) { int parent_x, parent_y, new_x, new_y; int score_size = 3; // ... draw_borders(field); draw_borders(score); while(1) { getmaxyx(stdscr, new_y, new_x); if (new_y != parent_y || new_x != parent_x) { parent_x = new_x; parent_y = new_y; wresize(field, new_y - score_size, new_x); wresize(score, score_size, new_x); mvwin(score, new_y - score_size, 0); wclear(stdscr); wclear(field); wclear(score); draw_borders(field); draw_borders(score); } // draw to our windows mvwprintw(field, 1, 1, "Field"); mvwprintw(score, 1, 1, "Score"); // refresh each window wrefresh(field); wrefresh(score); } // ... }
Now our sub-windows behave as expected:
Gotchas
When I was originally preparing this code example, I tried using both subwin
and derwin
to create subwindows of the main screen. While this approach worked when first running the program, it left artifacts of printed characters on the screen when resizing the window. Switching to using newwin
instead fixes the problem, but requires the programmer to free the memory afterwards -- a reasonable trade-off for a working program.
It's possible that these functions are better suited for showing a dialog box inside another window and not really for splitting a window. I'd encourage you to know about these other windowing functions and use them if they make sense in your programs.
Next Steps
You're able to move an object around the screen and can now create multiple windows to split up your game display. Now it's time to add some user interaction into your game -- I'll talk about that in my next post.
For reference, a complete version of the code discussed in this post is available as a Gist.