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 ...

Friday, April 29, 2016

Moving a Hedgehog Around a Game Board in JavaFX

This demonstration shows how to start a game application that moves a piece around a board.  The board is a grid of JavaFX Rectangles that provides a visual cue when the mouse hovers over them.  A game piece -- implemented as an ImageView -- is able to be dragged around the board.  When the drag operation is ended by a mouse release or by leaving the board, the game piece snaps to the square underneath.

All of the code in this example is available on GitHub.


This video shows the running application.  Hovering over a square highlights it.  Selecting the game piece makes it transparent. When I drag, the game piece moves with the mouse and the underlying square highlights.  The game piece snaps to the square when the drag operation is finished.  A mouse release or movement off the board terminates the dragging.


The application's UI is built with SceneBuilder.  This video walks through the FXML file used in the application and shows some of the productivity features in the tool.


The application uses two Java files: WoodlandMain.java and WoodlandController.java.  The class WoodlandMain is a trivial Application extension that loads the FXML, creates a Scene, applies a stylesheet, and creates the Stage.  WoodlandController is the Controller class referenced in the FXML.

Program Structure


For business applications, I add event handlers in the FXML.  That is, I create an @FXML-annotated method in the Java code and assign them directly to Nodes in SceneBuilder.  The business application usually needs only standard Node behavior.  For games, I usually drive the event processing through toplevel handlers registered with a toplevel container node (a Pane in this example).  This consolidates the updates of the various nodes (ex, dragging the game piece and highlighting the square underneath).

So, my @FXML initialize() method makes the following calls to the board Pane component.

  • setOnMousePressed
  • setOnMouseReleased
  • setOnMouseDragged
  • setOnMouseMoved

These handlers manipulate the squares and the game piece that make up the application rather than relying on handlers registered on the Nodes themselves.  Because the squares and the game piece are siblings, the capture or bubble event model doesn't propagate the events.

Hovering


Moving the mouse over a square outside of the drag operation will highlight it.  The highlighting is implemented with a JavaFX DropShadow Effect.  The inBoard() method determines if the mouse is inside of the board.  If it's not in the board, I clear any selections as a precaution.  Otherwise, I use the pickSquare() method to determine the selected square and apply the DropShadow.  The program will also keep track of the last hovered square using a member variable (lastHoverSquare) to un-highlight squares.

  
board.setOnMouseMoved((evt) -> {

 if( !inBoard( evt.getSceneX(), evt.getSceneY() ) ) {
  clearSelection();
 }
   
 Optional<Rectangle> hoverSquare = pickSquare( evt.getSceneX(), evt.getSceneY() );

 if( lastHoverSquare.isPresent() ) {
  lastHoverSquare.get().setEffect( null );
 }
   
 if( hoverSquare.isPresent() ) {
  hoverSquare.get().setEffect(dropShadow);
  lastHoverSquare = hoverSquare;
 }   
}); 

Selecting


Pressing the mouse within the game piece turns on the transparency.  A few variables (selectInImageX, topleftSelectInImageX, etc) are set recording where the selection occurs within the ImageView.  This is for applying offsets later in the relocate() and boundary-checking code.

board.setOnMousePressed((evt) -> {   
 if( selectGamePiece(evt.getSceneX(), evt.getSceneY()) ) {

  Point2D selectInImageScenePt = new Point2D( evt.getSceneX(), evt.getSceneY() );
  Point2D selectInImagePt = gamePiece.sceneToLocal( selectInImageScenePt );
    
  selectInImageX = gpMidpointX - selectInImagePt.getX();
  selectInImageY = gpMidpointY - selectInImagePt.getY();
    
  topLeftSelectInImageX = selectInImagePt.getX();
  topLeftSelectInImageY = selectInImagePt.getY();
    
  gamePiece.setOpacity( 0.4d );
  gpSelected = true;    
 }
});

The application works in several coordinate systems: Scene, Parent, and local.  The program standardizes on the Scene coordinates when doing calculations.  That's to make it easier to translate among nested components.  Parent coordinates are needed to apply the relocate() method.  Lastly, local coordinates are easy to extract from the Nodes themselves.  All through can be readily convered using Node methods like "sceneToLocal()" and "localToParent()".

You should use the relocate() method rather than directly setting the layoutX and layoutY properties.

Releasing


The post-select drag operation will be presented in the next section.  After the Node is selected and dragged, a mouse release or movement off the board ends the operation and centers the game piece on the square underneath.

If there isn't a piece selected -- say we're dragging in the tan border not associated with a square -- the method will defensively clear any selection.

board.setOnMouseReleased((evt) -> {   
   
 if( gpSelected ) {    
  Optional>Rectangle< onSquare = pickSquare( evt.getSceneX(), evt.getSceneY() );
  snap(onSquare);
 }
   
 clearSelection();  // unconditionally clear in case missed event
});

Dragging


Finally, the drag operation will relocate() the gamePiece and continue the highlighting behavior.   The onMouseDragged method is also responsible for terminating the drag by creating an artificial MOUSE_RELEASED event.  This allows me to reuse the onMouseReleasedCode and to continue with the remainder of the drag operation.

board.setOnMouseDragged( (evt) -> {
   
 if( !inBoard( evt.getSceneX(), evt.getSceneY() ) ) {
    
  //
  // End the drag operation if left the board
  //
    
  MouseEvent releaseEvent = new MouseEvent(
    MouseEvent.MOUSE_RELEASED,
    evt.getSceneX(), 
    evt.getSceneY(),
    evt.getScreenX(), 
    evt.getScreenY(),
    evt.getButton(),
    evt.getClickCount(),
    evt.isShiftDown(),
    evt.isControlDown(),
    evt.isAltDown(),
    evt.isMetaDown(),
    evt.isPrimaryButtonDown(),
    evt.isMiddleButtonDown(),
    evt.isSecondaryButtonDown(),
    true,
    evt.isPopupTrigger(),
    evt.isStillSincePress(),
    evt.getPickResult()      
    );
    
  board.fireEvent( releaseEvent );
 }
   
 if( gpSelected ) {    
  Point2D sceneEvtPt = new Point2D(evt.getSceneX(), evt.getSceneY());
  Point2D parentEvtPt = board.sceneToLocal(sceneEvtPt);
  Insets padding = boardContainer.getPadding();    
  gamePiece.relocate( parentEvtPt.getX()-padding.getLeft()+selectInImageX, parentEvtPt.getY()-padding.getTop()+selectInImageY);     
 }
   
 Optional<Rectangle> hoverSquare = pickSquare( evt.getSceneX(), evt.getSceneY() );

 if( lastHoverSquare.isPresent() ) {
  lastHoverSquare.get().setEffect( null );
 }
   
 if( hoverSquare.isPresent() ) {
  hoverSquare.get().setEffect(dropShadow);
  lastHoverSquare = hoverSquare;
 }
});

All of the code presented at this point is in the @FXML initialize() method.

Utility Methods


This is the code for several methods referenced in the above handlers.  For more detail, you should take a look at the code on GitHub (see top of post) to review the entire class including the member variables.  There are some helpful comments associated with the fields.

private void snap(Optional<Rectangle> onSquare) {
 if( onSquare.isPresent() ) {
   
  Point2D onSquarePt = new Point2D(onSquare.get().getLayoutX(), onSquare.get().getLayoutY());
 
  final Timeline timeline = new Timeline();
  timeline.setCycleCount(1);
  timeline.setAutoReverse(false);
  timeline.getKeyFrames()
  .add(new KeyFrame(Duration.millis(200), 
  new KeyValue(gamePiece.layoutXProperty(), onSquarePt.getX()+gpCenteringX),
  new KeyValue(gamePiece.layoutYProperty(), onSquarePt.getY()+gpCenteringY))
  );
  timeline.play();
 }
}

private void clearSelection() {
 selectInImageX = 0.0d;
 selectInImageY = 0.0d;
 gamePiece.setOpacity(1.0d);
 gpSelected = false;
}

private boolean selectGamePiece(double sceneX, double sceneY) { 
 return isPicked( gamePiece, sceneX, sceneY );
}
 
private Optional<Rectangle> pickSquare(double sceneX, double sceneY) {
  
 Optional<Rectangle> sq =squares
  .stream()
  .filter((square) -> isPicked(square, sceneX, sceneY))
  .findFirst();
  
 return sq;
}
 
private boolean inBoard(double sceneX, double sceneY) {
  
 boolean retval = (
 isPicked( board, sceneX-topLeftSelectInImageX, sceneY-topLeftSelectInImageY ) &&
 isPicked( board, sceneX+(gpWidth-topLeftSelectInImageX), sceneY+(gpHeight-topLeftSelectInImageY))
 );
  
 return retval;
}
 
private boolean isPicked(Node n, double sceneX, double sceneY) {
 Bounds bounds = n.getLayoutBounds();
 Bounds boundsScene = n.localToScene( bounds );
 if( (sceneX >= boundsScene.getMinX()) && (sceneY >= boundsScene.getMinY()) &&
   (sceneX <= boundsScene.getMaxX()) && ( sceneY <= boundsScene.getMaxY() ) ) {
  return true;
 }
 return false;
}


No comments:

Post a Comment