WhiteOutGame: A Fun Puzzle Game

By Jim Holmlund, June 18, 2009

This sample code shows how to build a version of the Lights Out game with the JavaFX technology. It shows manual layout, multiple scenes, buttons, highlighting, and transitional effects.

Understanding the Code

The code is designed so that it adapts to the window size and orientation(i.e. landscape or portrait).

The Env class uses the window size to compute the sizes and positions of the various GUI gadgets, as shown in Figure 1.

Source Code

    public def bigFont = 
        if (screenWidth < 320)
            Font {size: 30}
        else if (screenWidth < 450)
            Font {size: 60}
        else 
            Font {size: 70}

    // Compute height / width of the blue buttons based on the size of "Reset"
    def resetSize = Text {
        content: "Reset"
        font: smallFont
    };

    // Leave a bit of space around "Reset"
    public def blueButtonHeight = resetSize.layoutBounds.height + 8;
    public def blueButtonWidth = resetSize.layoutBounds.width + 10;
  

Figure 1: Computing Sizes of GUI Gadgets

Also, notice the computation of the size of the game buttons (the buttons in the 5 x 5 array) in Env.fx. This computation computes the desired size of a button based on the width/height of the window and its orientation.

The classes that create a layout format use the values from Env. For example, Figure 2 shows the BlueButton class setting the size of a BlueButton.

Source Code
    public class BlueButton extends Group {
        public-init var env: Env;
        public-init var text: String;
        // We will add a 2 pixel shadow.
        public-init var width = env.blueButtonWidth - 2;
        public-init var height = env.blueButtonHeight - 2;
 

Figure 2: BlueButton Class Setting the Size of a BlueButton

You can experiment with changing the Env settings to see how that changes the layout of the GUI gadgets. Be sure to note the setting of translateX and translateY, and the use of layoutBounds in creating the layout. For example, see the following code in Canvas.fx:
Source Code
 
        // the Next Level items in the shaded rectangle.
        finishedText.translateX = (shadedRect.width - finishedText.layoutBounds.width) / 2;
        finishedText.translateY = shadedRect.translateY + shadedRect.layoutBounds.minY + 
                                   .3 * nextButton.height;

        levelText.translateX = finishedText.translateX + finishedText.layoutBounds.minX + 5;
        levelText.translateY = finishedText.translateY + finishedText.layoutBounds.maxY + 5;
 

Figure 3: Setting the Coordinates of "Finished" and "Level"

The code in Figure 3 sets the coordinates of the words "Finished" and "Level" that appears just below when all the white squares have been eliminated. Also in Canvas.fx are uses of the new-for-JavaFX 1.2 class javafx.scene.layout.Tile. This class is used to layout the items:
        Reset Button
        "Moves"
        move-count
        Quit Button
These items run vertically down the right side of the screen in landscape mode and horizontally across the bottom of the screen in portrait mode. The Tile class allows the layout to adjusted for these two modes, e.g:
Source Code
        Tile {
            translateX:  if (env.isPortrait) 0 else env.gameSize
            translateY:  if (env.isPortrait) env.gameSize + 5  else  env.gameButtonGap
            columns: if (env.isPortrait) 3 else 1
            rows:  if (env.isPortrait) 1 else 3

            // In portrait mode, we allocate 1/3 the width for each button and the
            // "move count".
            tileWidth: if (env.isPortrait) env.gameSize / 3 else env.screenWidth - env.gameSize
            tileHeight: if (env.isPortrait) env.blueButtonHeight else env.gameSize / 3
            content: [
                BlueButton {
                    env: env
                    text: "Reset"
                    onMouseReleased: function(e:MouseEvent) {
                        model.reset();
                    }
                }

Figure 4: Setting up a Tile



The buttons in the game have drop shadows and borders that fade in or fade out as the mouse enters or leaves the button. These effects are achieved by creating two rectangles, one offset down and right by two pixels, and with a dark color. This is the drop shadow. The other rectangle is the button proper. It has a "stroke," the line that draws the rectangle. Note that the opacity of this line is "bound" to a variable named strokeAlpha that changes as the mouse enters or leaves. See the following code in BlueButton.fx. (This code could be simplified by using the new-for-JavaFX 1.2 class javafx.scene.control.Button).

Source Code
    content = [
        // This creates a shadow on the bottom and right of the button
        Rectangle {
            fill: Color.rgb(0x30, 0x30, 0x30)
            translateX: 2
            translateY: 2
            width: width
            height: height
            arcWidth: 6
            arcHeight: 6
        }
        Rectangle {
            fill: env.blueGrad
            width: width
            height: height
            arcWidth: 6
            arcHeight: 6
            stroke: bind Color{red: 1, green: 1, blue: 1, opacity: strokeAlpha}
             onMouseEntered: function(e) {
                fade.rate = 10.0;
                fade.play();
            }
             onMouseExited: function(e) {
                fade.rate = -10.0;
                fade.play();
            }
        }

Figure 5: Creating Drop Shadows and Borders in Buttons

Note the fade.play() calls in Figure 5. 'fade' is a Timeline variable:

Source Code
    def fade = Timeline {
        keyFrames: [
            at(0s)   { strokeAlpha => 0.3 tween Interpolator.LINEAR }
            at(0.5s) { strokeAlpha => 1.0 tween Interpolator.LINEAR }
        ]
    };

When this timeline is activated by the fade.play() statement, the value of the strokeAlpha variable is linearly varied from .3 to 1.0 over the course of .5 seconds. This variation changes the rectangle stroke to opaque. Similarly, when the mouse exits, the timeline is played in reverse and the rectangle stroke becomes nearly transparent.

The Splash class shows the splash screen and contains an example of nested Timelines. When the splash screen is displayed, the following occurs:

  • The splash screen fades in from transparent to opaque so that it becomes visible.
  • The word "White" slides onto the screen from the left and the word "Out" slides onto the screen from the right.
  • The Start button springs into position.
  • This effect is achieved by the Timeline in Figure 6.

    Source Code
     
        textWhite.x = -(textWhite.layoutBounds.width + 1);
        textWhite.visible = true;
    
        textOut.x = env.screenWidth + 1;
        textOut.visible = true;
        opacity = 0.0;
    
        def opa = Timeline {
            keyFrames: [
               at (.5s) {opacity => 1.0 tween Interpolator.LINEAR}
               KeyFrame {
                   time: .4s
                   timelines: Timeline {
                       keyFrames: [
                           at (.4s) {textWhite.x => finalWhiteX tween Interpolator.LINEAR}
                           at (.4s) {textOut.x => finalOutX tween Interpolator.LINEAR}
                       ]
                   }
               }
               KeyFrame {
                   time: 1s
                   timelines: Timeline {
                       rate: 1.0
                       keyFrames: [
                          at (.5s) {buttonY => finalButtonY tween spring}
                       ]
                   }
               }
            ]
        }
        opa.play();
    

    Figure 6: Creating a Timeline

    So we see that:
  • From 0 to .5 seconds, the opacity moves linearly from its original value of 0.0 to 1.0, making the contents visible.
  • Before .4 seconds, the words "White" and "Out" are off the screen to the left and right. From .4 to .8 seconds, these words slide to their final position.
  • Before 1 second, the Start button is below the screen. From 1 to 1.5 seconds, this button moves to its final position by using a CustomInterpolator that does a simulation of a spring action. The variable buttonY is bound to the translateY field of the startButton, so as buttonY changes, the startButton moves.
  • Customizing the Code

    The main customizations you can make to this code are:

  • You can change the window size in Main.fx. The current layout works fine down to about 200 x 200. Below that size, you might run into limitations.
  • You can change the layout constants and computations in Env.fx. For example, change nButtons to 30.

  • In addition, you could do some experimenting:
  • The game buttons are defined to be rectangles in Model.fx. Try changing them to be circles.
  • The buttons are a blue gradient defined in Env.fx. Try changing the buttons to some other color or a different gradient.
  • Notice that the Stage is not resizable. If you make it resizable, you can drag it larger or smaller but the contents do not expand or contract. Could you bind key variables to the size and width of the Stage so that when it is resized, the layout is redone? An easier alternative would be to add two buttons that would just create a new larger or smaller window size, remove the old window, and start the game in the new window at the current move position.
  • Try modifying the game by making the array of buttons act like a cylinder, both horizontally and vertically. For example, the "neighbors" of button[0,3] are [0,2] and [1,3]. This change would add [4,3] and [0,0] as neighbors.
  • Add a Solve It button that would cause the game to simulate a series of clicks that would turn off all the white buttons.
  • Note that the initial configuration of the buttons is achieved by simulating a few clicks. This means that the puzzle can be solved by repeating those clicks. Instead of simulating a few clicks, try just randomly setting a few buttons to be white. This change can result in unsolvable configurations.
  • Note that the clicks simulated to set up the initial configuration are restricted to a 4x4 square. If you increase the number of buttons, say to a 10x10 square, this initial configuration is a bit confining. Try increasing the 4x4 restriction to the full 10x10. Note that now the simulated clicks tend to be far apart so that they don't interact, leaving a trivially solvable puzzle. Find a new algorithm for setting the initial configuration that solves this problem.