Interlude: Tic Tac Toe
At this point if you have been reading sequentially through these chapters you know enough to put together a simple tic-tac-toe game.
The board is represented by an array of 9 cells. Cell 0 is the top left square, and cell 8 is the bottom right.
Each cell will contain nil, an
:X
or an :O
.The
DisplayBoard
component displays a board. DisplayBoard
accepts a board
param, and will fire back a clicked_at
event when the user clicks one of the squares.A small helper function
draw_squares
draws an individual square which is displayed as a BUTTON
. A click handler is attached which will fire the clicked_at
event with the appropriate cell id.Notice that
DisplayBoard
has no internal state of its own. That is handled by the DisplayGame
component.class DisplayBoard < HyperComponent
param :board
fires :clicked_at
def draw_square(id)
BUTTON(class: :square, id: id) { board[id] }
.on(:click) { clicked_at!(id) }
end
render(DIV) do
(0..6).step(3) do |row|
DIV(class: :board_row) do
(row..row + 2).each { |id| draw_square(id) }
end
end
end
end
The
DisplayGame
component has two state variables:@history
which is an array of boards, each board being the array of cells.@step
which is the current step in the history (we begin at zero)
@step
and @history
allows the player to move backwards or forwards and replay parts of the game.These are initialized in the
before_mount
callback. Because Ruby will adjust the array size as needed and return nil if an array value is not initialized, we can simply initialize the board to an empty array.There are three reader methods that read the state:
player
returns the current player's token. The first player is always:X
so even stepsare:X
, and odd steps are:O
.current
returns the board at the current step.history
uses state_reader to encapsulate the history state.
Encapsulated access to state in reader methods like this is not necessary but is good practice
class DisplayGame < HyperComponent
before_mount do
@history = [[]]
@step = 0
end
def player
@step.even? ? :X : :O
end
def current
@history[@step]
end
state_reader :history
end
We also have a
current_winner?
method that will return the winning player or nil based on the value of the current board:class DisplayGame < HyperComponent
WINNING_COMBOS = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
]
def current_winner?
WINNING_COMBOS.each do |a, b, c|
return current[a] if current[a] && current[a] == current[b] && current[a] == current[c]
end
false
end
end
There are two mutator methods that change state:
handle_click!
is called with the id of the square when a user clicks on a square.jump_to!
moves the user back and forth through the history.
The
handle_click!
mutator first checks to make sure that no one has already won at the current step, and that no one has played in the cell that the user clicked on. If either of these conditions is true handle_click!
returns, no mutation is signaled and nothing changes.If we had wanted to return AND signal a state mutation we would use the Rubynext
keyword instead ofreturn
.s
To update the board
handle_click!
duplicates the squares; adds the player's token to the cell; makes a new history with the new squares on the end, and finally updates the value of @step
.We like to use the convention where practical of ending mutator methods with a bang (!) so that readers of the code are aware that these will change state.
class DisplayGame < HyperComponent
mutator :handle_click! do |id|
board = history[@step]
return if current_winner? || board[id]
board = board.dup
board[id] = player
@history = history[0..@step] + [board]
@step += 1
end
mutator(:jump_to!) { |step| @step = step }
end
Now we have a couple of helper methods to build parts of the game display.
moves
creates the list items that allow the user to move back and forth through the history.status
provides the play state
class DisplayGame < HyperComponent
def moves
return unless history.length > 1
history.length.times do |move|
LI(key: move) { move.zero? ? "Go to game start" : "Go to move ##{move}" }
.on(:click) { jump_to!(move) }
end
end
def status
if (winner = current_winner?)
"Winner: #{winner}"
else
"Next player: #{player}"
end
end
end
And finally our render method which displays the Board and the game info:
class DisplayGame < HyperComponent
render(DIV, class: :game) do
DIV(class: :game_board) do
DisplayBoard(board: current)
.on(:clicked_at, &method(:handle_click!))
end
DIV(class: :game_info) do
DIV { status }
OL { moves }
end
end
end
&method
turns an instance method into a Proc rather than having to say{ |id| handle_click(id) }
This small game uses everything covered in the previous sections: HTML Tags, Component Classes, Params, Events and Callbacks, and State. The project was derived from this React tutorial: https://reactjs.org/tutorial/tutorial.html. You may want to compare our Ruby code with the React original.
The following sections cover reference materials, and some advanced information. You may want to skip to the HyperState section which will use this example to show how state can be encapsulated, extracted and shared resulting in easier to understand and maintain code.
Last modified 2yr ago