Tic Tac Toe, часть 0: Сравнение Svelte и React
Tic Tac Toe, часть 1: Svelte и Canvas 2D
Tic Tac Toe, часть 2: Undo/Redo с хранением состояний
Tic Tac Toe, часть 3: Undo/Redo с хранением команд
Tic Tac Toe, часть 4: Взаимодействие с бэкендом на Flask с помощью HTTP
В статье "Сравнение: Svelte и React" я попробовал повторить разработку игры Tic Tac Toe. Там я выполнил только первую часть исходного туториала для React'а без поддержки истории ходов. В этой статье мы начнем разработку этой игры с применением фреймворка Svelte с поддержкой истории ходов. История ходов на самом деле представляет собой систему Undo/Redo. В исходном туториале на React'e реализована система Undo/Redo с хранением состояний с произвольным доступом к любому состоянию. При реализации системы Undo/Redo обычно используется паттерн Command, и в списке команд хранятся команды Undo/Redo. Такой подход мы попробуем реализовать позже, сейчас выполним систему Undo/Redo с хранением состояний.
В разработке применено архитектурное решение Flux с использованием хранилища. Об этом отдельный раздел в статье.
Стартовый код
<script>
import Board from './Board.svelte';
</script>
<div class="game">
<div class="game-board">
<Board />
</div>
<div class="game-info">
<div class="status">Next player: X</div>
<div></div>
<ol></ol>
</div>
</div>
<style>
.game {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.status {
margin-bottom: 10px;
}
ol {
padding-left: 30px;
}
</style>
<script>
import { onMount } from 'svelte';
export let width = 3;
export let height = 3;
export let cellWidth = 34;
export let cellHeight = 34;
export let colorStroke = "#999";
let boardWidth = 1 + (width * cellWidth);
let boardHeight = 1 + (height * cellHeight);
let canvas;
onMount(() => {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, boardWidth, boardHeight);
ctx.beginPath();
// vertical lines
for (let x = 0; x <= boardWidth; x += cellWidth) {
ctx.moveTo(0.5 + x, 0);
ctx.lineTo(0.5 + x, boardHeight);
}
// horizontal lines
for (let y = 0; y <= boardHeight; y += cellHeight) {
ctx.moveTo(0, 0.5 + y);
ctx.lineTo(boardWidth, 0.5 + y);
}
// draw the board
ctx.strokeStyle = colorStroke;
ctx.stroke();
ctx.closePath();
});
</script>
<canvas
bind:this={canvas}
width={boardWidth}
height={boardHeight}
></canvas>
Стартовый код выводит пустую сетку. Она выводится с помощью элемента HTML5 canvas. Более подробно об использовании этого элемента рассказано в предыдущей статье Разработка игры Breakout на Svelte. Как рисовать сетку подсмотрел здесь. Компонент Board можно использовать повторно в других играх. Изменив переменные width и height можно изменить размер сетки, изменив значения переменных cellWidth и cellHeight можно изменить размер клетки.
Заполняем клетки нулями
Код на REPL
В функцию onMount() добавил вывод нулей в клетках после вывода сетки. И немного магических чисел связанных с позиционированием значений в клетках.
ctx.beginPath();
ctx.font = "bold 22px Century Gothic";
let d = 8;
for (let i = 0; i < height; i+=1) {
for (let j = 0; j < width; j+=1) {
ctx.fillText("O", j * cellWidth + d + 1, (i + 1) * cellHeight - d);
}
}
ctx.closePath();
Добавляем хранилище state
Код на REPL
В этом разделе проверяем изменение состояний с применением пользовательского хранилища. Добавил хранилище state в отдельном файле stores.js, это хранилище импортируется в оба компонента: App и Board. В этом хранилище определены методы state1 и state2 изменяющие состояние игры.
import { writable } from 'svelte/store';
function createState() {
const { subscribe, set, update } = writable(Array(9).fill('O'));
return {
subscribe,
state1: () => set(Array(9).fill('1')),
state2: () => set(Array(9).fill('2')),
};
}
export const state = createState();
В компонент App добавил две кнопки State 1 и State 2. Нажимая на кнопки вызываем соответствующие методы в хранилище.
<button on:click={state.state1}>State 1</button>
<button on:click={state.state2}>State 2</button>
В компоненте Board поменял строчку вывода нулей вывод данными их хранилища state. Здесь мы используем автоподписку на хранилище.
ctx.fillText($state[k], j * cellWidth + d + 1, (i + 1) * cellHeight - d);
На этом этапе игровое поле по-умолчанию заполняется нулями, нажимаем на кнопку State 1 — поле заполняется единицами, нажимаем на кпопку State 2 — поле заполняется двойками.
Заполнение клетки крестиком по клику мышкой
Код на REPL
В хранилище state добавил метод setCell(), который заполняет выбранную клетку крестиком.
setCell: (i) => update(a => {a[i] = 'X'; return a;}),
К canvas'у добавил обработчик события по клику мышкой, здесь определяем индекс клетки и вызываем метод setCell() хранилища state.
function handleClick(event) {
let x = Math.trunc((event.offsetX + 0.5) / cellWidth);
let y = Math.trunc((event.offsetY + 0.5) / cellHeight);
let i = y * width + x;
state.setCell(i);
}
Игровое поле по-умолчанию заполняется нулями, выполняем клик по любой клетке, нолик заменяется крестиком.
История ходов
Код на REPL
Напомню, что мы сейчас выполняем систему Undo/Redo с хранением состояний с произвольным доступом.
import { writable } from 'svelte/store';
class History {
constructor() {
this.history = new Array;
this.current = -1;
}
currentState() {
return this.history[this.current];
}
push(state) {
// TODO: remove all redo states
this.current++;
this.history.push(state);
}
}
function createHistory() {
const { subscribe, set, update } = writable(new History);
return {
subscribe,
push: (state) => update(h => { h.push(state); return h; }),
};
}
export const history = createHistory();
Хранилище state удалено, добавлено хранилище history для хранения истории ходов. Описываем его с помощью клаcса History. Для хранения состояний используем массив history. Иногда при реализации системы Undo/Redo используется два LIFO стека: undo-стек и redo-стек. Мы используем один массив history для хранения состояний в History. Свойство current используется для определения текущего состояния игры. Все состояния в history с начала массива до состояния с индексом current, можно так сказать, находятся в списке Undo, а все остальные в списке Redo. Уменьшая или увеличивая свойство current, другими словами выполняя команды Undo или Redo, мы выбираем состояние ближе к началу, или к концу игры. Методы undo и redo пока не реализованы, они будут добавлены позже. В класс History добавлены методы currentState() и push(). Метод currentState() возвращает текущее состояние игры, c помощью метода push() мы добавляем новое состояние в историю ходов.
В компоненте App мы убрали кнопки State 1 и State 2. И добавили кнопку Push:
<button on:click={() => history.push(Array(9).fill($history.current + 1))}>Push</button>
По нажатии на эту кнопку в историю ходов добавляется новое состояние, массив history просто заполняется значением индекса текущего состояния current.
В компоненте Board выводим текущее состояние из истории ходов. Здесь продемонстрировано использование автоподписки на хранилище:
ctx.fillText($history.currentState()[k], j * cellWidth + d + 1, (i + 1) * cellHeight - d);
В методе push хранилища history можно добавить вывод в консоль браузера и наблюдать как оно изменяется после нажатия на кнопку Push.
h.push(state); console.log(h); return h;
Выполнение клика по клетке
Код на REPL
В компоненте App убрали кнопку Push.
В хранилище history определили метод clickCell. Здесь мы создаем полную копию состояния игры, изменяем состояние выбранной клетки и добавляем в историю ходов новое состояние:
clickCell: (i) => update(h => {
// create a copy of the current state
const state = h.currentState().slice();
// change the value of the selected cell to X
state[i] = 'X';
// add the new state to the history
h.push(state);
console.log(h.history);
return h;
}),
В компоненте Board в функцию handleClick() добавили вызов метода хранилища clickCell():
history.clickCell(i);
В консоли браузера здесь также мы можем наблюдать как изменяется состояние истории ходов после каждого клика мышкой.
В следующих статьях мы доделаем игру до конца, с интерфейсом отмены/возврата шагов и произвольным доступом к любому шагу игры. Рассмотрим вариант реализации системы Undo/Redo с использованием паттерна проектирования Command. Рассмотрим взаимодействие с бэкендом, игрок будет соревноваться с интеллектуальным агентом в бэкенде.
Flux-архитектура
В разработке данной игры наблюдается применение архитектурного решения Flux. Действия реализованы в виде методов в определении хранилища history в файле stores.js. Присутствует хранилище history, которое описано в виде класса History. Представления реализованы в виде компонентов App и Board. По мне так, все это — то же самое, что и MVC архитектура, вид сбоку. Действия — контроллер, хранилище — модель, представление — вид. Описания обеих архитектур практически совпадают.
Репозиторий на GitHub
https://github.com/nomhoi/tic-tac-toe-part1
Установка игры на локальном компьютере:
git clone https://github.com/nomhoi/tic-tac-toe-part1.git
cd tic-tac-toe-part1
npm install
npm run dev
Запускаем игру в браузере по адресу: http://localhost:5000/.