Без предисловий, пишем игру крестики-нолики на PHP.
Правила игры и общая логика
Для ТЗ я ипользую майндмап https://mind42.com , удобно редактировать и сверяться потом, не забыл ли что-то сделать.
Сначала я описал правила игры, потом описал требования к реализации — команды и запросы.
Для такого простого проекта этой информации вполне достаточно, можно приступать к написанию кода.
Основной класс игры
В одном классе мы описываем всё что есть в игре.
По описанию игры, игра принимает три команды и отдаёт своё текущее состояние.
Поэтому в классе игры всего четыре публичных метода.
class Xo
{
public function commandMove(string $figure, int $x, int $y): void {}
public function commandNewGame(): void {}
public function commandGiveUp(): void {}
public function queryState(): string {}
}
Полный текст:
class Xo
{
private const FIGURE_X = 'X';
private const FIGURE_O = 'O';
private const FIGURES = [
self::FIGURE_X,
self::FIGURE_O,
];
private const WINNER_X_ROW = self::FIGURE_X . self::FIGURE_X . self::FIGURE_X;
private const WINNER_Y_ROW = self::FIGURE_O . self::FIGURE_O . self::FIGURE_O;
private const CELL_X = 'X';
private const CELL_O = 'O';
private const CELL_EMPTY = '-';
private const NEW_GAMEFIELD = '---------';
private const STATE_PLAYING = 'playing';
private const STATE_WINNER = 'winner';
private const STATE_DRAW = 'draw';
private StoredString $storedGameField;
private StoredString $storedNextFigure;
private StoredString $storedState;
private StoredString $storedWinner;
public function __construct()
{
$this->storedGameField = new StoredString('xo.gameField');
$this->storedNextFigure = new StoredString('xo.nextFigure');
$this->storedState = new StoredString('xo.state');
$this->storedWinner = new StoredString('xo.winner');
}
public function commandMove(string $figure, int $x, int $y): void
{
$figureNormalized = strtoupper($figure);
if (!in_array(strtoupper($figureNormalized), self::FIGURES)) {
throw new \InvalidArgumentException('Неизвестный тип фигуры');
}
if ($x < 1 || $x > 3) {
throw new \InvalidArgumentException('X должен быть в диапазоне от 1 до 3');
}
if ($y < 1 || $y > 3) {
throw new \InvalidArgumentException('Y должен быть в диапазоне от 1 до 3');
}
if (!$this->isPlaying()) {
throw new \InvalidArgumentException('Ход нельзя сделать, перезапустите игру');
}
if ($this->isCellTaken($x, $y)) {
throw new \InvalidArgumentException('Клетка уже занята');
}
if (!$this->isNextFigure($figureNormalized)) {
throw new \InvalidArgumentException('Сейчас не ваш ход');
}
$this->updateCell($x, $y, $figureNormalized);
if ($this->hasWinnerX()) {
$this->setStateWinner(self::FIGURE_X);
return;
}
if ($this->hasWinnerO()) {
$this->setStateWinner(self::FIGURE_O);
return;
}
if (!$this->hasEmptyCells()) {
$this->setStateDraw();
}
$this->switchNextFigure();
}
public function commandNewGame(): void
{
$this->setState(self::STATE_PLAYING);
$this->clearWinner();
$this->setGameField(self::NEW_GAMEFIELD);
$this->setNextFigure(self::FIGURE_X);
}
public function commandGiveUp(): void
{
$this->setStateWinner($this->getOtherFigure());
}
public function queryState(): string
{
$out = $this->isPlaying()
? "Ждём ход игрока \"{$this->getNextFigure()}\"\n"
: (
$this->isDraw()
? "Ничья\n"
: "Победил игрок \"{$this->getWinner()}\"\n"
);
$out .= "\n";
$x1y1 = $this->getCellDraw(1, 1);
$x2y1 = $this->getCellDraw(2, 1);
$x3y1 = $this->getCellDraw(3, 1);
$x1y2 = $this->getCellDraw(1, 2);
$x2y2 = $this->getCellDraw(2, 2);
$x3y2 = $this->getCellDraw(3, 2);
$x1y3 = $this->getCellDraw(1, 3);
$x2y3 = $this->getCellDraw(2, 3);
$x3y3 = $this->getCellDraw(3, 3);
$gameField = '';
$gameField .= "+---+---+---+\n";
$gameField .= "| {$x1y1} | {$x2y1} | {$x3y1} |\n";
$gameField .= "+---+---+---+\n";
$gameField .= "| {$x1y2} | {$x2y2} | {$x3y2} |\n";
$gameField .= "+---+---+---+\n";
$gameField .= "| {$x1y3} | {$x2y3} | {$x3y3} |\n";
$gameField .= "+---+---+---+\n";
$out .= $gameField;
return $out;
}
private function isCellTaken(int $x, int $y): bool
{
return !$this->isCellEmpty($x, $y);
}
private function isCellEmpty(int $x, int $y): bool
{
return !$this->isCellX($x, $y) && !$this->isCellO($x, $y);
}
private function isCellX(int $x, int $y): bool
{
return $this->getCell($x, $y) === self::CELL_X;
}
private function isCellO(int $x, int $y): bool
{
return $this->getCell($x, $y) === self::CELL_O;
}
private function getCell(int $x, int $y): string
{
return $this->getGameField()[$this->getPosition($x, $y)];
}
private function getCellDraw(int $x, int $y): string
{
$cell = $this->getCell($x, $y);
return $cell !== self::CELL_EMPTY ? $cell : ' ';
}
private function isPlaying(): bool
{
return $this->getState() === self::STATE_PLAYING;
}
private function isDraw(): bool
{
return $this->getState() === self::STATE_DRAW;
}
private function isNextFigure(string $figure): bool
{
return $this->getNextFigure() === $figure;
}
private function updateCell(int $x, int $y, string $figure): void
{
$position = $this->getPosition($x, $y);
$this->setGameField(
substr($this->getGameField(), 0, $position)
. $figure
. substr($this->getGameField(), $position + 1)
);
}
private function getPosition(int $x, int $y): int
{
return 3 * ($y - 1) + $x - 1;
}
private function hasWinnerX(): bool
{
return in_array(self::WINNER_X_ROW, $this->getCombinations());
}
private function hasWinnerO(): bool
{
return in_array(self::WINNER_Y_ROW, $this->getCombinations());
}
private function getCombinations(): array
{
$coordinates = [
[
[1, 1],
[2, 1],
[3, 1],
],
[
[1, 2],
[2, 2],
[3, 2],
],
[
[1, 3],
[2, 3],
[3, 3],
],
[
[1, 1],
[1, 2],
[1, 3],
],
[
[2, 1],
[2, 2],
[2, 3],
],
[
[3, 1],
[3, 2],
[3, 3],
],
[
[1, 1],
[2, 2],
[3, 3],
],
[
[1, 3],
[2, 2],
[3, 1],
],
];
$combinations = [];
foreach ($coordinates as $row) {
$combination = '';
foreach ($row as $cell) {
$combination .= $this->getCell($cell[0], $cell[1]);
}
$combinations[] = $combination;
}
return $combinations;
}
private function setStateWinner(string $figure): void
{
$this->setState(self::STATE_WINNER);
$this->setWinner($figure);
}
private function getGameField(): string
{
return $this->storedGameField->read() ?? self::NEW_GAMEFIELD;
}
private function setGameField(string $value)
{
$this->storedGameField->write($value);
}
private function hasEmptyCells(): bool
{
return str_contains($this->getGameField(), self::CELL_EMPTY);
}
private function setStateDraw(): void
{
$this->setState(self::STATE_DRAW);
$this->clearWinner();
}
private function switchNextFigure(): void
{
$this->setNextFigure($this->getOtherFigure());
}
private function setNextFigure(string $value): void
{
$this->storedNextFigure->write($value);
}
private function getNextFigure(): string
{
return $this->storedNextFigure->read() ?? self::FIGURE_X;
}
private function setState(string $value): void
{
$this->storedState->write($value);
}
private function getState(): string
{
return $this->storedState->read() ?? self::STATE_PLAYING;
}
private function setWinner(string $figure): void
{
$this->storedWinner->write($figure);
}
private function clearWinner(): void
{
$this->storedWinner->clear();
}
private function getWinner(): string
{
return $this->storedWinner->read() ?? '-';
}
private function getOtherFigure(): string
{
return $this->getNextFigure() === self::FIGURE_X ? self::FIGURE_O : self::FIGURE_X;
}
}
Сохранение состояния
Состояние игры мы храним в файлах на диске.
Почему не в БД? Потому что в файлах проще.
Я сделал очень примитивное хранилище с помощью нескольких классов и собственной библиотеки по работе с файлами:
https://github.com/Nex-Otaku/minimal-filesystem
Вот так выглядит структура модуля рантайма, здесь видно в том числе текстовые файлы в которых хранится состояние.
StoredString
class StoredString
{
private string $key;
private RuntimeState $runtimeState;
public function __construct(string $key)
{
$this->key = $key;
$this->runtimeState = new RuntimeState();
}
public function write(string $value): void
{
$this->runtimeState->set($this->key, $value);
}
public function read(): ?string
{
return $this->runtimeState->get($this->key);
}
public function clear(): void
{
$this->runtimeState->delete($this->key);
}
}
Для удобства работы с сохраняемым состоянием сделаем объект StoredString
, который может извлекать и сохранять состояние по запросу.
Сохранять и читать состояние этот объект будет обращаясь к файловому хранилищу RuntimeState
.
Почему не использовать хранилище напрямую, зачем я сделал обёртку?
-
Как только мне понадобится хранить не строку, а другой тип данных, например
Boolean
мне достаточно будет добавить аналогичный классStoredBoolean
и не придётся менять класс хранилища.Это позволит мне соблюсти принцип открытости-закрытости OCP из SOLID, когда для добавления новой функциональности мы только добавляем код без изменения уже существующего кода.
-
В объекте-обёртке удобно хранить ключ, по которому мы читаем и записываем данные.
Поместив ключ в конструктор объекта, мы избавляемся от необходимости писать его каждый раз при обращении к данным.
Мы пишем ключ только один раз в конструкторе, и всё.
Кода становится меньше, его легче читать, вероятность ошибки уменьшается.
Файловое хранилище
Для хранилища данных нам понадобится всего три операции: запись, чтение и очистка.
class RuntimeState
{
public function set(string $key, string $value): void {}
public function get(string $key): ?string {}
public function delete(string $key): void {}
}
Полный текст:
class RuntimeState
{
private Filesystem $filesystem;
public function __construct()
{
$this->filesystem = new Filesystem();
}
public function set(string $key, string $value): void
{
$this->filesystem->writeFile($this->getPath($key), $value);
}
public function get(string $key): ?string
{
if (!$this->filesystem->exists($this->getPath($key))) {
return null;
}
return $this->filesystem->readFile($this->getPath($key));
}
private function getPath(string $key): string
{
return __DIR__ . '/State/' . $key . '.txt';
}
public function delete(string $key): void
{
if (!$this->filesystem->exists($this->getPath($key))) {
return;
}
$this->filesystem->deleteFile($this->getPath($key));
}
}
Filesystem
— это мой класс для работы с файловой системой.
Я оформил его в пакет и подключаю через Composer, когда требуется делать что-то с файлами.
https://github.com/Nex-Otaku/minimal-filesystem
Запуск игры
Я запускаю игру через консольные команды в Laravel, но вообще можно запустить как угодно, даже без фреймворков.
Игра будет работать и без фреймворка, так как она нигде не завязана на фреймворк.
Пример команды “ход игрока”, остальные реализовываются аналогично:
class XoMoveCommand extends Command
{
protected $signature = 'xo:move {figure} {x} {y}';
protected $description = 'XO Move';
public function handle()
{
$figure = (string) $this->argument('figure');
$x = (int) $this->argument('x');
$y = (int) $this->argument('y');
$xo = new Xo();
$xo->commandMove($figure, $x, $y);
echo $xo->queryState() . "\n";
return 0;
}
}
Результат
Теперь можно играть в крестики-нолики.
Создание этой игры заняло один вечер, было по кайфу 😎