Lab 7: Working with GUIs
Objectives
1 Introduction
2 Provided code
3 Getting started:   calibrating your view
3.1 Calibration with more convenient coordinates
3.2 Calibration via transformations
4 Aside:   Cleaning up as you go
5 Drawing Simon Circles
5.1 Redrawing circles using shapes
6 Clicking on circles
7 Enhancements to Simon
8 Additional practice with views
9 Questions to ponder/  discuss
8.9

Lab 7: Working with GUIs

Starter files: code.zip

Objectives

The objectives of this lab are:

If you wish, you may work with another student for this lab. But you must still submit individually!

1 Introduction

In class we have seen how to create views using existing controls, such as buttons, text boxes, and labels. But if we want to create custom graphics, we’ll need to work a bit harder at it.

This lab will introduce you to drawing your own graphics by building up an interactive GUI game of Simon. Simon is a memory game, where the game generates a random sequence of flashing colorful shapes, and the player has to play them back. (Typically, the game involves playing back the sequence, but since that adds an element of animation or time-driven drawing, we’ll ignore that in this example game.)

2 Provided code

The starter files for this lab contain a complete model for the game, as well as a controller and a features interface. The view, however, is incomplete.

SimpleSimonView is a bare-bones JFrame subclass that implements our view interface. It has been wired up to a JSimonPanel, which is where most of our graphical work will be. Our panel requests a default size of 350x350 pixels, and so our window will size itself to be just big enough to contain that panel.

3 Getting started: calibrating your view

Similar to how TVs use a calibration test pattern to check if they’re displaying properly, we should try to draw a test pattern to make sure we understand where our image should go.

The first thing to try is to draw an X along both diagonals. To measure the size of your panel, use this.getBounds(), which returns a Rectangle object containing x, y, width and height fields. To draw anything, we need to override the paintComponent method of our panel. We could draw both lines of the X in the same color, but it will be more informative if we draw them in different colors.

@Override
protected void paintComponent(Graphics g) {
  super.paintComponent(g);
  Graphics2D g2d = (Graphics2D) g.create();
  Rectangle bounds = this.getBounds();
  g2d.setColor(Color.RED);
  g2d.drawLine(bounds.x, bounds.y, bounds.x + bounds.width, bounds.y + bounds.height);
  g2d.setColor(Color.BLUE);
  g2d.drawLine(bounds.x + bounds.width, bounds.y, bounds.x, bounds.y + bounds.height);
}

Predict which diagonal is going to be red, and which one is going to be blue. Why?

Once you’ve figured out which diagonal is which, it might be useful to further test your assumption, and draw differently-colored circles in each corner to check your understanding. You can use the g2d.fillOval method, which takes in a top, left, width and height, and fills in an oval within that rectangle. Since we want to draw circles centered on the corners, design a helper method that takes in the center of a circle, its radius and a color, and draws the given-colored circle of the desired size at the desired center. Draw a red circle in the top-left corner, a yellow circle in the top-right, a green circle in the bottom-left, and a blue circle in the bottom-right. Again, choosing different colors and different target locations helps you confirm that you’ve got the correct understanding of the coordinate system of your display.

3.1 Calibration with more convenient coordinates

In order to make our coordinate system more convenient, we might want to make the positive-y axis point upward, with the origin in the bottom-left corner. Graphics objects come with several methods to help translate the origin or scale the axes, or even rotate them. Be careful: the order of operations matters. Fill in the blanks in this code to create the desired coordinate system, and insert them into paintComponent before drawing any lines, to see their effect:

g2d.translate(???, ???);
g2d.scale(???, ???);

Have the colored circles moved around at all? Why?

If you were to swap the order of the two statements, how would their arguments need to change?

Try resizing the window right now: your calibration image ought to resize to match the window. Why? (If it does not, check that your code matches everything above, and see which step you’ve omitted that enables this resizing to work.)

3.2 Calibration via transformations

So far, we’ve simply gotten the axes pointing the way we want, and the origin positioned where we want. But it may well be easier to scale the display so that instead of it being arbitrarily sized (based on the window size, via this.getBounds()), it’s always a constant “logical” size. For example, we might want to make our display always be “40 logical units tall and wide, with the origin at the center”, so that the points (20, 20), (10, 10), (0, 0), (-10, 10) and (-20, -20) are equally spaced along the top-right-to-bottom-left diagonal. (The number 40 is arbitrary here; pick whatever logical dimensions make the most sense for your application.) Not only will doing this make our display resizable, but it will also make it possible for us to translate back and forth between “physical” coordinates on the screen, and “logical” coordinates in our program.

Look at the methods transformLogicalToPhysical and transformPhysicalToLogical.1In Computer Graphics, we typically refer to “logical” coordinates as “world” coordinates, and “physical” coordinates as “screen” coordinates: the idea being that we’re trying to translate the world in our scene onto pixels on screen. But since that terminology is a little bit ambiguous about the virtual versus physical world, we choose to stick to less-standard but less-ambiguous phrasing. They each create AffineTransforms, which are the representations of how Swing implements scaling, translating and rotating a Graphics object. Let’s walk through transformLogicalToPhysical: the intent is to

(assuming we’re using a 40x40 logical display-size). Accordingly, it performs three steps, which seem like they’re perhaps backwards:

  1. It translates the origin by half the width and half the height. This moves the origin to the middle of the window

  2. It scales the width by (actual width / logical width), and similarly scales the height by (actual height / logical height) — this feels upside-down, but we’ll see that it actually works properly, below.

  3. Finally, it scales the y-axis by -1.

The reason this works is because operations on an AffineTransform act like a stack: mathematically, they are applied in reverse order. So if we take the top-left logical coordinate (-20, 20), our transformation will

  1. Flip the y-axis, to produce (-20, -20)

  2. Scale both values by (350 / 40), to produce (-175, -175)

  3. Translate both coordinates by 175, to yield (0, 0)

This last result is the physical coordinates of the top-left of our window, as desired.

Try the computations again with logical coordinates (0,0) and (20, -20), and convince yourself they produce the middle and the bottom-right of the window, respectively.

Rewrite the paintComponent to use the new transformation:

@Override
protected void paintComponent(Graphics g) {
  super.paintComponent(g);
  Graphics2D g2d = (Graphics2D) g.create();
  g2d.transform(transformLogicalToPhysical());
  ...
}

Try resizing the window now. Does your calibration image resize to match?

Take a look at the transformPhysicalToLogical method. Work through the same arithmetic as above, and convince yourself that it transforms physical coordinates of (0, 0) to logical coordinates of (-20, 20), and similarly confirm that the middle of the window and bottom-right corner of the window map to (0, 0) and (20, -20). This method isn’t needed yet, but will be used later on. Before reading further, braintstorm what use-cases you might have for wanting to convert from physical coordinates back to logical ones.

4 Aside: Cleaning up as you go

A Graphics2D object is very stateful: it keeps track of the current color, current brushstroke, current text size and font, and many other properties. Drawing code typically needs to set those properties in order to customize them before use...but in order not to have weird lingering side effects, it’s considered very good practice to reset those properties to their previous values when you’re finished. There are two ways do do this. The easiest is shown above: create() a copy of the original Graphics object, and manipulate only the copy. The other will be shown below: save the “old” value of the property at the start, change the property to the desired new value, and then change it back to the old value when finished. You almost certainly will make this sort of stateful mistake at some point, so get into the habit of cleaning up as you go, early!

5 Drawing Simon Circles

At the top of the JSimonPanel file, we’ve defined two Maps that specify where to place the centers of the four Simon buttons, as well as the colors they should be and the radius they should be drawn. (You might notice the Swing colors we selected do not match the names of the Simon colors! There is nothing magic about the names of the Simon colors, or about our color palette choices, so don’t be fooled into thinking that cs3500.simon.model.ColorGuess.Red really means red pixels on screen...)

Reuse the calibration helper method you defined to draw circles, to draw each of the Simon circles where they belong:

... in paintComponent ...
for (ColorGuess c : ColorGuess.values()) {
  drawCircle(g2d,
          CIRCLE_COLORS.get(c),
          CIRCLE_CENTERS.get(c).getX(),
          CIRCLE_CENTERS.get(c).getY());
}

5.1 Redrawing circles using shapes

While the drawOval method is convenient, it will become more useful to familiarize yourself with Shapes, which can be constructed in many different ways. Two of the simplest are Ellipse2D.Doubles and Rectangle2D.Doubles, which create ellipses or rectangles using four double values for the top, left, width, and height of the shape.

Revise your drawCircle method to create one of these shapes, and then draw it directly. Rather than try to construct your circle at an arbitrary center, create the circle centered on the origin, and then use the graphics transformation to translate the origin to your desired center:

AffineTransform oldTransform = g2d.getTransform();
g2d.translate(x, y);
Shape circle = new Ellipse2D.Double(
      -CIRCLE_RADIUS,     // left
      -CIRCLE_RADIUS,     // top
      2 * CIRCLE_RADIUS,  // width
      2 * CIRCLE_RADIUS); // height
g2d.draw(circle);
g2d.setTransform(oldTransform);

(You may also want to look at the documentation for Path2D.Double, which lets you create a path made out of line segments. Try to create a “diamond” of an arbitrary given size centered at the origin, starting off by using moveTo(size, 0), then three calls to lineTo(...) at the other corners, and finally closePath() to close the shape. You may want to generalize this from a diamond to an arbitrary polygon. Look at the math in the Turtles example for some ideas.)

6 Clicking on circles

The last few lines of the JSimonPanel constructor set up a mouse listener for the panel. The idea is to let the user press the mouse button, move the mouse around and highlight various buttons as the mouse passes over them, and then when the mouse button is released while the mouse is on top of one of the circles, notify the model that a color has been selected. The stub implementation of the mouse listener is at the bottom of the file.

A MouseInputAdapter is a convenient class that implements a bunch of mouse-related listener interfaces, such that every method by default does nothing. We can subclass this adapter and override only the methods we care about: in our case, it will be mousePressed, mouseDragged, and mouseReleased. (The mouseMoved method is very similar to mouseDragged – but mouseMoved is called when the mouse is not pressed, while mouseDragged is called when the mouse is pressed.)

Some of the wiring in this listener class is done for you. In particular, the mousePressed and mouseReleased events are complete: read through that code carefully to understand how it works. (It uses a slightly odd syntax: JSimonPanel.this.whatever is a notation that means “Since MouseEventsListener is a class that’s nested inside JSimonPanel, this would refer to the MouseEventsListener class, but we need to access fields from the outer class containing this one...so we’ll write that as JSimonPanel.this.”)

The interesting method for you to complete is the mouseDragged event. The argument to this method is a MouseEvent whose location is defined in physical coordinates. Since everything in our game is defined in logical coordinates, we need to convert between the two. Use the other transformation method, transformPhysicalToLogical, to transform that point into logical coordinates:

Point physicalP = e.getPoint();
Point2D logicalP = transformPhysicalToLogical().transform(physicalP, null);

What remains to be done is figure out whether the logical point is “close enough” to the centers of each circle to be considered “inside” the circle. If so, set the activeColorGuess field on the JSimonPanel accordingly; if not, clear the field’s value. Next, revise your drawCircle method to take in a boolean indicating whether the circle should be filled or not, and then use that boolean to use either g2d.fill or g2d.draw as appropriate. (You will lastly need to call JSimonPanel.this.repaint() in the mouseDragged method to ensure that your view is redrawn.)

Congratulations! You now have a working game of Simon!

7 Enhancements to Simon

Currently, the view handles errors by printing to System.err, but this isn’t particularly noticeable to users. Look up the documentation for dialogs, and open a simple message dialog with an error icon instead, alerting the user they guessed incorrectly.

Try changing the number of colors, and revising the Map of circle centers so that you spread them evenly around a circle, rather than hard-code their locations.

Try changing the circles to other Shapes, and using the contains method to determine if the mouse has clicked on the shape.

Try creating a new panel, that you add to your SimpleSimonView, that draws the entire sequence of expected colors (so that the player isn’t guessing blindly!).

Try adding a timer to that new panel, so that it blinks the colors in sequence.

Try moving that timer into the controller, and enhancing the SimonView interface so that the controller tells the view to blink each color in sequence.

Have fun!

8 Additional practice with views

Continue working on the Tic-Tac-Toe example from Lecture 16.

9 Questions to ponder/discuss

Above, we created the MouseInputListener as an inner class of our panel. What was useful about that choice? What was awkward? Try moving the class into its own file. What changes are needed to make the code work again?

The Maps of circle locations and colors feel somewhat awkward. To some extent the circles are our own custom-drawn buttons — what would change if we tried to redesign this GUI to use JButtons instead of custom-drawn images? Instead of JButtons, could you design a custom SimonButtonPanel that extends JPanel and handles its own drawing and click-tracking on its own? If so, how do all the transforms and graphics commands change accordingly? (Some will get much simpler; some other code will get slightly trickier in exchange.) In general, custom GUIs have to make a tradeoff between truly customized graphics and reusable controls; this Simon game is a tiny example of such a tradeoff.

1In Computer Graphics, we typically refer to “logical” coordinates as “world” coordinates, and “physical” coordinates as “screen” coordinates: the idea being that we’re trying to translate the world in our scene onto pixels on screen. But since that terminology is a little bit ambiguous about the virtual versus physical world, we choose to stick to less-standard but less-ambiguous phrasing.