Lab 10: Structs and Character Animation

Overview

This lab will practice writing programs that use structs. We'll try to have some fun in the process, though. Folks always ask to do something game-like, so that's what we'll do.

We're going to keep everything in the terminal, since dealing with graphics libraries takes us a too far off-topic for one lab period. However, we will use a library called "curses" (actually "ncurses") that allows us to write to arbitrary positions in the terminal window and to read keystrokes in a "nonblocking" manner.

Part 1: ncurses basics (name p1.cpp)

The ncurses library views your terminal window as a big 2D table of single-character positions. So think of coordinates as (row,col) rather than (x,y). This means that (0,0) is the upper left corner of the terminal window. We've made it easy on you by writing a header file called easycurses.h and easycurses.cpp which wrap up the ncurses library main functionality into easy-to-call functions. Download it into your directory. All you need to do is
#include "easycurses.h"
in your .cpp files, and you need to compile with the -l ncurses compiler flag, like:

g++ -Wall p1.cpp easycurses.cpp -l ncurses -o p1
Do not ever touch easycurses.h and easycurses.cpp

Download p1.cpp under your account. This source code demonstrates some simple aspects of ncurses. Here's a few comments:


#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include "easycurses.h"
using namespace std;

int main() {
  // Initialize ncurses
  startCurses();

  // Draw 4 digit characters 
  // to the terminal screen.
  drawCharAndRefresh('A', 0, 0);
  usleep(800000);
  
  drawCharAndRefresh('B', 20, 30);
  usleep(800000);

  drawCharAndRefresh('C', 20, 31);
  usleep(800000);

  drawCharAndRefresh('D', 15, 50);
  
  // Loop forever until user enters 'q'
  char c;
  do { 
    c = inputChar();
  } while ( c!= 'q' );

  // Close ncurses
  endCurses();
  
  return 0;
}

Initializing Ncurses

startCurses();
This line of code should appear at the point in your main() when you're ready to go into "ncurses mode". When this is executed, the screen goes blank and you are ready to write to arbitrary locations in the terminal window.

Draw a character

drawCharAndRefresh('B', 20, 30);
This draws the char 'B' at position (20,30) in the terminal. How about a pause after you draw? The following sleeps your program for 800000 microseconds (i.e., 0.8 second):

usleep(800000);     // need: #include <unistd.h>

Non-blocking I/O with inputChar()

Ncurses (at least as we've initialized it) changes the model for how we read data. You fetch the next character with a call to inputChar(). The funny thing is that this is "non-blocking I/O", which means that you don't hang around and wait for input to come.
If you call inputChar() and nobody presses a key, inputChar simply returns immediately with an error code.
Contrast this with cin >> c or cin.get(). Thus, this loop just spins waiting for inputChar() to report that the user has pressed q.

Exiting Ncurses

The endCurses() function call is required in order for ncurses to clean up after itself and restore the terminal window.

Part 1a: Take it for a spin

Compile p1.cpp:

g++ -Wall p1.cpp easycurses.cpp -l ncurses -o p1
then run it. Use 'q' to quit.

Part 1b: How to debug your code with Ncurses

What if we need to use cout for debugging information? Of course we do! Well this is slightly problematic because ncurses now controls the screen. Suppose in the Part1 code we wanted to print out a debugging message "Printed a B!" just after writing the "B". We'd just add the line:

cout << "Printed a B!" << endl;
... immediately after the drawCharAndRefresh('B',20,30). Compile and run that.

What you will find is that this message pops up in the middle of our ncurses screen and messes things up. The "C" is no longer printed immediately to the right of the "B". It's a real problem when your debugging messages cause new bugs! So how can we write out debugging messages in an ncurses program?

  1. Use the standard error output stream (cerr) instead of standard out (cout) for the debugging message. This doesn't solve the problem immediately, but it helps. To see how ...
  2. Change your error message(s) to use cerr rather than cout, e.g.
    cerr << "Printed a B!" << endl;
    ... and run your program like this:
    ~/$ ./p1 2> err
    What this does is run your program as usual except that any output written to standard error (cerr) gets "redirected" to the file err, which is created or overwritten each time this is run. Let your program run to completion, then give the command cat err to see what is written.
  3. Now, what you'd really like is to see the debugging output as it happens. Here's how you do that: Open a separate terminal window, cd to the lab directory containing your program and the "err" file, and give the command
    ~/$ tail -f err
    Go back to your original terminal and type something like this:
    ~/$ echo "whoooooooo" > err
    You should see the whoooooo pop up in the other terminal. Spooky, eh?
  4. Now run p1 in the original terminal like this:
    ~/$ ./p1 2> err
    ... and watch as your ncurses window is pure and unblighted by debugging messages, but the message does pop up at the appropriate time in the second window.
    What's going on here:
    • 2> err is telling the shell to redirect standard error (cerr in our program) output to the file err.
    • Meanwhile, tail -f is constantly monitoring the file err, printing out any new lines that get written to the file.
    You can keep rerunning, compiling and debugging your program without ever having to rerun tail -f, which is nice.

Part 1c: Making Your Life Easier

Download rundebug to the same directory as your other files.

This automates Part 1b above. The script will open a second debugging terminal for you automatically and then run your p1 program. Just download it, and turn its executable bit on:

chmod 755 rundebug

Now instead of running ./p1 you just:

./rundebug p1

After running, close the debug window that pops up.

Submit:

You don't need to submit any file for part 1.

Part 2: Coming and going (name p2.cpp)

Functions drawChar() and refereshWindow()

In this part, we will use both drawChar() and drawCharAndRefresh().

Q: What's the difference between drawCharAndRefresh() and drawChar()?

A: drawCharAndRefresh() is simply drawChar() + refreshWindow()
The function refreshWindow() makes what you drew actually appear in the screen window.

In other words:

Due to this difference, if you want to draw multiple objects at the same time, you should call drawChar() multiple times and then call refreshWindow() once. On the other hand, if you just call drawCharAndRefresh() multiple times, those objects won't appear at the same time; rather, they will appear one by one.

Important!

We fully expect that the task described below will be done with a good design that makes use of structs and functions, and that the solution would work for a wide variety of inputs, not only for the input shown here.

Your task

Write a program from scratch that works as follows:
  1. Before going into ncurses mode, read input from the terminal like this
    4
    a (10,15)
    x (14,29)
    k (5,5)
    x (18,37)
  2. Start up ncurses.
  3. Draw each of the objects to the screen one by one using drawCharAndRefresh(). As with Part 1, pause for 0.8 second right after drawing each character.
  4. Erase all of them at the same time from the screen. Erasing objects at the same time means:
    1. For each object, write a ' ' (i.e., a blank) in the appropriate position using drawChar(). For example, with the above user input, you should call drawChar() 4 times. This time, don't pause, since you are erasing them at the same time.
    2. Call refreshWindow(). You just to need to call this function only once. This function will make what you drew using drawChar() actually appear on the screen.
    3. Pause for 0.8 second.
  5. Check the user input by calling inputChar(). If the user has pressed 'q', terminate the program. Otherwise, go back to step 3.

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 3: You got to move it, move it (name p3.cpp)

Copy p2.cpp into p3.cpp

Now we will animate things, i.e. each character will move on the screen.

Initial position and direction

Start all your characters using the positions from Part 2. All characters should move to the right (i.e., east).

Write structs and functions!

Make sure you store the position and direction in your struct! You may want to consider a couple functions which takes your struct type and modifies its position. Also, think for a moment about HOW you want to represent the current direction (more than one option is possible!).

Main loop

Your program will work as follows:


// read your characters as in part 2
// draw them with drawChar() calls
// call refreshWindow() (only once) to show what you drew on the screen
// sleep for 80,000 micro-seconds (i.e., 0.08 sec). 
 
for(int frame=0; frame < 20; frame++)
{
  // 1. Move all characters one step:
  //   a. erase them in the old positions with drawChar() calls
  //   b. compute the new positions for characters (based on the direction of each character)
  //   c. draw them in the new positions with drawChar() calls
  // 2. Call refreshWindow() (only once) to show what you drew on the screen

  usleep(80000); // sleep for 80,000 micro-seconds (i.e., 0.08 sec). 
                 // This corresponds to flipping to the next "frame" in an animation.
                 // The program is drawing about 12.5 (= 1/0.08) frames per second. 
} 

// exit the program right away; you don't need to take any user input

Requirement: Don't call drawCharAndRefresh()!!!

For part 3 and later parts:

  • Do not use drawCharAndRefresh() at all.
  • You must use only drawChar() and refreshWindow().
If you use drawCharAndRefresh(), the screen will blink a lot. This is because the program would show intermediate steps on the screen. In particular,
  • Recall that drawCharAndRefresh() calls refreshWindow() (i.e., drawCharAndRefresh() = drawChar() + refreshWindoow()).
  • Erasing the characters in the old positions using drawCharAndRefresh() will refresh the screen, showing no character on the screen for a moment.
  • Of course, the program will draw the characters in the new positions and refresh the screen, so the characters will come back and appear on the screen soon.
  • Therefore, the characters will repeatedly disappear and appear. They will blink.

Note also that refreshWindow() should be called exactly once every 0.08 second. This is because refreshWindow() is a costly function; physically refreshing the entire screen is not as cheap as changing some variables. Too many calls to refreshWindow() will slow down your program and you will feel it.

Therefore, many calls to drawCharAndRefresh() will make your program blinking and slow! Don't use drawCharAndRefresh() at all!

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 4: Changing the objects' direction randomly (name p4.cpp)

Copy p3.cpp into p4.cpp

Of course at this point your characters move only to the right, and things are not that interesting. So, to add a little drama, let's say that at each step, we make the character wander around a bit.

Seeding the random number generator

First, seed the random number generator with
srand(time(0));   // need: #include <cstdlib>
                  //       #include <ctime>
or else it will do the same thing every time — that would be boring!

Initial position and direction

It's the same as Part 3. Start all your characters. Initially all characters should move to the right (i.e., east).

Changing the objects' direction randomly

Now in the main loop, immediately before actually moving, each character should first determine the new direction as follows:

  1. With probability 1/5, the character chooses to make a turn (i.e., moving from horizontally to vertically, or vice versa). With probability 4/5, the character keeps the current direction.
    Here, making a turn means changing the direction with 90 or -90 degrees:
    • moving from horizontally to vertically.
    • moving from vertially to horizontally.

    Recall: The following code will print "yes" with probability 1/5:

    if( rand() % 5 == 0 )
          cout << "yes" << endl;

    You have to call rand() for each character to consider the probability of turning individually.

    When you call drawChar().

    • Moving North means going up, so the row value should be decreasing.

    • Moving South means going down, so the row value should be increasing.
    Important Tips

    At this moment, each character should remember its direction. Otherwise, you won't be able to say:

    "Hey character, keep going in the same direction as before".

    Note the direction could be E, W, S, or N.

    Note: Character A may have a different direction from Character B. Each character should remember its own direction.

  2. If the character should actually turn, which happens with probability 1/5, call rand() once more to choose the actual direction (if it doesn't turn, don't call rand()). In particular,
    • int r = rand()%2;
    • If the character should move from horizontally to vertically:
      • If r is 0, the new direction of the character should be S.
      • If r is 1, the new direction of the character should be N.
    • If the character should move from vertically to horizontally:
      • If r is 0, the new direction of the character should be E.
      • If r is 1, the new direction of the character should be W.

Tips for Debugging your code for Part 4
  1. Do cerr for the following:
    • Add the following in the first line of the body of the main for loop in order to show which frame the code is drawing.
      
        cerr  << endl << "frame = " << frame << "*****************************" << endl;
        
    • The output of each rand() call. Since a second rand() call outputs a different number, store the output of the rand() call and use it for cerr and your if-conditions as follows:
      
        int r = rand()%5;
        cerr << "rand()%5: " << r << endl;
        
      
        int r = rand()%2;
        cerr << "rand()%2: " << r << endl;
        
    • Arguments for each drawChar() call. Whenever you call drawChar(c, row, col), do:
      
        cerr << "drawChar(" << c << ", " << row << ", " << col << ")" << endl;
        
    • For each character, cerr its new direction. For example:
      
        cerr << "character " << label << ": direction = " << direction << endl;
        
      In the above snippet, label and direction indicate some variable(s) in your code for the label of the character and its new direction.
  2. Use rundebug script (read Part 1).
  3. Start your program with only one character. Debugging with many characters would be very hard to track things down. Just use one character.
  4. Given all the cerr messages, see if the rand() outputs and the movement of the character follow the instructions correctly.
Note: Sometimes, your characters move off the screen, and ncurses goes crazy --- nothing it draws makes sense anymore. That's fine.

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 5 (going further): Momma should I build a wall? (name p5.cpp)

Copy p4.cpp into p5.cpp

Instead of rendering exactly 20 frames, your solution for this part should run forever until the user enters 'q' (as with part 2). If you do this, at some point your characters move off the screen, and ncurses goes crazy --- nothing it draws makes sense anymore. Just kill the program with ctrl-c, and don't worry about it.

Now, though your animated character makes random turns, eventually it's going to make its way off the screen and strange things will happen. It'd be much cooler if the screen acted like a walled-in space that the character cannot escape. So let's do that. The only real difficulty here is that you need to know the height and width of the terminal window (in characters). Our easycurses function for that:


void getWindowDimensions(int& row, int& col); // declared in easycurses.h
So the call getWindowDimensions(h,w) sets h to the number of rows on the screen, and w to the number of columns. Note, then, that the valid positions you can drawChar(ch,row,col) to are when 0 ≤ row < h and 0 ≤ col < w.

Now that you can get the height and width of the terminal window, when you come to a "move" step you must check to see whether the move in question would take you off the screen. If it does, then instead of moving just change direction and leave it at that. Make the character "bounce" by simply reversing direction (north goes to south, east to west, etc). If you do this, your character will be walled in and will never leave the terminal window.

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 6 (going further): Yes it's that easy (name p6.cpp)

Now, instead of a few moving characters, make it 20 (or 40 or 1000 or whatever you want!). If you've done things right, you should be able to do this trivially. If you haven't made good use of structs and functions, it might be painful!

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 7 (going further): Imperio (name p7.cpp)

Now, let's make one character that's different. This will be a character that the user controls. To keep it simple, we'll use the a,s,d,w keys. Immediately after the usleep(), and prior to actually moving anyone, do a
char kb = inputChar();
... and if kb is an 'a', change the one character's direction to west, a 'd', change it to east, an 's' change it to south, and a 'w', change it to north. Note that this one character won't be subject to the random direction changes, and we won't worry about the walls for him ... it'll be up to the user to keep the player on the board.

(Typing a 'q' should still be used to quit the game.)

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp

Part 8 (going further): And in the end ... (name p8.cpp)

One last thing, if the user-controlled character collides with one of the other characters, or runs off the board, he dies and the game is over. When that happens, you might want to pause for a second or two before exiting ncurses. Oh, it'd also be a nice touch to report the number of steps (or seconds) the user stayed alive. You could add other fun things ... the tempo could increase, or more guys could enter the game or ... whatever else you can think of.

Submit:

~/bin/submit -c=IC210 -p=lab10 p*.cpp