Featured Post

Applying Email Validation to a JavaFX TextField Using Binding

This example uses the same controller as in a previous post but adds a use case to support email validation.  A Commons Validator object is ...

Saturday, February 13, 2016

Moving a Game Piece on a JavaFX Checkerboard

This post features a JavaFX program that moves a game piece around a checkerboard.  Hovering over a background square highlights it with a JavaFX InnerShadow Effect.  Selecting the piece and dragging turns it transparent and marks the original square with a fill-less circle.  All of the shapes are JavaFX Nodes: Rectangle, Circle, and Pane.  The graphic design is implemented with Scene Builder.


All of the source code for this blog post is available on Bitbucket.

This video shows the game piece being dragged around the board.


If you'd like to run the app (it's sandboxed), visit this web page.  You can skip the shortcut creation step if you want.

Graphic Design


The graphic design for the program is done in Scene Builder.  Scene Builder creates JavaFX vector objects -- Rectangle and Circle in this case -- in a WYSIWYG editor.  This means that I don't have to run a Java program so see the arrangement, spacing, and colors of the UI.  Moreover, with Control-P, I can quickly test the resizing of the whole layout.

This screenshot shows the UI with the stylesheet applied.  A Pane is wrapped in a VBox.  The Pane allows absolute positioning including overlapping as needed for the game piece display over the background squares.  In the Pane, there is a pair of circles: the main game piece "circle" and the temporary placeholder "origPositionCircle".  originPositionCircle is underneath circle, but while I was styling, I had moved it to the next square over.

Scene Builder Showing Board and Game Piece

Each row in the game board is another Pane.  This is to help with the background highlighting algorithm.  The row Panes (not the toplevel container).  So that the Hierachy is easier to work with, the row Panes are grouped in a Group.  The Group has no role in the layout or event processing.  This screenshot shows a section of the Group expanded.

Hierachy with rectangleGroup Expanded

Styling


A single stylesheet is used to get the checkerboard effect.  Here are the contents of rnb.css.

.board_square {
 -fx-fill: #1e90ff;
}

.board_square_lt {
 -fx-fill: #cccccc;
}

Each Rectangle is either assigned with the style class board_square or board_square_alt.

Event Processing


The movement of the game piece is handled by EventFilters registered on the piece itself, the object "circle".  This includes the manipulation of "origPositionCircle".

For most business applications, I register EventHandlers in Scene Builder.  However, in this game-oriented demo, I'm doing these in code.  For the business application, the event processing is usually obvious (Button press, MenuItem selection).  For this demo, I'd like to see all of the event registrations in a list because multiple Nodes may be involved with the same Event.  This code appears in the @FXML initialize() method of the controller class.

circle.addEventFilter(MouseEvent.MOUSE_PRESSED, this::startMovingPiece);
circle.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::movePiece);
circle.addEventFilter(MouseEvent.MOUSE_RELEASED, this::finishMovingPiece);

The above code is using Java 8 method references to register functions.  This is a code style preference that I feel helps its readability as compared with Lambdas and certainly anonymous inner classes. This is the startMovingPiece() method.

public void startMovingPiece(MouseEvent evt) {
 circle.setOpacity(0.4d);
 offset = new Point2D(evt.getX(), evt.getY());

 origPositionCircle.setOpacity(1.0d);
 origPositionCircle.setLayoutX( circle.getLayoutX() );
 origPositionCircle.setLayoutY( circle.getLayoutY() );
 
 movingPiece = true;
}

startMovingPiece() adjusts the opacity, records the position of the mouse within the circle, displays the origPositionCircle, and sets a flag.  This is the code executed with the game piece starts moving.

public void movePiece(MouseEvent evt) {
  
 Point2D mousePoint = new Point2D(evt.getX(), evt.getY());  
 Point2D mousePoint_s = new Point2D(evt.getSceneX(), evt.getSceneY());
  
 if( !inBoard(mousePoint_s) ) {
  return;  // don't relocate() b/c will resize Pane
 }
  
 Point2D mousePoint_p = circle.localToParent(mousePoint);  
 circle.relocate(mousePoint_p.getX()-offset.getX(), mousePoint_p.getY()-offset.getY());
}

movePiece() uses the relocate() function to move the circle in its parent.  It's using an offset to accommodate the different positioning based on where in the circle it was selected.  mousePoint is in local coordinates and needs to be converted to Scene coordinates for the inBoard() check and into Parent coordinates for the relocate() method.

The inBoard() method checks the bounds of the toplevel container Pane.

private boolean inBoard(Point2D pt) { 
 Point2D panePt = boardPane.sceneToLocal(pt);
 return panePt.getX()-offset.getX() >= 0.0d 
   && panePt.getY()-offset.getY() >= 0.0d 
   && panePt.getX() <= boardPane.getWidth() 
   && panePt.getY() <= boardPane.getHeight();
}

Finally, when the mouse is released, the game piece will snap to the Rectangle its dropped in.  This involves looking through the row Panes and Rectangles for a match.  I'm using an Animation (Timeline) to smooth out the final snapping movement and to make the origPositionCircle and circle opacities revert to their rest values.

public void finishMovingPiece(MouseEvent evt) {
  
 offset = new Point2D(0.0d, 0.0d);
  
 Point2D mousePoint = new Point2D(evt.getX(), evt.getY());
 Point2D mousePointScene = circle.localToScene(mousePoint);
  
 Rectangle r = pickRectangle( mousePointScene.getX(), mousePointScene.getY() );

 final Timeline timeline = new Timeline();
 timeline.setCycleCount(1);
 timeline.setAutoReverse(false);

 if( r != null ) {
   
  Point2D rectScene =r.localToScene(r.getX(), r.getY());   
  Point2D parent = boardPane.sceneToLocal(rectScene.getX(), rectScene.getY());
   
  timeline.getKeyFrames().add(
   new KeyFrame(Duration.millis(100), 
    new KeyValue(circle.layoutXProperty(), parent.getX()),
    new KeyValue(circle.layoutYProperty(), parent.getY()),
    new KeyValue(circle.opacityProperty(), 1.0d),
    new KeyValue(origPositionCircle.opacityProperty(), 0.0d)
    )
  );
 } else {

  timeline.getKeyFrames().add(
   new KeyFrame(Duration.millis(100), 
    new KeyValue(circle.opacityProperty(), 1.0d),
    new KeyValue(origPositionCircle.opacityProperty(), 0.0d)
   )
  );
 }
  
 timeline.play();
   
 movingPiece = false;
}

Most of the method is basic Timeline setup.  The pickRectangle() method will scan through the row Panes.  If a match is found, it will scan through the Pane's Rectangles looking for a match.  If there is a match, the game piece is moved via an Animation.

This is the code for the pickRectangle() method.  It's using the contains() method of the Rectangle to test whether or not the mouse is in the Rectangle.

private Rectangle pickRectangle(MouseEvent evt) {
 return pickRectangle(evt.getSceneX(), evt.getSceneY());
}
 
private Rectangle pickRectangle(double sceneX, double sceneY) {
 Rectangle pickedRectangle = null;
 for( Pane row : panes ) {   
  Point2D mousePoint = new Point2D(sceneX, sceneY);
  Point2D mpLocal = row.sceneToLocal(mousePoint);
  if( row.contains(mpLocal) ) {
   for( Node cell : row.getChildrenUnmodifiable() ) {
    Point2D mpLocalCell = cell.sceneToLocal(mousePoint);

    if( cell.contains(mpLocalCell) ) {
     pickedRectangle = (Rectangle)cell;
     break;
    }
   }    
   break;
  }
 }
 return pickedRectangle;
}

Background Highlight

The previous code handles the movement of the game piece and the snapping.  The program also has to handle the background square highlighting.  To do this, I register a handler on the toplevel Pane container and delegate this to Rectangle under the mouse.  I could put the handler on the Rectangle, but this won't work if I'm also dragging the Circle.  That's because circle and the Rectangles are siblings and if the front Circle gets a drag event, it doesn't capture or bubble to the sibling underneath,

This code appears in the @FXML initialize() method alongside the registrations of the circle EventFilters.

boardPane.addEventFilter(MouseEvent.MOUSE_EXITED, this::leaveBoard);
boardPane.addEventFilter(MouseEvent.MOUSE_RELEASED, this::checkReleaseOutOfBoard);
boardPane.addEventFilter(MouseEvent.MOUSE_MOVED, this::highlightSquare);
boardPane.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::highlightSquare);

highlightSquare() is registered for both a move and drag operation.  The method does not consume the event, so the EventFilter will continue the propagation to child components, specifically circle.

public void highlightSquare(MouseEvent evt) {

 Rectangle r = pickRectangle(evt); 
  
 if( r == null ) {

  if( currRect != null ) {
   // deselect previous
   currRect.setEffect( null );
  }

  currRect = null;
  return;  // might be out of area but w/i scene
 }
  
 if( r != currRect ) {
   
  if( currRect != null ) {
   // deselect previous
   currRect.setEffect( null );
  }
   
  currRect = r;
  if( currRect != null ) {  // new selection
   currRect.setEffect( shadow );
  }
 }
}

highlightSquare() uses the pickRectangle() function to apply or remove a JavaFX effect.  "shadow" is a class variable of type InnerShadow.

Boundary

The remaining two EventFilter registrations are for when the mouse hits the edge of the board.  leaveBoard() uses the origPositionCircle to re-relocate() the game piece.

public void leaveBoard(MouseEvent evt) {
 if( movingPiece ) {
   
  final Timeline timeline = new Timeline();

  offset = new Point2D(0.0d, 0.0d);
  movingPiece = false;

  timeline.getKeyFrames().add(
  new KeyFrame(Duration.millis(200), 
   new KeyValue(circle.layoutXProperty(), origPositionCircle.getLayoutX()),
   new KeyValue(circle.layoutYProperty(), origPositionCircle.getLayoutY()),
   new KeyValue(circle.opacityProperty(), 1.0d),
   new KeyValue(origPositionCircle.opacityProperty(), 0.0d)
       )
  );
  timeline.play();   
 }
}

If you release the mouse outside of the board, you won't get a mouse exited event.  To handle this, I register a second MOUSE_RELEASED handler.  This handler will consume the event, preventing it from reaching the circle game piece.  The handler cancels the move operation and restores the game piece to the original position.

public void checkReleaseOutOfBoard(MouseEvent evt) {
 Point2D mousePoint_s = new Point2D(evt.getSceneX(), evt.getSceneY());
 if( !inBoard(mousePoint_s) ) {
  leaveBoard(evt);
  evt.consume();
 }
}

chckReleaseOutOfBoard() does nothing if the release is in the board, leaving the event for the circle handler.

Graphic design and layout for games is great in Scene Builder.  The preview is a great way to see how your application will respond to resizing and how well the styles mesh.  Unlike a business app, I am preferring to put all event registration in one central location.  That's because the algorithms that I'm using in this demo are less obvious that the simple "onActions" using an a business app.

I've tested this on Windows, Mac, and Linux and found the performance adequate.  This particular example works especially well with touch as you don't have potential repeated mouse movements to get the piece from one edge to the other.


2 comments:

  1. Author Carl, Bekwam Games, thanks a million for this Checkerboard. It’s the best tutorial I’ve stumbled upon.

    I’m new to Java. JavaFX and especially Scene Builder got my immediate interest. But to find proper Scene Builder examples is not so easy. Your’s is excellent. I’ve subscribed to Bekwam Blog hoping to see more of your outstanding work.

    Thank you very much for the code in Bitbucket. You might not like this, but you numbered the squares in row 1 incorrectly. Not that I mind, I learnt a lot while trying to find the bug.

    Have a nice day. Dirk.

    ReplyDelete
    Replies
    1. Thanks for the kind words. If you get into Kotlin at some point, be sure to join the TornadoFX Slack Channel. There's always a chat going on about topics like this involving JavaFX.

      Delete