Без предисловий, пишем игру крестики-нолики на 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.

Почему не использовать хранилище напрямую, зачем я сделал обёртку?

  1. Как только мне понадобится хранить не строку, а другой тип данных, например Boolean мне достаточно будет добавить аналогичный класс StoredBoolean и не придётся менять класс хранилища.

    Это позволит мне соблюсти принцип открытости-закрытости OCP из SOLID, когда для добавления новой функциональности мы только добавляем код без изменения уже существующего кода.

  2. В объекте-обёртке удобно хранить ключ, по которому мы читаем и записываем данные.

    Поместив ключ в конструктор объекта, мы избавляемся от необходимости писать его каждый раз при обращении к данным.

    Мы пишем ключ только один раз в конструкторе, и всё.

    Кода становится меньше, его легче читать, вероятность ошибки уменьшается.

Файловое хранилище

Для хранилища данных нам понадобится всего три операции: запись, чтение и очистка.

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;
    }
}

Результат

Теперь можно играть в крестики-нолики.

Игра крестики-нолики

Создание этой игры заняло один вечер, было по кайфу 😎