All of the code in this post is available on GitHub here.
The Game
The object of the game is to correctly order the Sprites which are labeled A through G. See the following video for a demonstration.
You can play the game for yourself at https://www.bekwam.net/sortme/sortme.jnlp.
Graphic Design
To build the game, I used JavaFX and the Gluon-distributed Scene Builder app. The Sprite technique is implemented in Scene Builder as a composite Pane which holds several views for the Sprite, one for each state a Sprite might become.In the following screenshot from Scene Builder, the top row of components all belong to a single template Sprite.
Scene Builder Breaking Out a Sprite in the Top Row |
- The normal view of the Sprite at rest,
- The highlighted view when the Sprite gets the focus,
- The transparent view for when the Sprite is moving, and
- The temporary error view for when the Sprite is out of order.
In the game, a Sprite is a Pane and each view is a Group: normal, highlighted, transparent, temporary. Each Group is a pairing of a Rectangle and a Text. The Rectangles vary by opacity, effect, and border.
Controller Code
All of the code in this post is available on GitHub here. These sections omit boilerplate code like imports and the trivial Application that launches the app.The main code is in the Controller (SortMeController.java) and in a special purpose "Sprite" class that encapsulates the FX objects.
The Controller code starts with @FXML annotations that let me access what I'm calling a template Sprite -- what I defined in Scene Builder -- and build other Sprites from the template. In this header, there is also a Comparator to determine the stacking order of the Sprites, a list of Sprites kept convenience, and a list of the expectedResults which assists the terminal check() operation and sets up the game.
@FXML Pane background; @FXML Pane container; @FXML Group normal; @FXML Group highlight; @FXML Group drag; @FXML Group error; private SpriteXComparator spriteYComparator = new SpriteXComparator(); private final List<Sprite> sprites = new ArrayList<>(); private final List<String> expectedResults = Arrays.asList( new String[]{"A", "B", "C", "D", "E", "F", "G"} );
"background" is the container on which the game is played. The Sprites are arranged on this. "container" refers to the root FX object in a Sprite. The four Groups are the different views all belonging to the root.
This is the @FXML initialize() method. I'm deferring the presentation of the Sprite class, but it uses the FXML-bound fields in a constructor. Then, in a loop, that object is used to create other objects (templateSprite.create(s)).
Notice that I disconnect the initial object added to background. Everything added to background is added from the streams loop.
@FXML public void initialize() { Sprite templateSprite = new Sprite( container, normal, highlight, drag, error, "A" ); background.getChildren().remove(container); expectedResults .stream() .forEach((s) -> { Sprite sp = templateSprite.create( s ); background.getChildren().add( sp.container ); sprites.add( sp ); }); shuffle(); }
Sprite Class Code
I'll present the Sprite class code in segments. The full code is available on GitHub.Here are the fields of the Sprite class. They are the familiar Pane and Group objects plus a textProperty. There are also two fields that assist with the movement of the Sprite.
final Pane container; final Group normal; final Group highlight; final Group drag; final Group error; final StringProperty textProperty; private double mouseInSpriteX = -1.0d; private double mouseInSpriteY = -1.0d;
The final fields are set in a constructor. The constructor also registers a MouseEvent handler and binds the Text object's textProperty to the class variable (also called "textProperty"). A combination of setVisible and setLayout results in the "normal view" bing shown first.
public Sprite(Pane container, Group normal, Group highlight, Group drag, Group error, String text) { this.container = container; this.normal = normal; this.highlight = highlight; this.drag = drag; this.error = error; this.container.addEventHandler(MouseEvent.ANY, mouseHandler); this.normal.setVisible(true); this.normal.setLayoutX( 0.0d ); this.highlight.setVisible(false); this.highlight.setLayoutX( 0.0d ); this.drag.setVisible(false); this.drag.setLayoutX( 0.0d ); this.error.setVisible(false); this.error.setLayoutX( 0.0d ); textProperty = new SimpleStringProperty( text ); ((Text)this.normal.getChildren().get(1)).textProperty().bind( textProperty ); ((Text)this.highlight.getChildren().get(1)).textProperty().bind( textProperty ); ((Text)this.drag.getChildren().get(1)).textProperty().bind( textProperty ); ((Text)this.error.getChildren().get(1)).textProperty().bind( textProperty ); }
I have a public create() method that's called to duplicate a Sprite from another Sprite (what I've called the template Sprite). A better implementation would involve recursion and possibly reflection to do the deep copy, however this code-heavy version works and is clearer in its intent.
public Sprite create(String text) { Pane copyContainer = new Pane(); copyContainer.getStyleClass().addAll( container.getStyleClass() ); Group copyNormal = copyGroup( normal ); Group copyHighlight = copyGroup( highlight ); Group copyDrag = copyGroup( drag ); Group copyError = copyGroup( error ); copyContainer.getChildren().addAll( copyNormal, copyHighlight, copyDrag, copyError ); Sprite copySprite = new Sprite( copyContainer, copyNormal, copyHighlight, copyDrag, copyError, text ); return copySprite; }
The copyGroup() method digs into to the Rectangle and Text and creates objects based on them.
private Group copyGroup(Group sourceGroup) { Group copyGroup = new Group(); copyGroup.getStyleClass().addAll( sourceGroup.getStyleClass() ); Rectangle sourceBackground = (Rectangle)sourceGroup.getChildren().get(0); Rectangle copyBackground = new Rectangle(); copyBackground.setWidth( sourceBackground.getWidth() ); copyBackground.setHeight( sourceBackground.getHeight() ); copyBackground.getStyleClass().addAll( sourceBackground.getStyleClass() ); copyBackground.setEffect( sourceBackground.getEffect() ); copyBackground.setOpacity( sourceBackground.getOpacity() ); Text sourceText = (Text)sourceGroup.getChildren().get(1); Text copyText = new Text(); copyText.getStyleClass().addAll( sourceText.getStyleClass() ); copyText.setLayoutX( sourceText.getLayoutX() ); copyText.setLayoutY( sourceText.getLayoutY() ); copyText.setText( sourceText.getText() ); copyText.setOpacity( sourceBackground.getOpacity() ); copyGroup.getChildren().addAll( copyBackground, copyText ); return copyGroup; }
The preceding sections, Controller and Sprite Class, set up the game. The next section will focus on the movement of the Sprites.
Movement
Back to the Sprite class, I publish a relocate() method that will move the Sprite. This is used in the shuffle() operation that will randomize the starting display. There is also a MouseEvent EventHandler implemented as a Lambda that moves the pieces in response to drag events. This also controls the highlighting and view changes of the Sprite in response to events.EventHandler<MouseEvent> mouseHandler = (evt) -> { if( evt.getEventType() == MouseEvent.MOUSE_ENTERED && !evt.isPrimaryButtonDown()) { if( !Sprite.this.highlight.isVisible() ) { Sprite.this.normal.setVisible(false); Sprite.this.highlight.setVisible(true); Sprite.this.drag.setVisible(false); Sprite.this.error.setVisible(false); } } else if( evt.getEventType() == MouseEvent.MOUSE_EXITED && !evt.isPrimaryButtonDown() ) { if( !Sprite.this.normal.isVisible() ) { Sprite.this.normal.setVisible(true); Sprite.this.highlight.setVisible(false); Sprite.this.drag.setVisible(false); Sprite.this.error.setVisible(false); } } else if( evt.getEventType() == MouseEvent.MOUSE_DRAGGED ) { if( !Sprite.this.drag.isVisible() ) { Sprite.this.normal.setVisible( false ); Sprite.this.highlight.setVisible(false); Sprite.this.drag.setVisible(true); Sprite.this.error.setVisible(false); } if( mouseInSpriteX == -1.0d || mouseInSpriteY == -1.0d ) { Point2D spriteInParent = Sprite.this.container.localToParent( Sprite.this.container.getLayoutBounds().getMinX(), Sprite.this.container.getLayoutBounds().getMinY() ); double spriteMinX = spriteInParent.getX(); double spriteMinY = spriteInParent.getY(); mouseInSpriteX = evt.getSceneX() - spriteMinX; mouseInSpriteY = evt.getSceneY() - spriteMinY; } else { Sprite.this.container.relocate( evt.getSceneX() - mouseInSpriteX, evt.getSceneY() - mouseInSpriteY ); } } else if( evt.getEventType() == MouseEvent.MOUSE_RELEASED ) { if( mouseInSpriteX != -1.0d && mouseInSpriteY != -1.0d ) { mouseInSpriteX = -1.0d; mouseInSpriteY = -1.0d; Sprite.this.normal.setVisible(true); Sprite.this.highlight.setVisible(false); Sprite.this.drag.setVisible(false); Sprite.this.error.setVisible(false); } } };
Entered and Exited control the normal to highlight transition. The drag event will show the drag view (transparent). The relocate() method is called once the relocation is offset by where in the Sprite the mouse was initially placed.
Scoring
In the Controller class, there is a check() @FXML method tied to the bottom Button of the same name. check() marches through the expected results and a sorted -- by position -- list of Sprites. If each iteration matches (ex "A" is the top most, "B" is after "A", etc), the check passes. Otherwise, the check fails. In both cases a dialog is presented. An animation of the error view on the Sprite is used to highlight the problem Sprites.@FXML public void check() { Collections.sort( sprites, spriteYComparator ); boolean allMatch = true; ListspritesToFlag = new ArrayList<>(); for( int i=0; i { if (response == ButtonType.YES) { shuffle(); } else { Platform.exit(); } }); } else { Alert alert = new Alert( AlertType.WARNING, "Incorrect. Try again?" ); alert.showAndWait(); spritesToFlag .stream() .forEach((sp)->sp.flagAsError()); } }
Finally, the shuffle() method produces the starting display. Sprites are scattered across the background via a relocate() call.
@FXML public void shuffle() { Bounds bounds = background.getLayoutBounds(); double minx = bounds.getMinX(); double miny = bounds.getMinY(); Random random = new Random(); for( Sprite sp : sprites ) { double maxx = bounds.getMaxX() - sp.container.getWidth(); double maxy = bounds.getMaxY() - sp.container.getHeight(); double x = random.nextDouble() * (maxx - minx); double y = random.nextDouble() * (maxy - miny); sp.relocate(x, y); } }
This blog post demonstrated how to make a simple game using JavaFX and Scene Builder. Although the whole app could be coded exclusively in Java (no FXML), I'm in a better position from a graphic design perspective to maintain the shapes. For example, I can apply different stylesheets to Scene Builder to give my app a different skin. And I can quickly make adjustments since I see all the views at once.
As developers, we're often focused on delivering the functionality behind the controls. But in today's app-heavy world, we're going to be increasingly judged on style points.
No comments:
Post a Comment