FX My Life 2.5a: Friends of the State
We have basically enough to re-do the Tic-Tac-Toe game with this new grid control. "Why," you might ask, "would we do something that tedious?" And the answer is "to get a sense of how we could use the grid in a real, or real-ish, game." When we built that game, we got a sense of how reactive programming was helpful, and it would be similarly useful to figure out how our grid could work in that paradigm.
We have this file, "TicTacToe.java", that contains the complete "engine" for playing a game of tic-tac-toe, and my inclination is to say that this shouldn't have to change. We could do some refactoring, of course, but we should really be able to make this code usable with our new grid without breaking our old game at all. In fact, we should be able to save a game in one version and load it in another.
At the same time, we don't want to expend a ton of effort on this.
We need something like model-view-viewmodel (MVVM) or model-view-controller (MVC).
What is MVVMVCM? (About 2.5 billion.)
Before Microsoft invented MVVM in 2005 (but after Trygve Reenskaug put MVC into Smalltalk in 1976), it became apparent to a lot of us that it was helpful to separate the logic of a program from the interface as much as possible and keep the data layer at arm's length from either.
Then tools like Visual Basic, Delphi, VisProRexx, Access came along and virtually dared you to not mash your logic into your UI—although, in fairness, the dBase-inspired 4GLs had been defying you to separate anything from anything for a decade at that point.
Speed was a big reason for this. Also, we didn't have testing, and test-driven-development makes you consider things in terms of how easy they are to test—and simple, separate concerns are way easier.
Rather than pick a formal pattern and see if we can shoehorn what we want into it, what if we ask ourselves what it would take do specifically what we want:
- We want to put our new grid into our old game.
- We want to not have to change our game object.
- Nice to have: Minimal changers to our existing controller.
For our purposes our "M" is the class TicTacToe. In business apps, the model is typically a pretty thin layer of just data, and we could argue fairly convincingly that TicTacToe is just that, since it primarily receives changes and maintains the integrity of the state.
Furthermore, our "V" is the drawing of the tic-tac-toe board—and it has no direct access to the model. It's all handled by the TttController—the "C" in our MVC sundae!
Lookit us. We did a design without even meaning to. (Actually, if you'll recall, we did a fair amount of clean up on this in part one.) Does that mean we need to look at the VM—the view-model—to progress? What are we even doing?
The idea of the view-model is that it sits between the view and the model and works two ways. That's what our controller does. But our controller handles "big picture" stuff as well, like saving the game, resetting it, and so on.
If we create some kind of interface that our Grid can hook to, and hook our TicTacToe class to it so that it can be updated when the user does something and the view knows to update when the game does something—well, that's a view-model.
Having done that, we then have a pattern to follow for Dunslip.
As we've been doing all along, let's not worry about getting it right. We have the latest code in github—and we can always undo whatever mess we get into. And, as we've seen, getting into messes is how we learn.
In this case, the view-model we want is specifically for the grid control to interact with the underlying model: that would be the drawXXXX
and the handleXXXX
commands, basically. We should be able to get rid of the getCoord
method, too. At the game level, we should probably bind things like the win-or-draw status of the game to the approprate label.
It seems like the place we should start is by giving our Grid some means to draw something other than individual cells or grid lines, doesn't it? The handleXXXX
routines almost seem like they'll be easy, but how do we bridge the gap between completely internal game state and a view?
Visualize Whirled Grids
The answer is probably somewhere in the component we've been using for inspiration. I don't want to require a model, but I would like to allow one. I can see why the author made his Grid a generic, but I feel like using a more functional—and much looser approach—would suit me better. Your mileage, as they say, should consult your doctor.
I feel like our first foothold should be in this Controller method:
public void handleOnDrop(DragEvent event) {
int row = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int column = getCoord(board.getHeight(), event.getY(), board.getColumnCount());
try {
game.addPiece(event.getDragboard().getString(), column, row);
} catch (Exception e) {
e.printStackTrace();
}
draw();
}
But even this is not as simple as it seems because the drag event does not start with the grid—unless we make the Xs and Os bin a grid, but even if we could do that, that may raise more questions than it answers.
So, the first step we need to look at is here:
public void handleOnDragDetected(MouseEvent event) {
String piece = ((ImageView) event.getSource()).getId();
if (!game.winner.equals("")) {
message.setText("The game is over. Press NEW for a new game.");
} else {
if (!piece.equals(game.turn)) {
message.setText("You can't move " + piece + " when it's " + game.turn + "'s turn!");
} else {
message.setText(piece + " is being dragged!");
Dragboard db = X.startDragAndDrop(TransferMode.ANY);
ClipboardContent content = new ClipboardContent();
content.putString(piece);
db.setContent(content);
}
}
}
This is probably going to stay the same for now—although we should totally bind the message
label to the game somehow. Keep in mind that we're using a string that contains either an "X" or an "O" depending on what its ID is. Probably not optimal, but it will do for now.
The next step:
public void handleOnDragOver(DragEvent event) {
int column = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int row = getCoord(board.getHeight(), event.getY(), board.getColumnCount());
if ((column >= 0 && column <= 2) &&
(row >= 0 && row <= 2) &&
(game.get(row, column) == null)) {
if (event.getGestureSource() != board && event.getDragboard().hasString()) {
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
}
event.consume();
}
OK, this is our first really clear change, I think. Our grid component should handle the DragEvent by firing some kind of even containing the coordinate and the object being dragged. The ViewModel should pass this through to the model (perhaps via a translation method of some kind) which should then say "Yea" or "Nay" to the attempted move.
To map this out:
- Grid needs to handleOnDragOver events.
- It needs to be able to pass the event to an underlying viewmodel.
- It needs to also be able to fire a "highlight" event somehow in response to the "yea" or "nay".
The next step will almost certainly be to handle the drop:
public void handleOnDrop(DragEvent event) {
int row = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int column = getCoord(board.getHeight(), event.getY(), board.getColumnCount());
try {
game.addPiece(event.getDragboard().getString(), column, row);
} catch (Exception e) {
e.printStackTrace();
}
draw();
}
This should go really easily because it's literally just going to be "This was dropped on this coordinate".
But, alas, one thing stands in our way.
Our Tic-Tac-Toe game doesn't use a Grid
, obviously, since we just wrote it. It uses a GridPane
and I don't see any way we can easily transition from one to the other. We're going to have to break the game in order to use our new component.
This is one of the downsides to a gonzo, slash-and-hack approach to learning things. If we had spent more time up-front solving the grid issue—
GridPane
was clearly never a good choice—we wouldn't have to swallow this horse-pill now. On the flip-side, we're that much better equipped to handle the problem than we were when we started, so it was probably the wisest choice.
So, let's do a lot of commenting out and see where the road leads.
Live New Grids
Step 1, in tttgrid.fxml, replace GridPane
with Grid
. IntelliJ will want to add an import:
<?import fxgames.Grid?>
Then it will give us a lot of red in the former GridPane
's section:
<Grid fx:id="board" gridLinesVisible="true" layoutX="0.5" layoutY="0.5" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onDragDropped="#handleOnDrop" onDragOver="#handleOnDragOver" prefHeight="400.0" prefWidth="400.0">
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
</Grid>
In order, we have to change the @FXML
directive for board in TttController:
@FXML
public Grid board;
We have to remove the gridLinesVisible
, though we should probably add that or something like it at some point. And we gotta get rid of the constraints section. The resultant FXML is rather pleasing in its brevity:
<Grid fx:id="board" layoutX="0.5" layoutY="0.5" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" onDragDropped="#handleOnDrop" onDragOver="#handleOnDragOver" prefHeight="400.0" prefWidth="400.0">
</Grid>
This is another good sign the GridPane was never the right tool for the job. Let's add columns and rows to the end:
...colCount="3" rowCount="3">
Meanwhile, we have to make two code changes to get TttController.java to get it to compile, because we called our method getColCount
rather than getColumnCount
:
public void handleOnDragOver(DragEvent event) {
int column = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int row = getCoord(board.getHeight(), event.getY(), board.getColCount());
. . .
public void handleOnDrop(DragEvent event) {
int row = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int column = getCoord(board.getHeight(), event.getY(), board.getColCount());
Now, I don't expect this to work right off the bat, but let's see what happens if we run this:
That's...actually not too bad! The highlighting works, though not when an item is being dragged, and when you drop, well, everything ends up in the center:
That's because we're just adding things to Grid
, we're not specifying where they should be, and a StackPane
, from which we descend, puts everything in the center by default.
Let's keep working through this. The next method we should probably look at is:
public void handleOnDragOver(DragEvent event) {
int column = getCoord(board.getWidth(), event.getX(), board.getRowCount());
int row = getCoord(board.getHeight(), event.getY(), board.getColCount());
if ((column >= 0 && column <= 2) &&
(row >= 0 && row <= 2) &&
(game.get(row, column) == null)) {
if (event.getGestureSource() != board && event.getDragboard().hasString()) {
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
}
event.consume();
}
On reflection, this is kind of a mess, when we look at it. (Technically, we might call it " complected ".) Because the Controller is handling it, we must:
- Translate the mouse coordinate to a grid coordinate,
- But make sure it's actually ON the grid,
- Check to see if the square is free,
- And then allow the drag if it is.
What we should do is have the Grid ask the Model (via the ViewModel) if a move to that square would be valid and have it react accordingly.
In Theory, Theory Always Works
Time to implement...something. Our "model" for the grid takes an approach of having a view, a model, and then a cell object. This approach seems to have worked well—but I'm disinclined to follow it.
If I'm being honest, my primary reasons are simply how I like to work: Functionally and delaying type decisions till the last possible second. If I need to justify it further, I could add that I don't think the Grid should have any concept of state other than needing to redraw itself. We want our grid to be very unopinionated and really as type-indifferent as possible. We'll see if this strategy works out for us.
Let's start by putting in an observable for "item is being dragged over cell" that will fire whenever the Grid's hoverCoord changes during a drag event. You may have noticed that the Grid's usual highlighting feature doesn't work when we're dragging an X or O on to our board, so let's look at that first.
Actually, let's just take one step back from there and make sure we get all our drag events. We're gonna copy the mouse enter/move/exit code to the corresponding drag events:
this.setOnDragEntered(event -> {
hoverCoord = this.getCoord(event.getX(), event.getY());
setBackgrounds();
});
this.setOnDragOver(event -> {
int x = getAxisVal(event.getX(), true);
int y = getAxisVal(event.getY(), false);
if (hoverCoord.x != x || hoverCoord.y != y) {
this.setCoord(hoverCoord, event.getX(), event.getY());
this.setBackgrounds();
}
});
this.setOnDragExited(event -> {
hoverCoord = null;
setBackgrounds();
});
Obviously, we'll want to refactor these out but for now, let's leave them and make sure that we get our cell highlighting.
We don't. We get the enter working and the exit, but not the DragOver, so what's going on?
Well, when events don't appear in your components the way you think they should, the place to look is the owning control, which might be consuming them. And in fact, that's what's going on. We'll have to remove the controller's method:
public void handleOnDragOver(DragEvent event) {
And also take the reference to handleOnDragOver
out of the FXML. Now, you might be tempted to leave the method in but remove this line at the end:
event.consume();
I know I was; I figured the unconsumed event would get passed down if I took out that line. It did not. I do not know why.
So, we're going to want to factor out the hoverCoord
stuff that is common to the mouse moving and dragging happening, and we're going to want to add a hook that we can use in our "viewmodel" to have the grid talk directly to the game.
Let's refactor first:
public void setHoverCoord(double x, double y) {
if (hoverCoord == null) {
hoverCoord = this.getCoord(x, y);
} else {
int nx = getAxisVal(x, true);
int ny = getAxisVal(y, false);
if (hoverCoord.x != nx || hoverCoord.y != ny) {
hoverCoord.x = nx;
hoverCoord.y = ny;
}
}
setBackgrounds();
}
public void clearHoverCoord() {
hoverCoord = null;
setBackgrounds();
}
We'll use setHoverCoord
where we create and update the coordinate, and clearHoverCoord
when we delete it. This means our initialization code looks like:
this.setOnMouseEntered(event -> setHoverCoord(event.getX(), event.getY()));
this.setOnMouseExited(event -> clearHoverCoord());
this.setOnMouseMoved(event -> setHoverCoord(event.getX(), event.getY()));
this.setOnDragEntered(event -> setHoverCoord(event.getX(), event.getY()));
this.setOnDragOver(event -> setHoverCoord(event.getX(), event.getY()));
this.setOnDragExited(event -> clearHoverCoord());
Which I think can be improved, but let's see where this goes first. Oh, and don't forget, you'll have to remove the existing drag-over from the TttController
and the FXML
:
onDragOver="#handleOnDragOver"
Take the above out of the FXML and comment out the handleOnDragOver
method.
Why Is Everything Always An Event With You?
Ideally, I think what I'd want is for the view-model to look something like this:
public class tttViewModel {
private final TicTacToe game;
private final Grid board;
public tttViewModel(Grid g, TicTacToe t) {
game = t;
board = g;
g.setOnGridDragMove(game::canAccept);
g.setOnGridDragDrop(game::addPiece);
}
}
And this would allow us to eliminate handleOnDragOver
, handleOnDrop
and even getCoord
from TttController. But there's a little too much magic in this: We could (probably? maybe?) create an event class that gave us an integer x
and y
for the grid cell, but we also need a way to translate the DragEvent into the event.piece
.
We can do this by having the view-model have a handling for DragBoard, like so:
public tttViewModel(Grid g, TicTacToe t) {
game = t;
board = g;
g.setProcessDragBoard(de -> de.getDragboard().getString());
g.setOnGridDragMove(game::canAccept);
g.setOnGridDragDrop(game::addPiece);
}
There's an unfortunate—or at least I don't like it—side-effect of this, which we'll discover as we implement this. First of all, though, let's look how these events get set up in the Grid initialization:
this.setOnDragEntered(event -> setHoverCoord(event.getX(), event.getY()));
No change there. To do the drag-over, though, we need to plug in our view-model functions:
this.setOnDragOver(event -> {
setHoverCoord(event.getX(), event.getY());
if (onGridDragMove != null)
if (onGridDragMove.handle (onProcessDragBoard.handle(event), hoverCoord.x, hoverCoord.y))
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
});
By letting the grid delegate everything to the view model, we achieve that oh-so-clean tttViewModel
implementation. And the drop is handled similarly:
this.setOnDragDropped(event -> {
setHoverCoord(event.getX(), event.getY());
if (onGridDragDrop != null)
onGridDragDrop.handle( onProcessDragBoard.handle(event), hoverCoord.x, hoverCoord.y);
});
Smooth. So far.
In order to make this work, we'll need to create the set*
methods in Grid, and to create those methods, we'll need a FunctionalInterface
for each type:
package fxgames;
import javafx.scene.input.DragEvent;
@FunctionalInterface
public interface ProcessGridDragBoard<T> {
public T handle(DragEvent d);
}
Note that we have to make this method generic, because we don't want to constrain the client: In many cases we may be creating elaborate objects or who knows what. This means we also have to generify the other two "events":
package fxgames;
@FunctionalInterface
public interface GridDragMove<T> {
public boolean handle(T var1, int x, int y);
}
And:
package fxgames;
@FunctionalInterface
public interface GridDragDrop<T> {
public void handle(T var1, int x, int y);
}
But now, when we set this up in Grid
:
public ProcessGridDragBoard<T> onProcessDragBoard;
public void setProcessDragBoard(ProcessGridDragBoard<T> gde) {
this.onProcessDragBoard = gde;
}
public GridDragMove<T> onGridDragMove;
public void setOnGridDragMove(GridDragMove<T> e) {
this.onGridDragMove = e;
}
public GridDragDrop<T> onGridDragDrop;
public void setOnGridDragDrop(GridDragDrop<T> e) {
this.onGridDragDrop = e;
}
We gots a problem: We have no T
. To make this all work, we have to generify Grid:
public class Grid<T> extends StackPane {
Now, our model grid did this and it made a lot of sense: It had a concept of cells with state and it was coupling the grid to that game state.
We wanted to be looser, however, so the idea of having to tie the entire Grid to a transitory class chafes my hide. Let's finish this process, since we're already here, then take another look at the whole thing.
We have to change TttController:
@FXML
public Grid<String> board;
And our tttViewModel:
public tttViewModel(Grid<String> g, TicTacToe t) {
. . .
And, this is unrelated to the generic, but we need to add a canAccept
method to TicTacToe
:
public boolean canAccept(String s, int x, int y) {
return true;
}
We'll return true
for now and get back to the logic aspects later.
Oh, we do need to actually hook up the view-model in the TttController:
private TttViewModel tttvm;
. . .
public void initialize() {
player1.focusedProperty().addListener(nameChangeListener);
player2.focusedProperty().addListener(nameChangeListener);
tttvm = new TttViewModel(board, game);
And comment out TttController
's handleOnDragOver
, as that is all delegated to TttViewModel.
This all works. I've changed the tttViewModel
to TttViewModel
just to be consistent with my casing, and pushed the results to GitHub.
Theory into Practice, Take 2
Well, that was interesting and a worthwhile path to go down—but I don't like it: A minor, transitory data-type has infected our grid! The problem should be easy to fix, however, and the error arose from (me) having worked a lot in dynamically typed languages where things get passed around from one end of code to the other, in ignorance and bliss until they reach their final destination.
Can't do that in any statically typed language!
The problem is expecting the grid to process the DragBoard, even in the transitory way we're doing it.
g.setProcessDragBoard(de -> de.getDragboard().getString());
If we eliminate the whole concept of process-drag-board being delegable, take out all the little angle-brackets, we get this. In Grid, we just pass the DragEvent straight through to the view-model:
this.setOnDragEntered(event -> setHoverCoord(event.getX(), event.getY()));
this.setOnDragOver(event -> {
setHoverCoord(event.getX(), event.getY());
if (onGridDragMove != null)
if (onGridDragMove.handle (event, hoverCoord.x, hoverCoord.y))
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
});
this.setOnDragDropped(event -> {
setHoverCoord(event.getX(), event.getY());
if (onGridDragDrop != null)
onGridDragDrop.handle(event, hoverCoord.x, hoverCoord.y);
});
this.setOnDragExited(event -> clearHoverCoord());
We have only two onXXX
variables to set for Grid
:
public GridDragMove onGridDragMove;
public void setOnGridDragMove(GridDragMove e) {
this.onGridDragMove = e;
}
public GridDragDrop onGridDragDrop;
public void setOnGridDragDrop(GridDragDrop e) {
this.onGridDragDrop = e;
}
The GridDragDrop
type looks like this:
package fxgames;
import javafx.scene.input.DragEvent;
@FunctionalInterface
public interface GridDragDrop {
public void handle(DragEvent var1, int x, int y);
}
Replacing the generic with the decidedly concrete DragEvent
. Same thing happens in GridDragMove
:
package fxgames;
import javafx.scene.input.DragEvent;
@FunctionalInterface
public interface GridDragMove {
public boolean handle(DragEvent var1, int x, int y);
}
And now TttViewModel is not quite so clean, as we insert calls to DragBoardToPiece:
package fxgames.ttt;
import fxgames.Grid;
import javafx.scene.input.DragEvent;
public class TttViewModel {
private final TicTacToe game;
private final Grid board;
public TttViewModel(Grid g, TicTacToe t) {
game = t;
board = g;
g.setOnGridDragMove((var1, x, y) -> game.canAccept(dragBoardToPiece(var1), x, y));
g.setOnGridDragDrop((var1, x, y) -> {
game.addPiece(dragBoardToPiece(var1), x, y);
});
}
public String dragBoardToPiece(DragEvent de) {
return de.getDragboard().getString();
}
}
And don't forget to change the FXML import in the TttController:
@FXML
public Grid<String> board;
becomes:
@FXML
public Grid board;
It's always unfortunate when you have to take a clean piece of code like TttViewModel and make it a bit messier, but like the man says, there are no solutions, only trade-offs, and this is a good trade-off:
Our Grid is back to being pure: It knows nothing of the game underneath it, nor really anything of the view-model, except that it potentially exists somewhere. (And the way delegation works, not even that, since we could do this all in TttController.)
The View-Model is where the ugly should go: The way this universe works, apparently, is that we can create pure, beautiful things as long as they never interact with reality. We can make our game models as beautiful and clean and elegant as we can manage, because we're dealing with ideas expressed in math. We can make our views relatively clean, though since they end up rendered on real-world devices and interacted with by users, they will get messier as we try to make things good to look at and use—and these things tend to create compromise. But the View-Model is what has to reconcile the reality of the game model with the reality of the view, and that's always going to mean friction.
Now let's get to making the game actually work again.
Putting The Pieces In Places
Let's fix up the canAccept code. It should look something like this:
public boolean canAccept(String s, int x, int y) {
return (state[y][x]==null);
}
Remember, our TicTacToe
game stores values as an array of columns, not an array of rows. If column y
, row x
is null, we should be able to place the piece. But before we can test it, we have to remove the handleOnDrop
method from TttController
. Comment it out like we did the handleOnDragOver
previously, and take it out of the FXML, too!
onDragDropped="#handleOnDrop"
If you don't fxGames will fail when it tries to create the Tic-Tac-Toe game from the FXML.
Now, we can drag an "X" to the upper-right grid and drop it, and if we try next turn to drag an "O" to that same cell, we'll get the NO symbol. Of course, we can't see our Xes or Os any more because we removed the code in TttController that called Draw
.
Draw
is going to be tricky. Let's look at that mess:
public void draw() {
innerGroup.getChildren().remove(line);
for (int i = board.getChildren().size() - 1; i > 0; i--) board.getChildren().remove(i);
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++)
if (game.get(i, j) != null) {
ImageView xo = new ImageView();
if (game.get(i, j).equals("X"))
xo.setImage(X.getImage());
else if (game.get(i, j).equals("O"))
xo.setImage(O.getImage());
xo.setFitWidth(Math.round(board.getWidth() / 3));
xo.setFitHeight(Math.round(board.getHeight() / 3));
GridPane.setRowIndex(xo, i);
GridPane.setColumnIndex(xo, j);
board.getChildren().addAll(xo);
}
}
drawVictorySlash();
p1Score.setText(String.valueOf(game.playerOneWins));
p2Score.setText(String.valueOf(game.playerTwoWins));
tieScore.setText(String.valueOf(game.ties));
if (game.playerOneIsX) {
XoverO.setRotate(0);
} else {
XoverO.setRotate(180);
}
}
It:
- Clears out the children of the grid.
- It adds the Xes and Os in.
- It draws the victory slash.
- Sets the text messages.
- Flips the Player-One-Is-X toggle.
The grid is only concerned with the first three of these issues, so we'll need a way to offload that work to the grid while keeping the other items in the TttController
.
We'll start by making a pane for the pieces in our Grid
. We can copy the borderPane code verbatim:
public final Pane piecePane = new Pane();
. . .
piecePane.maxWidthProperty().bind(widthProperty());
piecePane.maxHeightProperty().bind(heightProperty());
this.getChildren().add(piecePane);
We'll start by making it public and having the view-model access that directly.
It's common practice in Java (and good practice generally) to encapsulate the features of a class so that clients don't use those elements directly. This allows you to lessen the surfaces that can cause breakage. In some cases this is undeniably true but also leads to a situation where adding getters and setters to any feature is just boilerplate. As always, we'll see where the road takes us.
If we look at the first section of draw
from TttController
, we see:
public void draw() {
innerGroup.getChildren().remove(line);
for (int i = board.getChildren().size() - 1; i > 0; i--) board.getChildren().remove(i);
I think we can replace this with:
public void draw() {
board.piecePane.getChildren().removeAll();
As I recall, we couldn't use removeAll
with our old control, because it would either remove things we didn't want removed or not remove things we did want removed, but I think we'll be okay here, because we've set up the piecePane
to be our kind-of "free draw" area.
The remaining section presents only two remaining issues: X
and O
don't exist for the view-model so we can't very well get their images:
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++)
if (game.get(i, j) != null) {
ImageView xo = new ImageView();
if (game.get(i, j).equals("X"))
xo.setImage(X.getImage());
else if (game.get(i, j).equals("O"))
xo.setImage(O.getImage());
xo.setFitWidth(Math.round(board.getWidth() / 3));
xo.setFitHeight(Math.round(board.getHeight() / 3));
GridPane.setRowIndex(xo, i);
GridPane.setColumnIndex(xo, j);
board.getChildren().addAll(xo);
}
}
}
Well, we should say, "those are the issues that keep this from compiling" but there are a few more that will keep this from actually working. The calls to GridPane
are meaningless in this context, since we don't even have a GridPane
any more. It'd be nice to have the Grid
give us the cell dimensions. And perhaps most importantly, we have to do something to position the graphics in the right place.
First things first, we should comment out the draw
method in TttController
. Then we should comment out or eliminate all the calls to that method. In the view-model, we should modify the drag-drop to call the new draw
:
g.setOnGridDragDrop((var1, x, y) -> {
game.addPiece(dragBoardToPiece(var1), x, y);
draw();
});
Now we need to position the pieces:
public void draw() {
board.piecePane.getChildren().removeAll();
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++)
if (game.get(i, j) != null) {
ImageView xo = new ImageView();
if (game.get(i, j).equals("X"))
xo.setImage(cont.X.getImage());
else if (game.get(i, j).equals("O"))
xo.setImage(cont.O.getImage());
xo.setFitWidth(Math.round(board.getWidth() / 3));
xo.setFitHeight(Math.round(board.getHeight() / 3));
xo.setX(i * board.getWidth() / 3);
xo.setY(j * board.getHeight() / 3);
board.piecePane.getChildren().addAll(xo);
}
}
OK, that works. But it shows us a few convenience methods we should have in our grid: We need cellWidth
and cellHeight
as well as cellX
and cellY
. In Grid
:
public final double cellWidth() {
return getWidth() / getColCount();
}
public final double cellHeight() {
return getHeight() / getRowCount();
}
I'm not sure why we were rounding it previously: Probably some sort of confusion with the visual coordinates versus the grid coordinates.
For cellX
and cellY
, I think I don't like those names. After all, the x
and the y
of our grid is the grid coordinate, not the visual coordinate. We could make it cellScreenX
and cellScreenY
, but using "Screen" implies a global coordinate, when we're returning the local coordinate. So let's go with cellLocalX
.
public final double cellLocalX(int i) {
return i * cellWidth();
}
public final double cellLocalY(int j) {
return j * cellHeight();
}
Note that we're not recalculating the cell width and heights. This is another kind of encapsulation, and one that's going to be critical coming up.
HashNode gets a little draggy when the posts get this long, so this post is:
TO BE CONTINUED
Source code to this point here .