CPSC 225 Intermediate Programming Spring 2025

Project 2: Omino

Checkpoint: Fri Feb 28 at 11:59pm

Due: Fri Mar 7 at 11:59pm

Introduction

The projects are opportunities to work on larger programs, and to bring together and apply topics from the course. Specific objectives for this project include:

Checkpoint

The checkpoint deadline is a guideline for pacing. There isn't anything to hand in, but you should treat it like a real deadline — if you aren't on track to meet it, you run a greater risk of not completing the project on time.

Revise-and-Resubmit and Late Work

Fixing problems is an important component of learning, and most assignments will have an opportunity for revise-and-resubmit. The resubmit deadline will be announced when the initial handin is handed back, and will generally allow a week or so for revision.

Revise-and-resubmit requires there to be something to revise and feedback to act on — it is not intended as a de facto extension. To be eligible for revise-and-resubmit, there must be some effort put into the assignment (a 3 or higher) for the original handin.

Late work is generally not accepted. If circumstances beyond your control prevent you from making any progress on an assignment before it is due, you are at significant risk of falling behind in the course. Come talk to me ASAP!

Allowed Resources, Working With Others, and Getting Help

You may use the course materials (slides, examples) posted on the course webpage and the course Canvas site as well as the textbook.

You are encouraged to come to office hours and Teaching Fellows for help with thinking through getting started, Java help, debugging help, or help with anything else you might be stuck on.

The use of generative AI (such as ChatGPT), homework help websites, or other resources (friends, YouTube, etc) as learning cheats is not permitted. "As a learning cheat" means generating, finding, copying, etc code that you incorporate directly or with only minor modifications into your solution. The most egregious version of this is generating the entire program, but the prohibition applies to even small sections of code as well.

Using other resources as learning aids is permitted, but you are strongly encouraged to utilize the course materials, office hours, and Teaching Fellows as your primary sources of help. "As a learning aid" is to understand concepts — e.g. getting examples of loops to understand how to write loops in general or the specific syntax for loops in Java, not their specific application to this program. ("An example of a program to play flip" is a solution, not an example, and is not a learning aid.)

Handin

To hand in your work:


Omino

Tetris is a classic computer game dating back to the mid-1980s. In it, tetrominoes (shapes made up of four squares) descend from the top of the screen, piling up on previous pieces. Players try to position the falling pieces to fill complete rows, which are then cleared. The goal is to last as long as possible before the board fills up to the top and no more pieces can be placed. Tetris is an extremely popular game and versions have appeared on a variety of computing platforms — and even in some less conventional places.

In this project, you'll implement a falling-polyomino game (which we'll call Omino!) that is similar to Tetris. It will be more flexible than Tetris, however, in that it will be able to easily support any collection of polyominoes (shapes made up of any number of squares) as well as any size board. Your game will look like the following:

The game will be played using the keyboard: 'j' moves the current piece to the left, 'l' moves it to the right, 'k' rotates counterclockwise, ' ' (spacebar) drops, 'n' starts a new game, 'p' pauses/unpauses the game, and 'q' quits the program.


Task

You have been provided with the user interface for the game; your job is to write the core of the program along with a tester to help verify the correctness of your code. An overview of the program organization and the specifics of the classes you should implement, including their instance variables, constructors, and methods, can be found in the Code Specifications section below. You should read through that whole section to gain an understanding of what you'll be doing before you start implementing. Note that you must use the provided code and create/implement the classes and methods as described below — this is not an assignment to write a Tetris game from scratch, and you will not receive credit if you do.

Your program should be robust — it shouldn't crash if the user uses it incorrectly — and your code should reflect good coding style. Javadoc comments should be present for all classes and methods and comments should include necessary information (including preconditions) without also including unnecessary or inappropriate information. Also choose descriptive names and follow consistent and standard conventions for naming and whitespace. It is strongly recommended that you follow the 225 programming standards. Autoformat will take care of many potential whitespace-related issues, including improper indentation and too-long lines. Use blank lines for grouping and organization within longer methods.

Setup

There should not be any compiler errors in the imported code, and you should be able to run the program. (You'll just see a window with an empty board — pressing keys to play the game won't do anything at this point as you have to write that part!) Note that this program uses JavaFX, so you'll need to have that set up properly.

Plan of Attack

Checkpoint

To be on track, you should have implemented and tested the supporting classes (Block, Polyomino, Piece, and Board) by the checkpoint deadline given at the beginning of this handout. Of course, being ahead of that is even better!


Code Specifications

Program Organization

This program is organized into six main classes:

There are also several supporting classes:

Testing

As you implement the classes below, also implement the test cases described in OminoTester. The structure should be similar to the testers in labs 2 and 4 — add tester subroutines for each method being tested, and call them from main to run specific test cases.

Classes

Implement the classes described below. Be sure include Javadoc comments for the class and each method. Also identify any class invariants and method preconditions in the appropriate comments and add runtime checks for those conditions where it is productive to do so.

Block

A polyomino is made up of a bunch of squares (or blocks). Block encapsulates the position (row and column) of a polyomino block.

Instance variables:

Constructor:

Methods:

Testing:

Block just holds and provides access to row and column values, and its method bodies are short and straightforward. There's not really any value in writing test cases for Block.


Polyomino

Polyomino captures the notion of a particular configuration of blocks independent of orientation i.e. a shape.

To describe a polyomino, a coordinate system is defined where (0,0) is the lower left corner of the rectangle surrounding the shape. There will always be at least one block in row 0 and in column 0, though there is not necessarily a block at (0,0).

For convenience, the list of blocks in a polyonimo will be specified as a string in the form r1 c1 r2 c2 r3 c3 .... The blocks can be listed in any order, and there can be any number of spaces between each row and column (as long as there's at least one).

For example, the two trominoes shown below would be described by the strings 0 0  1 0  2 0 and 0 0  1 0  0 1 respectively.

Since there can be more than one orientation of a given polyomino, we need to account for rotation. While it is possible to compute the correct blocks for all of the orientations given just one orientation, it is easier to work out the correct blocks for each orientation in advance (by drawing pictures of each orientation) and then just storing them all.

Instance variables:

Constructor:

Methods:

getBlocks will need to turn a string of block coordinates into an array of Block objects. String's split method is very handy here; the following code shows how to create an array of Blocks from a string of the form r1 c1 r2 c2 r3 c3 ...:

  String str = "0 0  1 0  2 0";
  String[] coords = str.split(" +");
  Block[] blocks = new Block[coords.length / 2];
  for ( int i = 0 ; i < coords.length ; i += 2 ) {
    blocks[i / 2] = new Block(Integer.parseInt(coords[i]),
                              Integer.parseInt(coords[i + 1]));
  }

Testing:

Like Block, Polyomino mostly just holds information and provides access to it. There are a couple of things worth testing, though.

Add tester subroutines and test cases for getNextRotation and getBlocks to OminoTester. For getBlocks, though, there's a wrinkle — checking that an array of Blocks has the right contents is a bit cumbersome. Address this by adding a private helper method blocksToString to OminoTester. blocksToString should take an array of Blocks and return a String formatted like the Strings given to Polyomino (i.e. in the form r1 c1 r2 c2 r3 c3 ...). You can then use blocksToString to make a String version of the Blocks array for easier comparison with the expected result.


Piece

Piece represents a particular orientation of a particular polyomino. It is immutable, meaning that rotating a piece results in a new piece rather than changing the orientation of the current piece.

Several useful properties of a piece can be computed from its blocks. The dimensions (width and height) are expressed in terms of the number of blocks.

Instance variables:

Constructor:

Methods:

The piece's blocks are the blocks for its particular orientation of the polyomino; you can get this from the polyomino.

Helper methods: (these are just like regular methods, but are private)

Note that neither of these helper methods use or change any of Piece's instance variables — they work solely with their parameters. Computing the width and height of the piece requires the blocks of the particular orientation of the polyomino; you can get the blocks from the polyomino.

Testing:

Like Polyomino, Piece mostly holds information (or accesses information held by Polyomino) but it does do a little computation that should be tested.

Add a tester subroutine and test case(s) to OminoTester to test that Piece's constructor correctly initializes the width and height.


Board

Board is the main playing area where the pieces land. It is represented as a grid of squares with (0,0) in the lower left corner, and keeps track of which pieces the blocks occupying squares come from.

Instance variables:

Constructor:

Methods:

The position of a piece is the row and column on the board corresponding to where the piece's (0,0) position is. Remember that (0,0) is in the lower left corner for both the piece and board coordinate systems.

For canPlace, blocks of the piece are allowed to extend above the top of the board, but cannot extend past the sides or the bottom or overlap blocks already on the board.

For addPiece, blocks that extend past the top of the board should be ignored — don't try to add them to the board, but it isn't an error if they exist. Assume that it is legal to add the piece i.e. canPlace is true.

For getDropRow, the landing row is be the row on the board where the piece's row 0 would end up if the piece was dropped. One way to determine the drop row is to keep asking canPlace for lower and lower rows until the last one where the piece can be placed is found. (A cleverer solution is an extra credit option.)

Helper methods: (these are just like regular methods, but are private)

Testing:

Many of Board's public methods do complex things, so you should identify and implement tester subroutines and test cases for them. There are two wrinkles, though: you'll want to test methods when there are blocks filled on the board, but the Board constructor only creates an empty board (and there's no real way to set up a particular board configuration), and the only way to check the contents of the board is to use the getter that tells you if a particular position on the board contains a block or not (which is kind of tedious to use to check the whole board).

To deal with the first wrinkle, add a package-level (not public or private) constructor to Board which takes a 2D array of Pieces storing the contents of the board and initializes the instance variables accordingly.

To deal with the second wrinkle, create a private helper method in OminoTester which takes a Board and a 2D array of Pieces storing the contents of the board and returns true if the Board's contents match the array and false otherwise. Interpret "match" as just that the same spots have blocks on the board and in the array — you don't need to check that the same Pieces are in the same spots.

It's still a bit tedious in the tester to set up the array of Pieces for the starting state and the expected result, but keep in mind that you can use the same piece for all the occupied blocks and you don't need to use a full-size board. Also use the initializer list syntax for the array.


Game

Game brings together the individual elements — the board, the current piece, piece movement, scoring — into the whole game.

You've been provided with a skeleton of part of the full Game class. Fill in and add to this skeleton as described in this section instead of creating a brand new class.

The board dimensions (in blocks), polyomino definitions and colors, and scoring information will be hardcoded into the game. Defining these things as constants brings the definitions together in one place and makes them easy to change.

Constants:

While constants are often public, these can be private since they exist only to make it easier to change the definitions within Game.

You are free to choose the board dimensions, point values, colors, and polyominoes subject to the following constraints:

Instance variables:

Constructor: (note that the header has been provided — just fill in the body as described)

Methods:

In particular, movePiece should:

Determining the new position for ROTATE is a little tricky as the piece should appear to rotate around its center rather than around its (0,0) block. The computation to figure out the new position:

  newrow = row + (piece height - rotated piece height) / 2;
  newcol = col + (piece width - rotated piece width) / 2;

Helper methods: (these are just like regular methods, but are private)

One additional thing is needed in order for changes to the game state in Game to result in the display being updated. Go through the constructor and methods of Game and identify where there are changes to the board's contents, the current piece (its position and/or the piece itself), the score, the number of pieces played, and/or the number of rows cleared. For each such occurrence, add one or more of the lines shown below at the end of the method:

  firePropertyChange(BOARD_PROPERTY);     // if there's a change to board contents
  firePropertyChange(CURPIECE_PROPERTY);  // if there's a change to the current piece (piece and/or position)
  firePropertyChange(SCORE_PROPERTY);     // if there's a change to the score
  firePropertyChange(NUMPIECES_PROPERTY); // if there's a change to the number of pieces played
  firePropertyChange(NUMROWS_PROPERTY);   // if there's a change to the number of rows cleared

Omino

Omino contains the main program and handles the user interface of the program. You don't need to look at or understand most of this code (unless you are curious), but you will need to fill in some pieces to connect the code you've written with the user interface. These things are described below, and can be located in the code by looking for TODO comments. (Eclipse hint: with Omino.java opened in an editor tab, look for small blue rectangles in the right margin of the editor. These indicate TODO comments in the file; click on one of the rectangles to go to that comment.)

Note that Omino has a Game instance variable called game_. This has already been initialized in the provided code; you just need to use it.

For the board and the current piece, draw each block as a solid square of size BLOCK_SIZE (defined in Omino) using the associated piece's color with a black outline so that it is possible to distinguish blocks from one another.

Extra Credit Features

Extra credit features are things you can add for extra credit. Make sure you complete the requirements of the assignment first, however, before tackling extra credit options!

Less-involved options: (easier, but fewer points)

Intermediate options: (harder than the easy options, easier than the harder options)

More-involved options: (harder, but more points)

If you are thinking of tackling either of the more involved options, stop by to discuss how to best incorporate them into the program — you should add the extra credit functionality rather than replacing the required versions.


Hints, Suggestions, and Technical Notes

Reference

This is a GUI program and you will need to work with colors and draw shapes. See section 3.9.1 in the text for more information.

Constants

Constants are like variables with a glass lid — once it has been initialized, the value can be seen through the lid but it can't be changed. Section 4.8.3 addresses named constants.

enums

Action is an enum which defines the possible piece-manipulation actions: An enum is essentially a combination of a type and a set of constants — Action is a type that can be used for declaring variables, parameters, and return types and Action.LEFT, Action.RIGHT, Action.ROTATE, Action.DROP, and Action.DOWN are the possible values that something of type Action can have. Section 2.3.5 of the text has more about enums.

2D Arrays

For the polyomino definition array, keep in mind that a 2D array is really a 1D array where each slot holds a 1D array — and that you don't have to have the same number of elements in each row. For example, the following defines the two one-sided trominoes (three squares) using the initializer list syntax:

  private static final String[][] POLYOMINOES =
    { { "0 0  1 0  2 0", "0 0  0 1  0 2" },
      { "0 0  1 0  0 1", "0 0  0 1  1 1", "1 0  0 1  1 1", "0 0  1 0  1 1" } };

Auto-Generating Constructors and Getters

Eclipse can help you with code generation for constructors and getters. When defining a new class, write the declarations for the instance variables, then put the cursor where you want the constructor or getters to go, right-click, and choose "Generate Constructor using Fields..." or "Generate Getters and Setters..."

For the constructor, choose which instance variables are to be initialized directly from parameters passed to the constructor (unselect the others) and click "Generate". You can then fill in the rest of the constructor body as needed.

For getters, select which getters you want to generate by expanding the tab next to the relevant instance variables and selecting the getter item. Click "Generate" when you are done. You can then edit the generated code as desired, such as if you want to change the name of the getter.

Potential Pitfalls

There are several different coordinate systems in use in this program, and it is easy to get them mixed up. Some tips:


Credits

The original inspiration (and some of the structure) for this assignment comes from materials by Nick Parlante.