Lab 8: Working with GUIs
Starter files: code.zip
Objectives
The objectives of this lab are:
Construct a working GUI using Java Swing.
Incorporate the GUI in an existing model-view-controller design.
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 AffineTransform
s, which are the representations of how Swing
implements scaling, translating and rotating a Graphics object. Let’s walk
through transformLogicalToPhysical
: the intent is to
Transform (0, 0) to the middle of our window
Transform (20, 20) to the top-right of our window
Transform (-20, -20) to the bottom-left of our window
(assuming we’re using a 40x40 logical display-size). Accordingly, it performs three steps, which seem like they’re perhaps backwards:
It translates the origin by half the width and half the height. This moves the origin to the middle of the window
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. 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
Flip the y-axis, to produce (-20, -20)
Scale both values by (350 / 40), to produce (-175, -175)
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 Map
s 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
Shape
s,
which can be constructed in many different ways. Two of the simplest are
Ellipse2D.Double
s
and
Rectangle2D.Double
s,
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 Shape
s, 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 14.
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 Map
s of circle locations and colors feel somewhat awkward. To some
extent the circles are our own custom-drawn buttons —JButton
s instead of custom-drawn
images? Instead of JButton
s, 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.