Csci 210 Lab 9: Tetris II
(Laura Toma adapted from Eric Chown based on a lab developed
at Stanford by Nick Parlante)
Overview
In this second part of the Tetris lab, you will implement class
Board, the class that handles the game board. The player code
is provided for you. You can find the Java code for this lab here.
Board
In the objected-oriented design of a tetris game, the board class does
most of the work, that is:
- Store the current state of a tetris board.
- Provide support for the common operations that a client "player"
module needs to build a GUI version of the game: add pieces to the
board, let pieces gradually fall downward, detect various conditions
about the board. but you need to
implement Board.
- Perform all of the above quickly. Our board implementation will
be structured to do common operations quickly. Speed will turn out to
be important.
The grid
The board represents the state of a tetris board. Its most obvious
feature is the "grid" --- a 2D array of booleans that stores which
spots are filled. The lower left-corner is (0,0) with X increasing to
the right and Y increasing upwards. Filled spots are represented by a
true value in the grid. The place() operation (below)
supports adding a piece into the grid, and the clearRows()
operation clears filled rows in the grid and shifts things down.
Widths and heights
The secondary "widths" and "heights" structures make many operations
more efficient.
The widths array stores how many filled spots there are in each
row. This allows the place() operation to detect efficiently if the
placement has caused a row to become filled.
The heights array stores the height to which each column has been
filled. The height will be the index of the open spot which is just
above the top filled spot in that column. The heights array allows the
dropHeight() operation to compute efficiently where a piece
will come to rest when dropped in a particular column.
The main board methods are the constructor, place(),
clearRows(), and dropHeight().
Board:: constructor
The constructor initializes a new empty board. The board may be any
size, although the standard Tetris board is 10 wide and 20 high. The
client code may create a taller board, such as 10x24, to allow extra
space at the top for the pieces to fall into play (our player code
does this).
As you know by now, a 2D array is really just a 1D array of
pointers to another set of 1D arrays. To allocate the grid use
new boolean[width][height].
Board:: int place(piece, x, y)
This takes a piece, and an (x,y), and sets the piece into the grid
with the origin (the lower-left corner) of the piece at that location
in the board. The undo() operation (below) can remove the
most recently placed piece.
Return value: returns PLACE_OK for a successful placement. Returns
PLACE_ROW_FILLED for a successful placement that also caused at least
one row to become filled.
Error cases: It's possible for the client to request a "bad"
placement -- one where part of the piece falls outside of the board or
that overlaps spots in the grid that are already filled. Such bad
placements may leave the board in a partially invalid state -- the
piece has been partly but no completely added for example. If part of
the piece would fall out of bounds return PLACE_OUT_BOUNDS. Otherwise,
if the piece overlaps already filled spots, return PLACE_BAD . The
client may return the board to its valid, pre-placement state with a
single undo().
Board:: clearRows()
This method deletes each row that is filled all the way across,
causing things above to shift down. New rows shifted in at the top of
the board should be empty. There may be multiple filled rows, and they
may not be adjacent. This is a complicated little coding problem. Make
a drawing to chart out your strategy. Use JBoardTest (below)
to generate a few of the weird row-clearing cases.
Implementation: The nicest solution does the whole thing in
one pass --- copying each row down to its ultimate destination,
although it's ok if your code needs to make multiple
passes. Single-pass hint: the To row is the row you are copying down
to. The To row starts at the bottom filled row and proceeds up one row
at a time. The From row is the row you are copying from. The From row
starts one row above the To row, and skips over filled rows on its way
up. The contents of the widths array needs to be shifted down also.
By knowing the maximum filled height of all the columns, you can
avoid needless copying of empty space at the top of the board. Also,
the heights[] array will need to be recomputed after row clearing. The
new value for each column will be lower than the old value (not
necessarily just 1 lower), so just start at the old value and iterate
down to find the new height.
Board:: int dropHeight(piece, x)
DropHeight() computes the y value where the origin (0,0) of a piece
will come to rest if dropped in the given column from infinitely
high. Drop height should use the heights[] array and the skirt of the
piece to compute the y value quickly, in O(piece-width)
time. DropHeight() assumes the piece falls straight down -- it does
not account for moving the piece around things during the drop.
The board undo() abstraction
The problem is that the client code doesn't want to just add a
sequence of pieces, the client code wants to experiment with adding
different pieces. To support this client use, the board will implement
a 1-deep undo facility. This will be a significant complication to the
board implementation that makes the client's life
easier. Functionality that meets the client needs while hiding the
complexity inside the implementing class -- OOP encapsulation design
in a nutshell.
The board has a "committed" state which is either true or
false. Suppose at some point that the board is committed. We'll call
this the "original" state of the board. The client may do a single
place() operation. The place() operation changes the board state as
usual, and sets committed=false. The client may also do a clearRows()
operation. The board is still in the committed==false state. Now, if
the client does an undo() operation, the board returns, somehow, to
its original state. Alternately, instead of undo(), the client may do
a commit() operation which marks the current state as the new
committed state of the board. The commit() means we can no longer get
back to the earlier "original" board state.
Here are the more formal rules.
- The board is in a "committed" state, and committed==true.
- The client may do a single place() operation which sets committed==false. The board must be in the committed state before place() is called, so it is not possible to call place() twice in succession.
- The client may do a single clearRows() operation, which also sets committed==false
- The client may then do an undo() operation that returns the board to its original committed state and sets committed==true. This is going backwards.
- Alternately, the client may do a commit() operation which keeps the board in its current state and sets committed==true. This is going forwards.
- The client must either undo() or commit() before doing another place().
Here is a little state change diagram which may help (or not).
Basically, the board gives the client the ability to do a single
place, a single clearRows, and still get back to the original
state. Alternately, the client may do a commit() which can be followed
by further place() and clearRows() operations. We're giving the client
a 1-deep undo capability.
Commit() and undo() operations when the board is already in the
committed state don't do anything. It can be convenient for the client
code to commit() just to be sure before starting in with a place()
sequence.
Client code that wants to have a piece appear to fall will do
something like following.
place the piece up at the top of the board
undo
place the piece one lower
undo
place the piece one lower
...
detect that the piece has hit the bottom because place returns PLACE_BAD or PLACE_OUT_OF_BOUNDS
undo
place the piece back in its last valid position
commit
add a new piece at the top of the board
Undo() is great for the client, but it complicates place() and
clearRows(). Here is one implementation strategy.
Undo() implementation strategy: Backups
For every "main" board data structure, there is a parallel
"backup" data structure of the same size. place() and clearRows()
operations copy the main state to the backup before making
changes. undo() restores the main state from the backup.
Widths and Heights
For the int[] widths and heights arrays, the board has backup
arrays called xWidths and xHeights. On place(), copy the current int
contents of the two arrays to the backups. Use
System.arraycopy(source, 0, dest, 0, length). System.arraycopy() is
pretty optimized as Java runtime operations go.
Swap trick: For undo() the obvious thing would be to do an
arraycopy() back the other way to restore the old state. But we can
better than that. Cool trick: just swap the backup and main
pointers.
// perform undo...
System.arraycopy(xWidths, 0, widths, 0, widths.length); // NO
int[] temp = widths; // YES just swap the pointers
widths = xWidths;
xWidths = temp;
This works very quickly. So the "main" and "backup" data structures
swap roles each cycle. This means that we never call "new" once they
are both allocated which is a great help to performance. So the
strategy is arraycopy() for backup, and swap for undo().
Grid
The grid needs to be backed up for a place() operation...
Simple: The simplest strategy is to just backup all the
columns when place() happens. This is an acceptable strategy. In this
case, no further backup is required for clearRows(), since place()
already backed up the whole grid.
Less Simple: The more complex (faster) strategy is to only
backup the columns that the piece is in ---- a number of columns equal to
the width of the piece (you do not need to implement the complex
strategy; I'm just mentioning it for completeness). In this case, the
board needs to store which columns were backed up, so it can swap the
right ones if there's an undo() (two ints are sufficient to know which
run of columns was backed up).
If a clearRows() happens, the columns
where the piece was placed have already been backed up, but now the
columns to the left and right of the piece area need to be backed up
as well.
As with the widths and heights, a copy-on-backup, the
swap-pointers-on-undo strategy works nicely.
Undo() implementation strategy: Alternatives
You are free to try alternative undo strategies, as long as they are
at least as fast as the simple strategy above. The "articulated"
alternative is to store what piece was played, and then for undo, go
through the body of that piece and carefully undo the placement of the
piece. It's more complex this way, and there's more logic code, but
it's probably faster. For the row-clearing case, the brute force copy
is probably near optimal---too much logic would be required for the
articulated undo of the deletion of the filled rows. The
place()/undo() sequence is much more common than
place()/clearRows()/undo(), so concentrating on making place()/undo()
fast is a good strategy.
sanityCheck()
The Board has a lot of internal redundancy between the grid, the
widths, the heights, and maxHeight. Write a sanityCheck() method that
verifies the internal correctness of the board structures: that the
widths and heights arrays have the right numbers, and that the
maxHeight is correct. Throw an exception if the board is not sane --
throw new RuntimeException("description"). Call sanityCheck() at the
bottom of place(), clearRows() and undo(). A boolean static called
DEBUG in your board class should control sanityCheck(). If debug is
on, sanityCheck does its checks. Otherwise it just returns. Turn your
project in with DEBUG=true. Put the sanityCheck() code in early. It
will help you debug the rest of the board. There's one tricky case: do
not call sanityCheck() in place() if the placement is bad -- the board
may not be in a sane state, but it's allowed in that case.
Performance
The Board has two design goals: (a) provide services for
the convenience of the client, and (b) run fast. To be more explicit,
here are the speed prioritizations.
- Accessors: getRowWidth(), getColumnHeight(), getWidth(),
getHeight(), getGrid(), dropHeight(), and getMaxHeight() Ñ these
should all be super fast. Essentially constant time.
- The place()/clearRows()/undo() system can copy all
the arrays for backup and swap pointers for undo(). That's almost as
fast as you can get.
Milestone ---JBoardTest (The Mule)
Once you have Board somewhat written, you can use the provided
JBoardTest class ("the mule") for testing. The mule is just a GUI
driver for the board interface. It lets you add pieces, move them
around, place, and clear rows. In the spirit of "transparency"
debugging, it also displays the hidden board state of the widths and
heights arrays. Use the mule to try out basic test scenarios for your
board. It's much easier than trying to observe your board while
playing tetris in real time. You can also write your own debugging
helper code such as a printBoard() method that prints out all of the
state of the board to standard output -- sometimes it's handy to have
a log you can scroll back through to see all the state over time. The
"drop" button in our applications will only work if there's a
straight-down vertical path for the piece to fall from an infinite
height. If there's an overhang in the way above the piece, drop will
not do anything (look at the JBoardTest source code for "drop").
Here I've set up a case to test clearing multiple rows. You can
look at the JBoardTest sources to see how it's implemented. It's a
fairly thin layer built on the board.