|
| 1 | +At this point if you have been reading sequentially through these chapters you know enough to put together a simple tic-tac-toe game. |
| 2 | + |
| 3 | +### The Game Board |
| 4 | + |
| 5 | +The board is represented by an array of 9 cells. Cell 0 is the top left square, and cell 8 is the bottom right. |
| 6 | + |
| 7 | +Each cell will contain nil, an :X or an :O. |
| 8 | + |
| 9 | +### Displaying the Board |
| 10 | + |
| 11 | +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. |
| 12 | + |
| 13 | +A small helper function `draw_squares` draws an individual square which is displayed as a `BUTTON`. A click handler is attached which |
| 14 | +will fire the `clicked_at` event with the appropriate cell id. |
| 15 | + |
| 16 | +Notice that `DisplayBoard` has no internal state of its own. That is handled by the `Game` component. |
| 17 | + |
| 18 | +```ruby |
| 19 | +class DisplayBoard < HyperComponent |
| 20 | + param :board |
| 21 | + fires :clicked_at |
| 22 | + |
| 23 | + def draw_square(id) |
| 24 | + BUTTON(class: :square, id: id) { board[id] } |
| 25 | + .on(:click) { clicked_at!(id) } |
| 26 | + end |
| 27 | + |
| 28 | + render(DIV) do |
| 29 | + (0..6).step(3) do |row| |
| 30 | + DIV(class: :board_row) do |
| 31 | + (row..row + 2).each { |id| draw_square(id) } |
| 32 | + end |
| 33 | + end |
| 34 | + end |
| 35 | +end |
| 36 | +``` |
| 37 | + |
| 38 | +### The Game State |
| 39 | + |
| 40 | +The `Game` component has two state variables: |
| 41 | ++ `@history` which is an array of boards, each board being the array of cells. |
| 42 | ++ `@step` which is the current step in the history (we begin at zero) |
| 43 | + |
| 44 | +`@step` and `@history` allows the player to move backwards or forwards and replay parts of the game. |
| 45 | + |
| 46 | +These are initialized in the `before_mount` callback. Because Ruby will adjust the array size as needed |
| 47 | +and return nil if an array value is not initialized, we can simply initialize the board to an empty array. |
| 48 | + |
| 49 | +There are two reader methods that read the state: |
| 50 | + |
| 51 | ++ `player` returns the current player's token. The first player is always :X so even steps |
| 52 | +are :X, and odd steps are :O. |
| 53 | ++ `current` returns the board at the current step. |
| 54 | ++ `history` uses state_reader to encapsulate the history state. |
| 55 | + |
| 56 | +Encapsulated access to state in reader methods like this is not necessary but is good practice |
| 57 | + |
| 58 | +```ruby |
| 59 | +class Game < HyperComponent |
| 60 | + before_mount do |
| 61 | + @history = [[]] |
| 62 | + @step = 0 |
| 63 | + end |
| 64 | + |
| 65 | + def player |
| 66 | + @step.even? ? :X : :O |
| 67 | + end |
| 68 | + |
| 69 | + def current |
| 70 | + @history[@step] |
| 71 | + end |
| 72 | + |
| 73 | + state_reader :history |
| 74 | +end |
| 75 | +``` |
| 76 | + |
| 77 | +### Calculating the Winner Based on the Game State |
| 78 | + |
| 79 | +We also have a `current_winner?` method that will return the winning player or nil based on the value of the current board: |
| 80 | + |
| 81 | +```ruby |
| 82 | +class Game < HyperComponent |
| 83 | + WINNING_COMBOS = [ |
| 84 | + [0, 1, 2], |
| 85 | + [3, 4, 5], |
| 86 | + [6, 7, 8], |
| 87 | + [0, 3, 6], |
| 88 | + [1, 4, 7], |
| 89 | + [2, 5, 8], |
| 90 | + [0, 4, 8], |
| 91 | + [2, 4, 6] |
| 92 | + ] |
| 93 | + |
| 94 | + def current_winner? |
| 95 | + WINNING_COMBOS.each do |a, b, c| |
| 96 | + return current[a] if current[a] && |
| 97 | + current[a] == current[b] && |
| 98 | + current[a] == current[c] |
| 99 | + end |
| 100 | + false |
| 101 | + end |
| 102 | +end |
| 103 | +``` |
| 104 | + |
| 105 | +### Mutating the Game State |
| 106 | + |
| 107 | +There are two mutator methods that change state: |
| 108 | ++ `handle_click!` is called with the id of the square when a user clicks on a square. |
| 109 | ++ `jump_to!` moves the user back and forth through the history. |
| 110 | + |
| 111 | +The `handle_click!` mutator first checks to make sure that no one has already won at the current step, and that |
| 112 | +no one has played in the cell that the user clicked on. If either of these conditions is true `handle_click!` |
| 113 | +returns and nothing changes. |
| 114 | + |
| 115 | +To update the board `handle_click!` duplicates the squares; adds the player's token to the cell; makes a new |
| 116 | +history with the new squares on the end, and finally updates the value of `@step`. |
| 117 | + |
| 118 | +> We like to use the convention where practical of ending mutator methods with a bang (!) so that readers of the |
| 119 | +code are aware that these will change state. |
| 120 | + |
| 121 | +```ruby |
| 122 | + |
| 123 | +class Game < HyperComponent |
| 124 | + mutator :handle_click! do |id| |
| 125 | + board = history[@step] |
| 126 | + return if current_winner? || board[id] |
| 127 | + |
| 128 | + board = board.dup |
| 129 | + board[id] = player |
| 130 | + @history = history[0..@step] + [board] |
| 131 | + @step += 1 |
| 132 | + end |
| 133 | + |
| 134 | + mutator(:jump_to!) { |step| @step = step } |
| 135 | +end |
| 136 | +``` |
| 137 | + |
| 138 | +### The Game Display |
| 139 | + |
| 140 | +Now we have a couple of helper methods to build parts of the game display. |
| 141 | + |
| 142 | ++ `moves` creates the list items that allow the user to move back and forth through the history. |
| 143 | ++ `status` provides the play state |
| 144 | + |
| 145 | +```ruby |
| 146 | +class Game < HyperComponent |
| 147 | + def moves |
| 148 | + return unless history.length > 1 |
| 149 | + |
| 150 | + history.length.times do |move| |
| 151 | + LI(key: move) { move.zero? ? "Go to game start" : "Go to move ##{move}" } |
| 152 | + .on(:click) { jump_to!(move) } |
| 153 | + end |
| 154 | + end |
| 155 | + |
| 156 | + def status |
| 157 | + if (winner = current_winner?) |
| 158 | + "Winner: #{winner}" |
| 159 | + else |
| 160 | + "Next player: #{player}" |
| 161 | + end |
| 162 | + end |
| 163 | +end |
| 164 | +``` |
| 165 | + |
| 166 | +And finally our render method which displays the Board and the game info: |
| 167 | + |
| 168 | +```ruby |
| 169 | +class Game < HyperComponent |
| 170 | + render(DIV, class: :game) do |
| 171 | + DIV(class: :game_board) do |
| 172 | + DisplayBoard(board: current) |
| 173 | + .on(:clicked_at, &method(:handle_click!)) |
| 174 | + end |
| 175 | + DIV(class: :game_info) do |
| 176 | + DIV { status } |
| 177 | + OL { moves } |
| 178 | + end |
| 179 | + end |
| 180 | +end |
| 181 | +``` |
| 182 | + |
| 183 | +> `&method` turns an instance method into a Proc rather than having to say `{ |id| handle_click(id) }` |
| 184 | +
|
| 185 | +### Summary |
| 186 | + |
| 187 | +This small game uses everything covered in the previous sections: HTML Tags, Component Classes, Params, Events and Callbacks, and State. |
| 188 | +The project was derived from this React tutorial: https://reactjs.org/tutorial/tutorial.html. |
| 189 | +You may want to compare our Ruby code with the React original. |
| 190 | + |
| 191 | + |
| 192 | +The following sections cover reference materials, and some advanced information. You may want to skip to the HyperState section which |
| 193 | +will use this example to show how state can be encapsulated, extracted and shared resulting in easier to understand and maintain code. |
0 commit comments