Home

Listening to the keyboard

Listening to the keyboard

Perhaps we also want to allow the user to play the game with a keyboard. Let's say we number the fields from 1 to 9 (top-left to bottom-right), and pressing the number should be the same as clicking with the mouse in the field.

To handle this, the Canvas needs to listen to the keyboard:

  listenTo(keys)

We then have to add a reaction to the scala.swing.event.KeyTyped event. Its four parameters are the source component, the Char, the modifiers, and the location (used to distinguish multiple keys such as left and right Shift keys or the keys on the numeric keypad). We only use the Char parameter:

  listenTo(mouse.clicks)
  listenTo(keys)
  reactions += {
    case MouseClicked(_, p, _, _, _) => mouseClick(p.x, p.y)
    case KeyTyped(_, c, _, _) => 
      if ('1' <= c && c <= '9') {
	val idx = c - '1'
	publish(TicTacToeEvent(idx % 3, idx / 3))
      }
  }

In addition to scala.swing.event.KeyTyped, there are scala.swing.event.KeyPressed and scala.swing.event.KeyReleased, which allow you to detect when the key is pressed in and when it is released again.

If you run the program like this, the keyboard doesn't work yet. The reason is that the Canvas object does not have the keyboard focus—currently, only the two buttons can receive keyboard input.

We fix this by setting the focusable attribute of the Canvas, and now everything works.

Here is again the full program tictactoe4.scala:

import scala.swing._
import scala.swing.event._
import java.awt.{Color,Graphics2D,BasicStroke}
import java.awt.geom._

// --------------------------------------------------------------------

class Board {
  private var player = 1
  private val grid = Array(0, 0, 0,
			   0, 0, 0,
			   0, 0, 0)

  def apply(x: Int, y: Int): Int = grid(3 * y + x)
  def currentPlayer: Int = player
  def play(x: Int, y: Int) {
    if (this(x, y) == 0) {
      grid(3 * y + x) = player
      player = 3 - player
    }
  }
  def restart() {
    for (i <- 0 until 9)
      grid(i) = 0
    player = 1
  }
}

// --------------------------------------------------------------------

case class TicTacToeEvent(x: Int, y: Int) extends Event

// --------------------------------------------------------------------

class Canvas(val board: Board) extends Component {
  preferredSize = new Dimension(320, 320)

  focusable = true
  listenTo(mouse.clicks)
  listenTo(keys)
  reactions += {
    case MouseClicked(_, p, _, _, _) => mouseClick(p.x, p.y)
    case KeyTyped(_, c, _, _) => 
      if ('1' <= c && c <= '9') {
	val idx = c - '1'
	publish(TicTacToeEvent(idx % 3, idx / 3))
      }
  }

  // returns squareSide, x0, y0, wid
  private def squareGeometry: (Int, Int, Int, Int) = {
    val d = size
    val squareSide = d.height min d.width
    val x0 = (d.width - squareSide)/2
    val y0 = (d.height - squareSide)/2
    (squareSide, x0, y0, squareSide/3)
  }

  private def mouseClick(x: Int, y: Int) {
    val (squareSide, x0, y0, wid) = squareGeometry
    if (x0 <= x && x < x0 + squareSide &&
	y0 <= y && y < y0 + squareSide) {
      val col = (x - x0) / wid
      val row = (y - y0) / wid
      publish(TicTacToeEvent(col, row))
    }
  }
    
  override def paintComponent(g : Graphics2D) {
    g.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, 
		       java.awt.RenderingHints.VALUE_ANTIALIAS_ON)
    g.setColor(Color.WHITE);
    val d = size
    g.fillRect(0,0, d.width, d.height)
    val (squareSide, x0, y0, wid) = squareGeometry
    g.setColor(Color.BLACK)
    // vertical lines
    for (x <- 1 to 2)
      g.draw(new Line2D.Double(x0 + x * wid, y0, x0 + x * wid, y0 + squareSide))
    // horizontal lines
    for (y <- 1 to 2)
      g.draw(new Line2D.Double(x0, y0 + y * wid, x0 + squareSide, y0 + y * wid))
    g.setStroke(new BasicStroke(3f))
    for (x <- 0 until 3) {
      for (y <- 0 until 3) {
	board(x, y) match {
	  case 1 =>
	    g.setColor(Color.RED)
	    g.draw(new Ellipse2D.Double(x0 + x * wid + 10, y0 + y * wid + 10, 
					wid - 20, wid - 20))
	  case 2 =>
	    g.setColor(new Color(0, 160, 0))
	    val x1 = x0 + x * wid + 10
	    val y1 = y0 + y * wid + 10
	    g.draw(new Line2D.Double(x1, y1, x1 + wid - 20, y1 + wid - 20))
	    g.draw(new Line2D.Double(x1, y1 + wid - 20, x1 + wid - 20, y1))
	  case _ => // draw nothing
	}
      }
    }
  }
}

// --------------------------------------------------------------------

class UI(val board: Board) extends MainFrame {
  private def restrictHeight(s: Component) {
    s.maximumSize = new Dimension(Short.MaxValue, s.preferredSize.height)
  }

  title = "Tic Tac Toe #4"

  val canvas = new Canvas(board)
  val newGameButton = Button("New Game") { newGame() }
  val turnLabel = new Label("Player 1's turn")
  turnLabel.foreground = Color.BLUE
  val quitButton = Button("Quit") { sys.exit(0) }
  val buttonLine = new BoxPanel(Orientation.Horizontal) {
    contents += newGameButton
    contents += Swing.HGlue
    contents += turnLabel
    contents += Swing.HGlue
    contents += quitButton
  }

  // make sure that resizing only changes the TicTacToeDisplay
  restrictHeight(buttonLine)
  
  contents = new BoxPanel(Orientation.Vertical) {
    contents += canvas
    contents += Swing.VStrut(10)
    contents += buttonLine
    border = Swing.EmptyBorder(10, 10, 10, 10)
  }

  listenTo(canvas)
  reactions += {
    case TicTacToeEvent(x, y) => 
      board.play(x, y)
      updateLabelAndBoard()
  }

  def updateLabelAndBoard() {
    turnLabel.text = "Player %d's turn".format(board.currentPlayer)
    canvas.repaint()
  }

  def newGame() {
    board.restart()
    updateLabelAndBoard()
  }
}

object TicTacToeFour {
  def main(args: Array[String]) {
    val board = new Board
    val ui = new UI(board)
    ui.visible = true
  }
}

Improvements

This was the last version of our TicTacToe game. Here are two suggestions for how you could improve the game:

Recognize end of game

Extend the program so that it can recognize when the game has ended, either because one player has three in a row, or because the board is full.

Add a method winner to the Board class that:

Use this method to check the board after every move (the best place would be in updateLabelAndBoard). If the game has ended, pop up a dialog with a message declaring the winner. After the user has closed the dialog, the game should restart automatically.

Computer player

Modify the game so that Player 2 is played by the computer. Every time the user makes a move, check if the game has ended because player 1 has three in a row or because the board is full. If so, display a dialog, after which the game restarts.

Otherwise, compute a move for player 2 and play the move. If the computer has won, display a dialog (after which the game restarts).

Keep a counter showing how often the human and the computer have won a game at the bottom of the screen (where it currently displays whose turn it is—we don't need that anymore).

To make it more interesting (and complicated), you could switch roles after each round. (So in round one the computer plays player 2, in round two the computer plays player 1, then again player 2, and so on.)