项目作者: lesnitsky

项目描述 :
An example tutorial built with git-tutor https://github.com/lesnitsky/git-tutor
高级语言: JavaScript
项目地址: git://github.com/lesnitsky/tic-tac-toe.git
创建时间: 2018-08-19T03:19:15Z
项目社区:https://github.com/lesnitsky/tic-tac-toe

开源协议:Other

下载


Tic Tac Toe

This tutorial will walk you through a process of creation of a tic-tac-toe game

Built with Git Tutor

Project setup

Before we actually start writing code, I recommend to install editorconfig plugin for your ide/text editor. It will keep code consistent in terms of line-endings style, indentation, newlines

📄 .editorconfig

  1. root = true
  2. [*]
  3. charset = utf-8
  4. indent_style = space
  5. indent_size = 4
  6. end_of_line = lf
  7. trim_trailing_whitespace = true
  8. insert_final_newline = true

Every web-app needs an html entry-point, this ain’t exception, so let’s add simple html file

📄 index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Tic Tac Toe</title>
  7. </head>
  8. <body>
  9. </body>
  10. </html>

index.js will be a js main file

📄 src/index.js

  1. console.log('Hello world');

Now we need to add script to index.html

📄 index.html

  1. <title>Tic Tac Toe</title>
  2. </head>
  3. <body>
  4. -
  5. + <script src="./src/index.js"></script>
  6. </body>
  7. </html>

Most likely the codebase will grow, so eventually we’ll need some module system. This tutorial is not about setting-up a javascript bundler like webpack, so let’s just use es6 modules which are already supported by latest Chrome. To make chrome understand import statement, type attribute should be set to module

📄 index.html

  1. <title>Tic Tac Toe</title>
  2. </head>
  3. <body>
  4. - <script src="./src/index.js"></script>
  5. + <script src="./src/index.js" type="module"></script>
  6. </body>
  7. </html>

Let’s get started

Game state

Let’s define a game state variable

📄 src/index.js

  1. - console.log('Hello world');
  2. + const GameState = {
  3. +
  4. + }

We’ll need an information about current player to know whether x or o should be placed on a game field.

📄 src/index.js

  1. const GameState = {
  2. -
  3. + currentPlayer: 0,
  4. }

0x should be placed

1o

field property will represent a game state.
That’s an array of 9 elements (3 columns x 3 rows) with initial value -1. Simple if (fieldValue > 0) check will work to distinguish empty fields from filled.

📄 src/index.js

  1. const GameState = {
  2. currentPlayer: 0,
  3. + field: Array.from({ length: 9 }).fill(-1),
  4. }

Game state modifications

Now we need to implement a function which will switch a current player. Let’s do this with XOR operator. (how xor works).

📄 src/index.js

  1. currentPlayer: 0,
  2. field: Array.from({ length: 9 }).fill(-1),
  3. }
  4. +
  5. + function changeCurrentPlayer(gameState) {
  6. + gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  7. + }

To modify field values in plain array we’ll need a function to convert row and col indices to an array index

📄 src/index.js

  1. function changeCurrentPlayer(gameState) {
  2. gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  3. }
  4. +
  5. + function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
  6. + return rowIndex * 3 + colIndex;
  7. + }

Game turn logic

Now we’ll start handling game turn logic.
Create a function placeholder

📄 src/index.js

  1. function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
  2. return rowIndex * 3 + colIndex;
  3. }
  4. +
  5. + function turn(gameState, rowIndex, colIndex) {
  6. +
  7. + }

Convert row and col indices to plain array index

📄 src/index.js

  1. }
  2. function turn(gameState, rowIndex, colIndex) {
  3. -
  4. + const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  5. }

If game field already contains some value, do nothing

📄 src/index.js

  1. function turn(gameState, rowIndex, colIndex) {
  2. const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  3. + const fieldValue = GameState.field[index];
  4. +
  5. + if (fieldValue >= 0) {
  6. + return;
  7. + }
  8. }

Put player identifier to a field

📄 src/index.js

  1. if (fieldValue >= 0) {
  2. return;
  3. }
  4. +
  5. + gameState.field[index] = gameState.currentPlayer;
  6. }

and change current player

📄 src/index.js

  1. }
  2. gameState.field[index] = gameState.currentPlayer;
  3. + changeCurrentPlayer(gameState);
  4. }

Win

The next thing we need to handle is a “win” state.
Lets add helper variables which will contain array indices by rows:

📄 src/index.js

  1. field: Array.from({ length: 9 }).fill(-1),
  2. }
  3. + const Rows = [
  4. + [0, 1, 2],
  5. + [3, 4, 5],
  6. + [6, 7, 8],
  7. + ];
  8. +
  9. function changeCurrentPlayer(gameState) {
  10. gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  11. }

cols:

📄 src/index.js

  1. [6, 7, 8],
  2. ];
  3. + const Cols = [
  4. + [0, 3, 6],
  5. + [1, 4, 7],
  6. + [6, 7, 8],
  7. + ];
  8. +
  9. function changeCurrentPlayer(gameState) {
  10. gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  11. }

and diagonals

📄 src/index.js

  1. [6, 7, 8],
  2. ];
  3. + const Diagonals = [
  4. + [0, 4, 8],
  5. + [2, 4, 6],
  6. + ];
  7. +
  8. function changeCurrentPlayer(gameState) {
  9. gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  10. }

Now let’s take a look at some examples of a “win” state

  1. 1 -1 0
  2. 0 1 -1
  3. -1 -1 1

Winner is 1. Sum of diagonal values equals 3

We can assume that we can detect a winner by getting a sum of each row, col and diagonal values and comparing it to a 0 (0 + 0 + 0) or 3 (1 + 1 + 1)

But here’s another example

  1. 0 -1 1
  2. 1 0 -1
  3. -1 -1 0

A sum of 1st and 2nd row = 0

Sum of both diagonals = 0

Sum of 1st and 3d cols = 0

That’s not the right way to go… 😞

💡 Easy fix!
Change initial value of field to -3 😎

📄 src/index.js

  1. const GameState = {
  2. currentPlayer: 0,
  3. - field: Array.from({ length: 9 }).fill(-1),
  4. + field: Array.from({ length: 9 }).fill(-3),
  5. }
  6. const Rows = [

Ok, now we are good. So let’s create a simple sum function

📄 src/index.js

  1. gameState.field[index] = gameState.currentPlayer;
  2. changeCurrentPlayer(gameState);
  3. }
  4. +
  5. + function sum(arr) {
  6. + return arr.reduce((a, b) => a + b, 0);
  7. + }

and a helper function which maps field indices to values

📄 src/index.js

  1. function sum(arr) {
  2. return arr.reduce((a, b) => a + b, 0);
  3. }
  4. +
  5. + function getValues(gameState, indices) {
  6. + return indices.map(index => gameState.field[index]);
  7. + }

function getWinner should find if some row, col or diagonal sum is 0 or 3. Let’s get values of all rows

📄 src/index.js

  1. function getValues(gameState, indices) {
  2. return indices.map(index => gameState.field[index]);
  3. }
  4. +
  5. + function getWinner(gameState) {
  6. + const rows = Rows.map((row) => getValues(gameState, row));
  7. + }

and do the same for cols and diagonals

📄 src/index.js

  1. function getWinner(gameState) {
  2. const rows = Rows.map((row) => getValues(gameState, row));
  3. + const cols = Cols.map((col) => getValues(gameState, col));
  4. + const diagonals = Diagonals.map((col) => getValues(gameState, col));
  5. }

now let’s create a single array of all values in field

📄 src/index.js

  1. const rows = Rows.map((row) => getValues(gameState, row));
  2. const cols = Cols.map((col) => getValues(gameState, col));
  3. const diagonals = Diagonals.map((col) => getValues(gameState, col));
  4. +
  5. + const values = [...rows, ...cols, ...diagonals];
  6. }

and find if some chunk sum equals 0 or 3

📄 src/index.js

  1. const diagonals = Diagonals.map((col) => getValues(gameState, col));
  2. const values = [...rows, ...cols, ...diagonals];
  3. +
  4. + let winner = -1;
  5. +
  6. + values.forEach((chunk) => {
  7. + const chunkSum = sum(chunk);
  8. +
  9. + if (chunkSum === 0) {
  10. + winner = 0;
  11. + return;
  12. + }
  13. +
  14. + if (chunkSum === 3) {
  15. + winner = 1;
  16. + return;
  17. + }
  18. + });
  19. +
  20. + return winner;
  21. }

Game loop

Now let’s describe a game loop.
We’ll create a generator function to query row and col for each next turn from outside world.
If you are not familliar with generator functions – read this medium post

📄 src/index.js

  1. return winner;
  2. }
  3. +
  4. + function* gameLoop(gameState) {
  5. +
  6. + }

Generator should execute until getWinner returns anything but -1.

📄 src/index.js

  1. }
  2. function* gameLoop(gameState) {
  3. + let winner = -1;
  4. +
  5. + while (winner < 0) {
  6. + winner = getWinner(gameState);
  7. + }
  8. }

it should also make a turn befor each getWinner call

📄 src/index.js

  1. let winner = -1;
  2. while (winner < 0) {
  3. + const [rowIndex, colIndex] = yield;
  4. + turn(gameState, rowIndex, colIndex);
  5. winner = getWinner(gameState);
  6. }

Now let’s test our gameLoop

Create a mock scenario of a game:

📄 src/index.js

  1. winner = getWinner(gameState);
  2. }
  3. }
  4. +
  5. + const turns = [
  6. + [1, 1],
  7. + [0, 1],
  8. + [0, 0],
  9. + [1, 2],
  10. + [2, 2],
  11. + ];

Create a game generator object

📄 src/index.js

  1. [1, 2],
  2. [2, 2],
  3. ];
  4. +
  5. + const game = gameLoop(GameState);
  6. + game.next();

Iterate over game turns and pass each turn to generator

📄 src/index.js

  1. const game = gameLoop(GameState);
  2. game.next();
  3. +
  4. + turns.forEach(turn => game.next(turn));

After execution of this scenario game generator should finish it execution.
This means that leading .next() call should return an object { value: undefined, done: true }

📄 src/index.js

  1. game.next();
  2. turns.forEach(turn => game.next(turn));
  3. +
  4. + console.log(game.next());

Let’s check it with node.js

  1. node src/index.js
  2. { value: undefined, done: true }

Yay, it works!

Refactor time

Now as a core of a game is ready let’s start refactor our index.js and split it in several modules

Drop testing code

📄 src/index.js

  1. }
  2. }
  3. - const turns = [
  4. - [1, 1],
  5. - [0, 1],
  6. - [0, 0],
  7. - [1, 2],
  8. - [2, 2],
  9. - ];
  10. -
  11. const game = gameLoop(GameState);
  12. game.next();
  13. -
  14. - turns.forEach(turn => game.next(turn));
  15. -
  16. - console.log(game.next());

Move everything but gameLoop from index.js to game-state.js.

📄 src/game-state.js

  1. const GameState = {
  2. currentPlayer: 0,
  3. field: Array.from({ length: 9 }).fill(-3),
  4. }
  5. const Rows = [
  6. [0, 1, 2],
  7. [3, 4, 5],
  8. [6, 7, 8],
  9. ];
  10. const Cols = [
  11. [0, 3, 6],
  12. [1, 4, 7],
  13. [6, 7, 8],
  14. ];
  15. const Diagonals = [
  16. [0, 4, 8],
  17. [2, 4, 6],
  18. ];
  19. function changeCurrentPlayer(gameState) {
  20. gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  21. }
  22. function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
  23. return rowIndex * 3 + colIndex;
  24. }
  25. function turn(gameState, rowIndex, colIndex) {
  26. const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  27. const fieldValue = gameState.field[index];
  28. if (fieldValue >= 0) {
  29. return;
  30. }
  31. gameState.field[index] = gameState.currentPlayer;
  32. changeCurrentPlayer(gameState);
  33. }
  34. function sum(arr) {
  35. return arr.reduce((a, b) => a + b, 0);
  36. }
  37. function getValues(gameState, indices) {
  38. return indices.map(index => gameState.field[index]);
  39. }
  40. function getWinner(gameState) {
  41. const rows = Rows.map((row) => getValues(gameState, row));
  42. const cols = Cols.map((col) => getValues(gameState, col));
  43. const diagonals = Diagonals.map((col) => getValues(gameState, col));
  44. const values = [...rows, ...cols, ...diagonals];
  45. let winner = -1;
  46. values.forEach((chunk) => {
  47. const chunkSum = sum(chunk);
  48. if (chunkSum === 0) {
  49. winner = 0;
  50. return;
  51. }
  52. if (chunkSum === 3) {
  53. winner = 1;
  54. return;
  55. }
  56. });
  57. return winner;
  58. }

📄 src/index.js

  1. - const GameState = {
  2. - currentPlayer: 0,
  3. - field: Array.from({ length: 9 }).fill(-3),
  4. - }
  5. -
  6. - const Rows = [
  7. - [0, 1, 2],
  8. - [3, 4, 5],
  9. - [6, 7, 8],
  10. - ];
  11. -
  12. - const Cols = [
  13. - [0, 3, 6],
  14. - [1, 4, 7],
  15. - [6, 7, 8],
  16. - ];
  17. -
  18. - const Diagonals = [
  19. - [0, 4, 8],
  20. - [2, 4, 6],
  21. - ];
  22. -
  23. - function changeCurrentPlayer(gameState) {
  24. - gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  25. - }
  26. -
  27. - function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
  28. - return rowIndex * 3 + colIndex;
  29. - }
  30. -
  31. - function turn(gameState, rowIndex, colIndex) {
  32. - const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  33. - const fieldValue = GameState.field[index];
  34. -
  35. - if (fieldValue >= 0) {
  36. - return;
  37. - }
  38. -
  39. - gameState.field[index] = gameState.currentPlayer;
  40. - changeCurrentPlayer(gameState);
  41. - }
  42. -
  43. - function sum(arr) {
  44. - return arr.reduce((a, b) => a + b, 0);
  45. - }
  46. -
  47. - function getValues(gameState, indices) {
  48. - return indices.map(index => gameState.field[index]);
  49. - }
  50. -
  51. - function getWinner(gameState) {
  52. - const rows = Rows.map((row) => getValues(gameState, row));
  53. - const cols = Cols.map((col) => getValues(gameState, col));
  54. - const diagonals = Diagonals.map((col) => getValues(gameState, col));
  55. -
  56. - const values = [...rows, ...cols, ...diagonals];
  57. -
  58. - let winner = -1;
  59. -
  60. - values.forEach((chunk) => {
  61. - const chunkSum = sum(chunk);
  62. -
  63. - if (chunkSum === 0) {
  64. - winner = 0;
  65. - return;
  66. - }
  67. -
  68. - if (chunkSum === 3) {
  69. - winner = 1;
  70. - return;
  71. - }
  72. - });
  73. -
  74. - return winner;
  75. - }
  76. -
  77. function* gameLoop(gameState) {
  78. let winner = -1;

Export everything gameLoop depends on

📄 src/game-state.js

  1. - const GameState = {
  2. + export const GameState = {
  3. currentPlayer: 0,
  4. field: Array.from({ length: 9 }).fill(-3),
  5. }
  6. return rowIndex * 3 + colIndex;
  7. }
  8. - function turn(gameState, rowIndex, colIndex) {
  9. + export function turn(gameState, rowIndex, colIndex) {
  10. const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  11. const fieldValue = gameState.field[index];
  12. return indices.map(index => gameState.field[index]);
  13. }
  14. - function getWinner(gameState) {
  15. + export function getWinner(gameState) {
  16. const rows = Rows.map((row) => getValues(gameState, row));
  17. const cols = Cols.map((col) => getValues(gameState, col));
  18. const diagonals = Diagonals.map((col) => getValues(gameState, col));

and import it in index.js

📄 src/index.js

  1. + import { GameState, getWinner, turn } from './game-state.js';
  2. +
  3. function* gameLoop(gameState) {
  4. let winner = -1;

Rendering game state on canvas

Add canvas to index.html

📄 index.html

  1. </head>
  2. <body>
  3. <script src="./src/index.js" type="module"></script>
  4. + <canvas></canvas>
  5. </body>
  6. </html>

and get a reference to canvas with querySelector

📄 src/index.js

  1. const game = gameLoop(GameState);
  2. game.next();
  3. +
  4. + const canvas = document.querySelector('canvas');

Let’s make body full-height

📄 index.html

  1. <meta charset="UTF-8">
  2. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  3. <title>Tic Tac Toe</title>
  4. + <style>
  5. + html, body {
  6. + height: 100%;
  7. + }
  8. + </style>
  9. </head>
  10. <body>
  11. <script src="./src/index.js" type="module"></script>

and reset default margins

📄 index.html

  1. html, body {
  2. height: 100%;
  3. }
  4. +
  5. + body {
  6. + margin: 0;
  7. + }
  8. </style>
  9. </head>
  10. <body>

Setup canvas size

📄 src/index.js

  1. game.next();
  2. const canvas = document.querySelector('canvas');
  3. +
  4. + const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  5. + canvas.width = size;
  6. + canvas.height = size;

and get a 2d context

📄 src/index.js

  1. const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  2. canvas.width = size;
  3. canvas.height = size;
  4. +
  5. + const ctx = canvas.getContext('2d');

Move canvas setup code to separate file

📄 src/canvas-setup.js

  1. export function setupCanvas() {
  2. const canvas = document.querySelector('canvas');
  3. const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  4. canvas.width = size;
  5. canvas.height = size;
  6. const ctx = canvas.getContext('2d');
  7. return { canvas, ctx };
  8. }

📄 src/index.js

  1. const game = gameLoop(GameState);
  2. game.next();
  3. -
  4. - const canvas = document.querySelector('canvas');
  5. -
  6. - const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  7. - canvas.width = size;
  8. - canvas.height = size;
  9. -
  10. - const ctx = canvas.getContext('2d');

and import it to index.js

📄 src/index.js

  1. import { GameState, getWinner, turn } from './game-state.js';
  2. + import { setupCanvas } from './canvas-setup.js';
  3. function* gameLoop(gameState) {
  4. let winner = -1;
  5. const game = gameLoop(GameState);
  6. game.next();
  7. +
  8. + const { canvas, ctx } = setupCanvas();

Now let’s create render function which will visualize the game state

📄 src/renderer.js

  1. /**
  2. * @typedef GameState
  3. * @property {Number} currentPlayer
  4. * @property {Array<number>} field
  5. *
  6. * @param {HTMLCanvasElement} canvas
  7. * @param {CanvasRenderingContext2D} ctx
  8. * @param {GameState} gameState
  9. */
  10. export function draw(canvas, ctx, gameState) {
  11. }

We’ll need to clear the whole canvas on each render call

📄 src/renderer.js

  1. * @param {GameState} gameState
  2. */
  3. export function draw(canvas, ctx, gameState) {
  4. -
  5. + ctx.clearRect(0, 0, canvas.width, canvas.height);
  6. }

We’ll render each cell with strokeRect, so let’s setup cellSize (width and height of each game field cell) and lineWidth (border width of each cell)

📄 src/renderer.js

  1. */
  2. export function draw(canvas, ctx, gameState) {
  3. ctx.clearRect(0, 0, canvas.width, canvas.height);
  4. +
  5. + ctx.lineWidth = 10;
  6. + const cellSize = canvas.width / 3;
  7. +
  8. }

And finally we rendered smth! 🎉

📄 src/renderer.js

  1. ctx.lineWidth = 10;
  2. const cellSize = canvas.width / 3;
  3. + gameState.field.forEach((_, index) => {
  4. + const top = Math.floor(index / 3) * cellSize;
  5. + const left = index % 3 * cellSize;
  6. +
  7. + ctx.strokeRect(top, left, cellSize, cellSize);
  8. + });
  9. }

To see the result install live-server

  1. npm i -g live-server
  2. live-server .

Wait, what? Nothing rendered 😢
That’s because we forgot to import and call draw function

📄 src/index.js

  1. import { GameState, getWinner, turn } from './game-state.js';
  2. import { setupCanvas } from './canvas-setup.js';
  3. + import { draw } from './renderer.js';
  4. function* gameLoop(gameState) {
  5. let winner = -1;
  6. game.next();
  7. const { canvas, ctx } = setupCanvas();
  8. + draw(canvas, ctx, GameState);

Let’s make canvas a bit smaller to leave some space for other UI

📄 src/canvas-setup.js

  1. export function setupCanvas() {
  2. const canvas = document.querySelector('canvas');
  3. - const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  4. + const size = Math.min(document.body.offsetHeight, document.body.offsetWidth) * 0.8;
  5. canvas.width = size;
  6. canvas.height = size;

and add a css border to make all cell edges look the same

📄 index.html

  1. body {
  2. margin: 0;
  3. }
  4. +
  5. + canvas {
  6. + border: 5px solid black;
  7. + }
  8. </style>
  9. </head>
  10. <body>

It also looks weird in top-left corner, so align canvas to center with flex-box

📄 index.html

  1. <style>
  2. html, body {
  3. height: 100%;
  4. + display: flex;
  5. + align-items: center;
  6. + justify-content: center;
  7. }
  8. body {

So, we’ve rendered game field cells.
Now let’s render X and O symbols

📄 src/renderer.js

  1. ctx.strokeRect(top, left, cellSize, cellSize);
  2. });
  3. }
  4. +
  5. + /**
  6. + * @param {CanvasRenderingContext2D} ctx
  7. + */
  8. + function drawX(ctx, top, left, size) {
  9. +
  10. + }

We’ll use path to render symbol both for X and O

📄 src/renderer.js

  1. * @param {CanvasRenderingContext2D} ctx
  2. */
  3. function drawX(ctx, top, left, size) {
  4. + ctx.beginPath();
  5. +
  6. + ctx.closePath();
  7. + ctx.stroke();
  8. }

Draw a line from top-left to bottom-right

📄 src/renderer.js

  1. function drawX(ctx, top, left, size) {
  2. ctx.beginPath();
  3. + ctx.moveTo(left, top);
  4. + ctx.lineTo(left + size, top + size);
  5. +
  6. ctx.closePath();
  7. ctx.stroke();

Draw a line from top-right to bottom-left

📄 src/renderer.js

  1. ctx.moveTo(left, top);
  2. ctx.lineTo(left + size, top + size);
  3. + ctx.moveTo(left + size, top);
  4. + ctx.lineTo(left, top + size);
  5. +
  6. ctx.closePath();
  7. ctx.stroke();

Rendering O is even more simple

📄 src/renderer.js

  1. ctx.closePath();
  2. ctx.stroke();
  3. + }
  4. + /**
  5. + * @param {CanvasRenderingContext2D} ctx
  6. + */
  7. + function drawO(ctx, centerX, centerY, radius) {
  8. + ctx.beginPath();
  9. +
  10. + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
  11. + ctx.closePath();
  12. +
  13. + ctx.stroke();
  14. }

And let’s actually render X or O depending on a field value

📄 src/renderer.js

  1. ctx.lineWidth = 10;
  2. const cellSize = canvas.width / 3;
  3. - gameState.field.forEach((_, index) => {
  4. + gameState.field.forEach((value, index) => {
  5. const top = Math.floor(index / 3) * cellSize;
  6. const left = index % 3 * cellSize;
  7. ctx.strokeRect(top, left, cellSize, cellSize);
  8. +
  9. + if (value < 0) {
  10. + return;
  11. + }
  12. +
  13. + if (value === 0) {
  14. + drawX(ctx, top, left, cellSize);
  15. + } else {
  16. + drawO(ctx, left + cellSize / 2, top + cellSize / 2, cellSize / 2);
  17. + }
  18. });
  19. }

Nothing rendered? That’s correct, every field value is -2, so let’s make some turns

📄 src/index.js

  1. game.next();
  2. const { canvas, ctx } = setupCanvas();
  3. +
  4. + turn(GameState, 0, 1);
  5. + turn(GameState, 1, 1);
  6. + turn(GameState, 2, 0);
  7. +
  8. draw(canvas, ctx, GameState);

📄 src/renderer.js

  1. }
  2. if (value === 0) {
  3. - drawX(ctx, top, left, cellSize);
  4. + const margin = cellSize * 0.2;
  5. + const size = cellSize * 0.6;
  6. +
  7. + drawX(ctx, top + margin, left + margin, size);
  8. } else {
  9. - drawO(ctx, left + cellSize / 2, top + cellSize / 2, cellSize / 2);
  10. + const radius = cellSize * 0.3;
  11. + drawO(ctx, left + cellSize / 2, top + cellSize / 2, radius);
  12. }
  13. });
  14. }

Interactions

Everything seems to be done, the only thing left – interactions.
Let’s start with cleanup:

📄 src/index.js

  1. const { canvas, ctx } = setupCanvas();
  2. - turn(GameState, 0, 1);
  3. - turn(GameState, 1, 1);
  4. - turn(GameState, 2, 0);
  5. -
  6. draw(canvas, ctx, GameState);

Add click listener and calculate clicked row and col

📄 src/index.js

  1. const { canvas, ctx } = setupCanvas();
  2. draw(canvas, ctx, GameState);
  3. +
  4. + canvas.addEventListener('click', ({ layerX, layerY }) => {
  5. + const row = Math.floor(layerY / canvas.height * 100 / 33);
  6. + const col = Math.floor(layerX / canvas.width * 100 / 33);
  7. + });

Pass row and col indices to game loop generator

📄 src/index.js

  1. canvas.addEventListener('click', ({ layerX, layerY }) => {
  2. const row = Math.floor(layerY / canvas.height * 100 / 33);
  3. const col = Math.floor(layerX / canvas.width * 100 / 33);
  4. +
  5. + game.next([row, col]);
  6. });

and reflect game state changes on canvas

📄 src/index.js

  1. const col = Math.floor(layerX / canvas.width * 100 / 33);
  2. game.next([row, col]);
  3. + draw(canvas, ctx, GameState);
  4. });

Now let’s congratulate a winner

📄 src/index.js

  1. winner = getWinner(gameState);
  2. }
  3. +
  4. + setTimeout(() => {
  5. + alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
  6. + });
  7. }
  8. const game = gameLoop(GameState);

Oh, we forgot to handle a draw! No worries. Let’s add isGameFinished helper:

📄 src/game-state.js

  1. return winner;
  2. }
  3. +
  4. + export function isGameFinished(gameState) {
  5. + return gameState.field.every(f => f >= 0);
  6. + }

and call it on each iteration of a game loop

📄 src/index.js

  1. - import { GameState, getWinner, turn } from './game-state.js';
  2. + import { GameState, getWinner, turn, isGameFinished } from './game-state.js';
  3. import { setupCanvas } from './canvas-setup.js';
  4. import { draw } from './renderer.js';
  5. function* gameLoop(gameState) {
  6. let winner = -1;
  7. - while (winner < 0) {
  8. + while (winner < 0 && !isGameFinished(gameState)) {
  9. const [rowIndex, colIndex] = yield;
  10. turn(gameState, rowIndex, colIndex);
  11. }
  12. setTimeout(() => {
  13. - alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
  14. + if (winner < 0) {
  15. + alert(`It's a draw`);
  16. + } else {
  17. + alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
  18. + }
  19. });
  20. }

LICENSE

WTFPL