The Standalone Tic-Tac-Toe GameFigure 31-2 gives the class diagrams for the standalone tic-tac-toe game, FourByFour. The class names and public methods are shown. The FourByFour class is a Java 3D JFrame, which contains a GUI made up of a WrapFourByFour object for the 3D canvas and a text field for messages. The public showMessage( ) method allows objects to write into that field. Figure 31-2. Class diagrams for FourByFourWrapFourByFour constructs the scene: the lights, background, the parallel projection, and 16 poles, but leaves the initialization of the markers to a Positions object. WrapFourByFour creates a PickDragBehavior object to handle mouse picking and dragging. The Board object contains the game logic, with data structures representing the current state of the board and methods for making a move and reporting a winner.
The Origins of the GameA tic-tac-toe game, FourByFour, has been part of the Java 3D distribution for many years. It has a more extensive GUI than the version described in this chapter, supporting repeated games, varying skill levels, and listing the high scores. A crucial difference is that the demo pits the machine against a single player, rather than player versus player. This requires a much more complicated Board class, weighing in at 2,300 lines (compared to 300 in my version of Board). It's possible to change the machine's skill level (from dumb to expert), which makes Board carry out increasingly comprehensive analyses of the player's moves and the current game state. The original Board renders the game to a 2D window in addition to a 3D canvas. The original FourByFour demo utilizes its own versions of the Box and Cylinder utilities and builds its GUI with AWT. My version uses the Java 3D shape utility classes and Swing. The demo employs its own ID class to number the marker shapes; my code uses the userData field of the Java 3D Shape3D class. Building the Game SceneMy WrapFourByFour class uses the same coding style as earlier 3D examples in that it creates the 3D canvas and adds the scene in createSceneGraph( ): private void createSceneGraph(Canvas3D canvas3D, FourByFour fbf) { sceneBG = new BranchGroup( ); bounds = new BoundingSphere(new Point3d(0,0,0), BOUNDSIZE); // Create the transform group which moves the game TransformGroup gameTG = new TransformGroup( ); gameTG.setCapability(TransformGroup.ALLOW_TRANSFORM_READ); gameTG.setCapability(TransformGroup.ALLOW_TRANSFORM_WRITE); sceneBG.addChild(gameTG); lightScene( ); // add the lights addBackground( ); // add the background gameTG.addChild( makePoles( ) ); // add poles // posns holds the spheres/boxes which mark a player's turn. // Initially posns displays a series of small white spheres. Positions posns = new Positions( ); // board tracks the players' moves on the game board Board board = new Board(posns, fbf); gameTG.addChild( posns.getChild( ) ); // add markers mouseControls(canvas3D, board, gameTG); sceneBG.compile( ); // fix the scene } A transformGroup, gameTG, is added below the scene's BranchGroup; it's used by the PickDragBehavior object to rotate the game when the mouse is dragged. Consequently, all the visible game objects (e.g., poles, markers) are linked to gameTG; static entities (e.g., the lights, background) are connected to sceneBG. makePoles( ) creates the 16 poles where the game markers appear. The initial positions of the poles are shown in Figure 31-3. The first pole (the leftmost pole of the first row) is centered at (-30,0,30) and has length 60 units. The other three poles in the row are spaced at 20-unit intervals to the right, and the next row begins, 20 units along the z-axis. There's no particular significance to these dimensions, aside from making the various parts of the game easier to see: private BranchGroup makePoles( ) { Color3f grey = new Color3f(0.25f, 0.25f, 0.25f); Color3f black = new Color3f(0.0f, 0.0f, 0.0f); Figure 31-3. The game poles Color3f diffuseWhite = new Color3f(0.7f, 0.7f, 0.7f); Color3f specularWhite = new Color3f(0.9f, 0.9f, 0.9f); // Create the pole appearance Material poleMaterial = new Material(grey, black, diffuseWhite,specularWhite,110.f); poleMaterial.setLightingEnable(true); Appearance poleApp = new Appearance( ); poleApp.setMaterial(poleMaterial); BranchGroup bg = new BranchGroup( ); float x = -30.0f; float z = -30.0f; for(int i=0; i<4; i++) { for(int j=0; j<4; j++) { Transform3D t3d = new Transform3D( ); t3d.set( new Vector3f(x, 0.0f, z) ); TransformGroup tg = new TransformGroup(t3d); Cylinder cyl = new Cylinder(1.0f, 60.0f, poleApp); cyl.setPickable(false); // user cannot select the poles tg.addChild( cyl ); bg.addChild(tg); x += 20.0f; } x = -30.0f; z += 20.0f; } return bg; } // end of makePoles( ) A pole is represented by a Cylinder, below a transformGroup which positions it. The poles (and transforms) are grouped under a BranchGroup. The cylinders are made unpickable, which simplifies the picking task in PickDragBehavior. mouseControls( ) creates a PickDragBehavior object, attaching it to the scene: private void mouseControls(Canvas3D c,Board board, TransformGroup gameTG) { PickDragBehavior mouseBeh = new PickDragBehavior(c, board, sceneBG, gameTG); mouseBeh.setSchedulingBounds(bounds); sceneBG.addChild(mouseBeh); } initUserPosition( ) modifies the view to use parallel projection and moves the viewpoint along the +z axis so the entire game board is visible and centered in the canvas: private void initUserPosition( ) { View view = su.getViewer( ).getView( ); view.setProjectionPolicy(View.PARALLEL_PROJECTION); TransformGroup steerTG = su.getViewingPlatform( ).getViewPlatformTransform( ); Transform3D t3d = new Transform3D( ); t3d.set(65.0f, new Vector3f(0.0f, 0.0f, 400.0f)); steerTG.setTransform(t3d); } Building the Game MarkersThe Positions object creates three sets of markers: 64 small white balls, 64 larger red balls, and 64 blue cubes. The white balls are visible, the other shapes are invisible when the game starts. When a player makes a move, the selected white ball is replaced by a red one (if it was player 1's turn) or a blue cube (for player 2). This functionality is achieved by creating three Java 3D Switch nodes, one for each set of markers, linked to the scene with a Group node, as shown in Figure 31-4. Figure 31-4. Scene graph branch for the game markersEach Shape3D is positioned with a transformGroup and allocated a bit in a Java 3D BitSet object corresponding to its position in the game (positions are numbered 0 to 63). The BitSet is used as a mask in the Switch node to specify what shapes are visible or invisible. The three Switch branches are created with calls to makeWhiteSpheres( ), makeRedSpheres( ), and makeBlueCubes( ), which are functionally similar. The code for makeWhiteSpheres( ) is: private void makeWhiteSpheres( ) { // Create the switch nodes posSwitch = new Switch(Switch.CHILD_MASK); // Set the capability bits posSwitch.setCapability(Switch.ALLOW_SWITCH_READ); posSwitch.setCapability(Switch.ALLOW_SWITCH_WRITE); posMask = new BitSet( ); // create the bit mask Sphere posSphere; for (int i=0; i<NUM_SPOTS; i++) { Transform3D t3d = new Transform3D( ); t3d.set( points[i] ); // set position TransformGroup tg = new TransformGroup(t3d); posSphere = new Sphere(2.0f, whiteApp); Shape3D shape = posSphere.getShape( ); shape.setUserData( new Integer(i) ); // add board position ID to each shape tg.addChild( posSphere ); posSwitch.addChild(tg); posMask.set(i); // make visible } // Set the positions mask posSwitch.setChildMask(posMask); group.addChild( posSwitch ); } // end of makeWhiteSpheres( ) All the game marker Shape3D objects are pickable by default, which means that the PickDragBehavior object can select them. An important feature of makeWhiteSpheres( ) is that each ball is assigned user data (i.e., an Integer object holding its position index). makeRedSpheres( ) and makeBlueCubes( ) don't set user data for their markers.
The integer field for a selected white ball is read by PickDragBehavior to determine its position. There's no need for integer fields in the red balls or blue cubes since they occupy positions on the board. Another difference between makeWhiteSpheres( ) and the other two methods is that the white balls are all set to be visible initially, and the red balls and blue cubes are invisible. This is changed during the course of the game by calls to set( ): public void set(int pos, int player) // called by Board to update the 3D scene { // turn off the white marker for the given position posMask.clear(pos); posSwitch.setChildMask(posMask); // turn on one of the player markers if (player == PLAYER1) { player1Mask.set(pos); player1Switch.setChildMask(player1Mask); // red for p1 } else if (player == PLAYER2) { player2Mask.set(pos); player2Switch.setChildMask(player2Mask); // blue for p2 } else // should not happen System.out.println("Illegal player value: " + player); } The pos argument is the position index (a number between 0 and 63), extracted from the user data field of the selected white marker. The player value represents the first or second player. The main design choice in the Positions class is to create all the possible markers at scene creation time. This makes the scene's initialization a little slower, but the rendering speed for displaying a player's marker is improved because Shape3D nodes don't need to be attached or detached from the scene graph at runtime. The position indexes for the markers (0-63) are tied to locations in space by the initLocations( ) method, which creates a points[] array of markers' coordinates. The positions correspond to the indexes of points[]: private void initLocations( ) { points = new Vector3f[NUM_SPOTS]; int count = 0; for (int z=-30; z<40; z+=20) for (int y=-30; y<40; y+=20) for (int x=-30; x<40; x+=20) { points[count] = new Vector3f((float)x, (float)y, (float)z); count++; } } points[] is used to initialize the transformGroups for the markers, positioning them in space. The coordinates were chosen so the markers appear embedded in the poles. Figure 31-5 shows the first row of poles (the back row in Figure 31-3) and its 16 markers, which have position indexes 0-15. Figure 31-5. Marker positions in the first row of polesPicking and DraggingThe PickDragBehavior object allows the user to click on a white marker to signal that the next move should be in that position, and to utilize mouse dragging to rotate the scene. The object monitors mouse drags, presses, and releases. A mouse release is employed as a way of detecting that a new mouse drag may start during the next user interaction. processStimulus( ) responds to the three mouse operations: public void processStimulus(Enumeration criteria) { WakeupCriterion wakeup; AWTEvent[] event; int id; int xPos, yPos; while (criteria.hasMoreElements( )) { wakeup = (WakeupCriterion) criteria.nextElement( ); if (wakeup instanceof WakeupOnAWTEvent) { event = ((WakeupOnAWTEvent)wakeup).getAWTEvent( ); for (int i=0; i<event.length; i++) { xPos = ((MouseEvent)event[i]).getX( ); yPos = ((MouseEvent)event[i]).getY( ); id = event[i].getID( ); if (id == MouseEvent.MOUSE_DRAGGED) processDrag(xPos, yPos); else if (id == MouseEvent.MOUSE_PRESSED) processPress(xPos, yPos); else if (id == MouseEvent.MOUSE_RELEASED) isStartDrag = true; // a new drag may start next time } } } wakeupOn (mouseCriterion); } // end of processStimulus( ) processDrag( ) handles mouse dragging and is passed the current (x, y) position of the cursor on screen. processPress( ) deals with a mouse press, and the global Boolean isStartDrag is set to true when the mouse is released. Dragging the boardWhen the user drags the mouse, a sequence of MOUSE_DRAGGED events are generated, each one including the current (x, y) position of the cursor. processDrag( ) obtains the movement covered by a single MOUSE_DRAGGED event by calculating the offset relative to the (x, y) coordinate from the previous drag event (stored in xPrev and yPrev). The x and y components of the move are converted into x- and y-axis rotations and are applied to the TRansformGroup for the board. However, this approach only works after the first event, so the second event (and subsequent ones) have a previous coordinate to consider. The first event in a drag sequence is distinguished by the isStartDrag Boolean: private void processDrag(int xPos, int yPos) { if (isStartDrag) isStartDrag = false; else { // not the start of a drag, so can calculate offset int dx = xPos - xPrev; // get dists dragged int dy = yPos - yPrev; transformX.rotX( dy * YFACTOR ); // convert to rotations transformY.rotY( dx * XFACTOR ); modelTrans.mul(transformX, modelTrans); modelTrans.mul(transformY, modelTrans); // add to existing x- and y- rotations boardTG.setTransform(modelTrans); } xPrev = xPos; // save locs so can work out drag next time yPrev = yPos; } modelTrans is a global transform3D object that stores the ongoing, total rotational effect on the board. TRansformX and TRansformY are globals. boardTG is the TRansformGroup for the board, passed in from WrapFourByFour when the PickDragBehavior object is created. Picking a markerprocessPress( ) sends a pick ray along the z-axis into the world, starting from the current mouse press position. The closest intersecting node is retrieved and if it's a Shape3D containing a position index, then that position will be used as the player's desired move. One problem is translating the (x, y) position supplied by the MOUSE_PRESSED event into world coordinates. This is done in two stages: The screen coordinate is mapped to the canvas' image plate and then to world coordinates. The picking code is simplified by the judicious use of setPickable(false) when the scene is set up. The poles are made unpickable when created in makePoles( ) in WrapFourByFour, which means that only the markers can be selected: // global private final static Vector3d IN_VEC = new Vector3d(0.f,0.f,-1.f); // direction for picking -- into the scene private Point3d mousePos; private Transform3D imWorldT3d; private PickRay pickRay = new PickRay( ); private SceneGraphPath nodePath; private void processPress(int xPos, int yPos) { canvas3D.getPixelLocationInImagePlate(xPos, yPos, mousePos); // get the mouse position on the image plate canvas3D.getImagePlateToVworld(imWorldT3d); // get image plate --> world transform imWorldT3d.transform(mousePos); // convert to world coords pickRay.set(mousePos, IN_VEC); // ray starts at mouse pos, and goes straight in nodePath = bg.pickClosest(pickRay); // get first node along pickray (and its path) if (nodePath != null) selectedPosn(nodePath); } The image plate to world coordinates transform is obtained from the canvas and applied to mousePos, which changes it in place. A ray is sent into the scene starting from that position, and the closest SceneGraphPath object is retrieved. This should be a branch ending in a game marker or null (i.e., the user clicked on a pole or the background). selectedPosn( ) gets the terminal node of the path and checks that it's a Shape3D containing user data (only the white markers hold data, which is their position index): private void selectedPosn(SceneGraphPath np) { Node node = np.getObject( ); // get terminal node of path if (node instanceof Shape3D) { // check for shape3D Integer posID = (Integer) node.getUserData( ); //get posn index if (posID != null) board.tryPosn( posID.intValue( ) ); } } The position index (as an int) is passed to the Board object where the game logic is located. If a red or blue marker was selected, the lack of user data will stop any further processing since it's not possible for a player to make a move in a spot which has been used. Picking comparisonsThis is the third example of Java 3D picking in this book, and it's worth comparing the three approaches:
The Game RepresentationThe Board object initializes two arrays when it's first created: winLines[][] and posToLines[][]. winLines[][] lists all the possible winning lines in the game, in terms of the four positions that make up a line. For example, referring to Figure 31-5, {0,1,2,3}, {3,6,9,12}, and {0,4,8,12} are winning lines; the game has a total of 76 winning lines. For each line, winLines[][] records the number of positions occupied by a player. If the total reaches four for a particular line, then the player has completed the line and won. posToLines[] specifies all the lines that utilize a given position. Thus, when a player selects a given position, all of those lines can be updated simultaneously. Processing a selected positionThe main entry point into Board is TRyPosn( ), called by PickDragBehavior to pass the player's selected position into the Board object for processing: public void tryPosn(int pos) { if (gameOver) // don't process position when game is over return; positions.set(pos, player); // change the 3D marker shown at pos playMove(pos); // play the move on the board // switch players, if the game isn't over if (!gameOver) { player = ((player == PLAYER1) ? PLAYER2 : PLAYER1 ); if (player == PLAYER1) fbf.showMessage("Player 1's turn (red spheres)"); else fbf.showMessage("Player 2's turn (blue cubes)"); } } // end of tryPosn( ) Board uses a global Boolean, gameOver, to record when the game has ended. The test of gameOver at the start of tryPosn( ) means that selecting a marker will have no effect once the game is finished. The player's marker is made visible by a call to set( ) in the Positions object, and playMove( ) updates winLines[][]. After the move, the current player is switched; the player variable holds the current player's ID. However, the move may have been a winning one, so gameOver is checked before the switch. The calls to showMessage( ) cause the text field in the GUI to be updated. Storing the selected positionplayMove( ) uses the supplied position index to modify the various lines in which it appears. If the number of used positions in any of those lines reaches four, then the player will have won, and reportWinner( ) will be called: private void playMove(int pos) { nmoves++; // update the number of moves // get number of lines that this position is involved in int numWinLines = posToLines[pos][0]; /* Go through each line associated with this position and update its status. If I have a winner, stop game. */ int line; for (int j=0; j<numWinLines; j++) { line = posToLines[pos][j+1]; if (winLines[line][1] != player && winLines[line][1] != UNOCCUPIED) winLines[line][0] = -1; /* The other player has already made a move in this line so this line is now useless to both players. */ else { winLines[line][1] = player; //this line belongs to player winLines[line][0]++; // one more posn used in line if (winLines[line][0] == 4) { // all positions used, gameOver = true; // so this player has won reportWinner( ); } } } } // end of playMove( ) The winLines[x][1] field for line x states whether a player has made a move in that line. If a player selects a position in a line being used by another player, then the line becomes useless, which is signaled by setting winLines[x][0] == -1. Reporting a winnerreportWinner( ) does some numerical "hand-waving" to obtain a score, based on the running time of the game and the number of moves made. The score is reported in the text field of the GUI: private void reportWinner( ) { long end_time = System.currentTimeMillis( ); long time = (end_time - startTime)/1000; int score = (NUM_SPOTS + 2 - nmoves)*111 - (int) Math.min(time*1000, 5000); if (player == PLAYER1) fbf.showMessage("Game over, player 1 wins with score "+score); else // PLAYER2 fbf.showMessage("Game over, player 2 wins with score "+score); } // end of reportWinner( ) |