This post is the fifth in the series of posts focused on the design and implementation of a port in ClojureScript of the game named Tribolo. You can try the game at the following address.
Our first post described the game and identified the following responsibilities, each of them will be developed in a different post:
- Game logic: the rules governing transitions between game states
- Rendering: transforming the current game state into HTML and SVG
- Store: the in-memory data-base holding the application state
- Game loop: coordinates and control of the different aspects
- AI logic: selection of the best next move for an AI player
Among these different parts, we already implemented the game logic. The goal of today is to implement the next one, the rendering.
Reminder on the target architecture
Our different responsibilities are interconnected through the following high level architecture (you can refer to our first post for more details):
Store ----------------> Rendering ---------------> Reagent ^ (reaction) ^ (send hiccup) | | | | (listen) | (update) | \-------------------- Game loop | | ,-------------------------| (use to update store) | | v v Game logic <-------------- AI logic
The rendering consumes the application state referred as the store and will be refreshed whenever these data change. Identifying the inputs needed for the rendering will later help us design our application state.
Overview
Before diving into the implementation, we have to discuss which data we want to display in the user interface. We also have to identify and list all the interactions we want to offer to our user.
Inputs of the Rendering
A game is a succession of turns. Each turn represents a valid state of the game. It contains the status of the board, the scores and the next player to play.
The turn is the main piece of data we want to display on the screen. The scores will end up on the top menu of the game, while the board model directly fuels the display of the board view showing the ownership status of each cell.
A transition is an edge between a turn and a turn accessible through one move of the player or an AI bot. Each transition is associated a coordinate. Playing at this coordinate triggers the transition.
Our Tribolo game can offer move suggestions to the human player. The transitions associated to a turn are the natural input data to use to identify and display these suggestions.
User interactions
The user plays a move by clicking on a cell of the board. Doing so translates into a event to play at the coordinate associated to the cell.
Playing a move is by far the most important interaction the user has with the game. But the user has additional actions available as well through the top menu:
- Restarting a new game
- Toggle the suggestions feature
- Restarting the same game
- Going back to our last move
The game loop will be responsible to update the application state by listening to the events associated to each of these actions. To keep our rendering decoupled from the game loop, we define a protocol to hold these actions:
(defprotocol IUserInteractions | |
"A protocol for the interactions that can be triggered from the GUI" | |
(on-new-game [this] "Send a new game command") | |
(on-toogle-help [this] "Send a toggle help command") | |
(on-restart [this] "Send a restart command") | |
(on-undo [this] "Send an undo command") | |
(on-player-move [this x y] "Send a play command at [x y]")) |
Main frame
The goal of the main frame is to assemble the different parts of our user interface: the top menu and the board. The implementation follows from this description and simply delegates the data to the appropriate sub-components:
(defn main-frame | |
[turn suggestions interactions] | |
[:div.game-panel | |
[menu/show-top-menu (:scores turn) (:player turn) interactions] | |
[board/render-board (:board turn) suggestions interactions]]) |
To check that the data sent to the user interface is well-formed, we can define a spec for our main-frame rendering function.
(s/fdef main-frame | |
:args (s/cat | |
:turn ::turn/turn | |
:helps (s/fspec :args (s/cat :coord ::board-model/coord)) | |
:interactions #(satisfies? IUserInteractions %))) |
Since main-frame is the sole entry point of the rendering, this spec will also implicitly check the rendering of all sub-components as well. This is the only spec our entire user interface will need. It will allow to catch errors very early in the rendering process.
Note: You can find some other example of usage of spec in the post where we implemented the game logic. We also discussed spec in our previous article.
Top Menu
Our top menu will be responsible for the rendering of the scores. It will also render a bunch of buttons to trigger the following actions:
- Restarting a new game
- Toggle the suggestions feature
- Restarting the same game
- Going back to our last move
The code pretty much follows our description. The main goal of show-top-menu is to delegate the appropriate call-back, plug the appropriate call-backs for each button, and wrap the whole inside div that provides the appropriate styling:
This code makes uses the following helper function to render a button:
The rendering of the score is delegated to the show-scores function. We delegate to the player->css-style function the selection of the style of each score. The goal is to highlight the score of the current player.
(defn- player->css-style | |
[player highlight?] | |
(let [player-class (str "score--" (name player)) | |
score-class (if (highlight? player) "score--is-current" "score")] | |
(str player-class " " score-class))) | |
(defn- player->score-text | |
[player score] | |
(str (str/capitalize (name player)) " - " score)) | |
(defn- show-scores | |
[scores highlight?] | |
(for [player player/all :let [score (get scores player)]] | |
^{:key player} | |
[:div | |
{:class (player->css-style player highlight?)} | |
(player->score-text player score)])) |
Board
Rendering the board of the game consists in creating a SVG panel and filling it with the SVG representation of each cells.
There are five possible owners for cells in Tribolo: the three players, the walls, and the fifth possibility is that the cell is empty. If the cell is empty, the player can play at the coordinate of this cell, and otherwise cannot. As a consequence, the suggestions only concern the empty cells as well.
This main distinction is visible through the implementation of the render-board function. It delegates the rendering of the empty cells to empty-cell, while the other cells are rendered using rect-cell:
(defn render-board | |
[board suggestions interactions] | |
(into | |
(empty-svg-board) | |
(for [[position cell] (board/to-seq board)] | |
^{:key position} | |
(if (= :none cell) | |
[empty-cell position interactions (if (suggestions position) :help :none)] | |
[rect-cell position cell])))) |
The rendering of the cells is pretty simple. The empty-cell is only a rect-cell to which we add the appropriate callback to play at the coordinate associated to the cell.
The rect-cell renders a rectangle in SVG and chooses the appropriate styling. The next section will demonstrate why we choose to base the rendering on CSS style rather than directly picking a color.
(def relative-size 0.9) | |
(def border-size (/ (- 1 relative-size) 2)) | |
(defn- rect-cell | |
[[x y] player options] | |
[:rect.cell | |
(merge | |
{:class (str "cell--" (name player)) | |
😡 (+ border-size x) :width relative-size | |
:y (+ border-size y) :height relative-size} | |
options)]) | |
(defn- empty-cell | |
[[x y :as position] interactions player] | |
(rect-cell position player | |
{:on-click #(i/on-player-move interactions x y)})) |
Animations
If you tried playing the game, you will have noticed that there is a small transition time for the ownership of a cell to change. This small animation allows to highlight the moves of the player and the artificial intelligence bots.
Because these animations stay pretty simple, they are directly implemented using CSS styles. We only have to add the transition-duration attribute on our cells style.
.cell { | |
transition-duration: 0.75s; | |
... | |
} |
We want to avoid the transition effect to occur when creating a new game. To do so, we remove the animation for the empty cells. And to better highlight the suggestion feature, we will tweak the transitions of the help cells as well. It results in the following CSS styles:
.cell--blue { | |
fill: blue; | |
} | |
.cell--red { | |
fill: #e80000; | |
} | |
.cell--green { | |
fill: green; | |
} | |
.cell--none { | |
fill: lightgray; | |
transition-duration: 0s; | |
} | |
.cell--wall { | |
fill: #646464; | |
} | |
.cell--help { | |
fill: blue; | |
fill-opacity: 0.25; | |
} | |
.cell--help { | |
transition-duration: 1s; | |
} |
Conclusion and what’s next
This is it, the whole rendering of the game. Thanks to the use of Reagent, the code is quite short and was really easy to write as well.
Now that we know what the user interface needs as input, we can design our application state accordingly and start thinking about the game loop as well. Both these topics will be the focus of the next post.