Создание игры Minesweeper в SolidJS - Игровая доска

Создание игры Minesweeper в SolidJS - Игровая доска

Прошло некоторое время с тех пор, как я в последний раз что-то писал. Я подумал, что если поделиться с вами чем-то приятным, это может послужить средством для возвращения к этому. Таким образом, я терпеливо ждал чего-то, что могло бы возродить мое вдохновение и интерес, что в конечном итоге пришло в виде старой, но затягивающей игры Minesweeper :)

Моя дочь недавно открыла для себя Minesweeper на своем компьютере, и, к моему удивлению, она стала для нас увлекательным совместным занятием. Я сижу рядом с ней, время от времени предлагая свои два цента, и поражаюсь тому, как быстро она уловила логику игры. Она играет все быстрее и быстрее, с каждым днем ставя рекорды.

Сидя там, я не мог не задуматься о коде, лежащем в основе игры. В частности, я размышлял о расположении игрового поля. Это двумерный массив или плоский массив? Как вычисляются числа? Каким волшебником нужно быть, чтобы создать такую штуку?

Этого достаточно, чтобы разжечь огонь под моей задницей и заставить меня действовать :)

Итак, хотите поучаствовать в создании игры Minesweeper с помощью SolidJS?
Поехали!

Эй! Для получения большего количества материалов, подобных тому, что вы собираетесь прочитать, проверьте @mattibarzeev на Twitter 🍻

Код можно найти в этом репозитории GitHub:
https://github.com/mbarzeev/solid-minesweeper

Игровая доска

Доска сделана из массива с 16 ячейками. Это позволит нам создать доску 4х4. В настоящее время она жестко закодирована, позже мы сможем сделать на ней несколько случайных расположений:

const boardArray: number[] = [0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0];

Содержимое массива состоит из 0 и 1, где 1 означает плитку с миной, а 0 - пустую плитку.
Для отображения я использую компонент <For> из Solid:

const App: Component = () => {
   return (
       <div class={styles.App}>
           <header class={styles.header}>
               <For each={boardArray}>
                   {(item: number, index: () => number) => <div>{item}</div>}
               </For>
           </header>
       </div>
   );
};

В результате получается вот что:

Описание изображения

Подождите… дальше будет лучше.

Очевидно, нам нужен способ расположить их в сетке 4x4, и для этого я использую … css-сетку :)
Я помещу мой <For …> тег внутрь div, который имеет класс board css, и вот определение этого класса:

.board {
 --tile-dimension: 30px;
 --row-length: 4;


 display: grid;
 grid-template-columns: repeat(auto-fit, minmax(var(--tile-dimension), 1fr));
 max-width: calc(var(--row-length) * var(--tile-dimension));
}

Теперь это выглядит следующим образом:

Описание изображения

Да, больше похоже на это.
Мы знаем, что эти пустые клетки должны указывать на количество мин, которые находятся рядом с ними во всех 8 направлениях. Как мы это сделаем?

Простой алгоритм игры Minesweeper

Здесь я использую подход ”грубой силы”, хотя может оказаться, что это самый эффективный способ достижения цели. Я проверяю каждую плитку на наличие мин рядом с ней, но поскольку массив плоский, нам нужно быть умными. Позвольте мне сначала поместить здесь код, а затем объяснить, что происходит в функции “getMinesCount()“.
Вот рендеринг, где мы передаем индекс текущей ячейки в функцию getMinesCount:

<div class={styles.board}>
                   <For each={boardArray} fallback={<div>Loading...</div>}>
                       {(item: number, index: () => number) => (
                           <div style={{width: '30px', height: '30px'}}>{getMinesCount(index())}</div>
                       )}
                   </For>
               </div>

А вот реализация функции getMinesCount:

function getMinesCount(index: number) {
   const cell = boardArray[index];
   if (cell === 1) {
       return 'x';
   } else {
       let minesCount = 0;
       const hasLeftCells = index % ROW_LENGTH > 0;
       const hasRightCells = (index + 1) % ROW_LENGTH !== 0;
       const bottomCellIndex = index + ROW_LENGTH;
       const topCellIndex = index - ROW_LENGTH;


       if (boardArray[bottomCellIndex] === 1) {
           minesCount++;
       }


       if (boardArray[topCellIndex] === 1) {
           minesCount++;
       }


       if (hasLeftCells) {
           boardArray[index - 1] === 1 && minesCount++;
           boardArray[topCellIndex - 1] === 1 && minesCount++;
           boardArray[bottomCellIndex - 1] === 1 && minesCount++;
       }


       if (hasRightCells) {
           boardArray[index + 1] === 1 && minesCount++;
           boardArray[topCellIndex + 1] && minesCount++;
           hasRightCells && boardArray[bottomCellIndex + 1] && minesCount++;
       }


       return minesCount;
   }
}

Логика довольно проста - мы вычисляем верхнюю и нижнюю ячейки, выясняем, есть ли у ячейки соседи слева или справа, и соответственно увеличиваем minesCount.

На самом деле это не так уж плохо, учитывая, что у нас сложность O(n).

Вот как это выглядит сейчас:

Описание изображения

Символ “X” обозначает мины, а цифры показывают, сколько мин находится рядом с клеткой. Пора создать компонент Tile.

Вы, наверное, заметили, что я использую константу “ROW_LENGTH” для вычисления размеров доски, и я также имею это значение в CSS как переменную, и это немного раздражает меня, что изменение размеров сетки требует изменения как CSS, так и JS, а не только в одном месте.
Для этого я решил определить переменную CSS в коде рендеринга, например, так:

<div class={styles.App} style={{'--row-length': ROW_LENGTH}}>
           <header class={styles.header}>
               <div class={styles.board}>
                   <For each={boardArray}>
                       {(item: number, index: () => number) => <Tile {...getTileData(index())} />}
                   </For>
               </div>
           </header>
       </div>

Таким образом, мне нужно только изменить константу “ROW_LENGTH”, и все выравнивается идеально.

Компонент плитки

import {Component, createSignal} from 'solid-js';
import styles from './Tile.module.css';


export type TileData = {
   isMine: boolean;
   value?: number;
};


const Tile: Component<TileData> = (data: TileData) => {
   const [isOpen, setIsOpen] = createSignal(false);
   const [isMarked, setIsMarked] = createSignal(false);


   const onTileClicked = (event: MouseEvent) => {
       !isMarked() && setIsOpen(true);
   };


   const onTileContextClick = (event: MouseEvent) => {
       event.preventDefault();
       !isOpen() && setIsMarked(!isMarked());
   };


   const value = data.isMine ? 'X' : data.value;


   return (
       <div class={styles.Tile} onclick={onTileClicked} onContextMenu={onTileContextClick}>
           <div class={styles.value} classList={{[styles.exposed]: isOpen() || isMarked()}}>
               {isMarked() ? '🚩' : value !== 0 ? value : ''}
           </div>
       </div>
   );
};


export default Tile;

Как вы можете видеть, щелчок правой кнопкой мыши отмечает плитку, а обычный щелчок открывает ее. Отметку можно переключить, но после открытия плитка уже открыта.

И мы используем это так в основном приложении:

<For each={boardArray} fallback={<div>Loading...</div>}>
                       {(item: number, index: () => number) => <Tile {...getTileData(index())} />}
                   </For>

И вот что мы получили: доска с ”пустыми” плитками, при нажатии на которые они открываются - те, что с цифрами, показывают цифры, те, что с минами, показывают “X”, а отмеченные - флажки.

Описание изображения

Доска Случайностей

Пора добавить немного случайности в котел. Я хотел бы сгенерировать плоский массив 20x20 (400 клеток), в котором разбросаны 40 мин. Вот код для этого:

const totalMines = 40;
const ROW_LENGTH = 20;
const TOTAL_TILES = Math.pow(ROW_LENGTH, 2);


const boardArray = [...Array(TOTAL_TILES)].fill(0);


let count = 0;
while (count < totalMines) {
   const randomCellIndex = Math.floor(Math.random() * TOTAL_TILES);
   if (boardArray[randomCellIndex] !== 1) {
       boardArray[randomCellIndex] = 1;
       count++;
   }
}

Вот как это выглядит без скрытия значения плитки для наглядности:

Описание изображения

На этом пока все.

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

Следующий шаг - понять логику, лежащую в основе функции ”автораскрытия”, когда несколько плиток раскрываются одним щелчком мыши. Нам нужно определить соответствующую логику для открытия соседних плиток при нажатии на одну плитку. Нам также необходимо решить остальные вопросы механики игры, чтобы обеспечить ее бесперебойную работу.

Код можно найти в этом репозитории GitHub:
https://github.com/mbarzeev/solid-minesweeper