Создание головоломки с использованием React Hooks

  • 18 сентября, 13:46
  • 5917
  • 0

В этой статье мы сделаем простую головоломку - игра в 15, используя React Hooks. Но что это такое?

Вот определение Википедии:

Игра в 15, пятнашки, такен — популярная головоломка, придуманная в 1878 году Ноем Чепмэном. Представляет собой набор одинаковых квадратных костяшек с нанесёнными числами, заключённых в квадратную коробку. Длина стороны коробки в четыре раза больше длины стороны костяшек для набора из 15 элементов, соответственно в коробке остаётся незаполненным одно квадратное поле. Цель игры — перемещая костяшки по коробке, добиться упорядочивания их по номерам, желательно сделав как можно меньше перемещений.

Создание головоломки с использованием React Hooks

Тем не менее, пронумерованный вариант головоломки более математическая версия. Те, что продаются в магазинах игрушек, обычно являются имиджевым вариантом игры. В этом варианте каждая из плиток представляет собой небольшой квадратный сегмент изображения, и когда плитки расположены в правильном порядке, полное изображение принимает форму. 

Мы будем строить этот конкретный вариант головоломки в этой статье. Когда плитки этой головоломки будут расположены в правильном порядке, мы получим изображение Рубеуса Хагрида, Хранителя Ключей и лесника из Хогвартса.

Несколько наблюдений

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

  1. Только плитки, примыкающие к пустому квадрату в сетке (т.е. с разделяющим ребром), могут быть перемещены.
  2. Их можно перемещать только в положение пустого квадрата.
  3. Если мы считаем пустой квадрат пустой плиткой, то перемещение соседней плитки в пустой квадрат может быть смоделировано как замена позиции плитки пустой плиткой.
  4. Когда плитки расположены в правильном порядке, i-th плитка занимает квадрат в Math.floor(i/4) строке и i % 4 столбце сетки.
  5. В любой момент времени максимум одна плитка может быть перемещена в любом направлении.

Имея в виду эти наблюдения, давайте начнем строить головоломку.

Леса и константы

Сначала давайте создадим простую веб-страницу, где будет отображаться наше приложение. 

html
  head
    title 15 Puzzle (Using React Hooks)
    meta(name='viewport', content='initial-scale=1.0')
    link(rel='stylesheet', href='/style.css')

  body
    #root
    script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/react/16.8.6/umd/react.production.min.js')
    script(type='text/javascript', src='https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.6/umd/react-dom.production.min.js')
    script(type='text/javascript', src='/index.js')

Имея эту структуру веб-страницы, давайте определим некоторые константы в index.js.

const NUM_ROWS = 4;
const NUM_COLS = 4;
const NUM_TILES = NUM_ROWS * NUM_COLS;
const EMPTY_INDEX = NUM_TILES - 1;
const SHUFFLE_MOVES_RANGE = [60, 80];
const MOVE_DIRECTIONS = ['up', 'down', 'left', 'right'];

function rand (min, max) {
  return min + Math.floor(Math.random() * (max - min + 1));
}

Здесь rand функция генерирует случайное целое число между minи max(включительно). А константа SHUFFLE_MOVES_RANGE определяет минимальное и максимальное количество случайных ходов, которые мы хотим выполнить, чтобы скремблировать доску головоломки. EMPTY_INDEX индекс пустой плитки. Когда все плитки в правильном порядке, нижний правый квадрат, т.е. 16-й квадрат (индекс массива 15), будет пустым.

Определение GameState

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

Чтобы сделать класс синглтоном, мы определим статическое свойство с именем, instance которое будет содержать ссылку на текущий экземпляр класса, и статический метод, getInstance который будет возвращать текущий экземпляр, если он существует, в противном случае он создаст новый экземпляр.

class GameState {
  static instance = null;

  static getInstance () {
    if (!GameState.instance) GameState.instance = new GameState();
    return GameState.instance;
  }
}

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

Здесь самая важная информация, которую мы храним, - это состояние пазла. Давайте сначала смоделируем это.

Доска-головоломка представляет собой набор из 16 плиток (включая пустую плитку). В любой момент времени каждая плитка находится в определенной позиции в сетке. Положение плитки может быть представлено двумя целыми числами, обозначающими row index и column index. Мы можем смоделировать это как массив целочисленных пар, как показано ниже (ниже приведено представление доски, где плитки расположены в правильном порядке):

[
  [0, 0], // 1st tile is at 1st row, 1st column
  [0, 1], // 2nd tile is at 1st row, 2nd column
  [0, 2],
  [0, 3], // 4th tile is at 1st row, 4th column
  [1, 0], // 5th tile is at 2nd row, 1st column
  [1, 1],
  ...
  [3, 2],
  [3, 3], // 16th tile is at 4th row, 4th column (this is the empty tile)
]

Давайте напишем статический метод, чтобы сгенерировать состояние доски, где плитки находятся в правильном порядке, помните, что когда плитки находятся в правильном порядке, i-th плитка находится в Math.floor(i / 4) th строке и в i % 4th столбце.

Также, когда загадка решена, плитки находятся в правильном порядке. Итак, давайте определим статическое свойство с именем, solvedBoard которое будет хранить решенное состояние.

class GameState {
  // ...

  static getNewBoard () {
    return Array(NUM_TILES).fill(0).map((x, index) => [
      Math.floor(index / NUM_ROWS), 
      index % NUM_COLS
    ]);
  }

  static solvedBoard = GameState.getNewBoard();
}

Когда начинается игра,

  • Счетчик хода установлен в 0
  • стек предыдущих состояний пуст, и
  • доска находится в упорядоченном состоянии.

Затем из этого состояния мы перетасовываем/скремблируем доску перед тем, как представить ее пользователю для решения. Давайте напишем это. На этом этапе мы пропустим написание метода перемешивания доски. Мы просто покажем заглушку на ее месте.

class GameState {
  // ...

  constructor () {
    this.startNewGame();
  }

  startNewGame () {
    this.moves = 0;
    this.board = GameState.getNewBoard();
    this.stack = [];
    this.shuffle(); // we are still to define this method, 
                    // let's put a stub in its place for now
  }

  shuffle () {
    // set a flag that we are to shuffle the board
    this.shuffling = true;

    // Do some shuffling here ...

    // unset the flag after we are done
    this.shuffling = false;
  }
}

Теперь давайте определим методы перемещения плиток. Во-первых, нам нужно определить, можно ли перемещать определенную плитку или нет. Давайте предположим, что i-th плитка сейчас на позиции (r, c). Тогда i-th плитку можно перемещать, то есть 16th плитка в данный момент расположена рядом с ней. 

Чтобы быть смежными, две плитки должны находиться в одной строке или в одном и том же столбце, и если они находятся в одной строке, то разница их индексов столбцов должна быть равна единице, а если они находятся в одном и том же столбце, то разница их индексов строк должны быть равна единице.

class GameState {
  // ...

  canMoveTile (index) {
    // if the tile index is invalid, we can't move it
    if (index < 0 || index >= NUM_TILES) return false;

    // get the current position of the tile and the empty tile
    const tilePos = this.board[index];
    const emptyPos = this.board[EMPTY_INDEX];

    // if they are in the same row, then difference in their 
    // column indices must be 1 
    if (tilePos[0] === emptyPos[0])
      return Math.abs(tilePos[1] - emptyPos[1]) === 1;

    // if they are in the same column, then difference in their
    // row indices must be 1
    else if (tilePos[1] === emptyPos[1])
      return Math.abs(tilePos[0] - emptyPos[0]) === 1;

    // otherwise they are not adjacent
    else return false;
  }
}

На самом деле перемещение плитки на пустой квадрат гораздо проще, нам просто поменять местами позицию этой плитки и позицию пустой плитки. 

Если доска уже решена, мы хотим заморозить доску и запретить дальнейшее движение плиток. Но на этом этапе мы не будем реализовывать метод. Вместо реального метода мы напишем заглушку.

class GameState {
  // ...

  moveTile (index) {
    // if we are not shuffling, and the board is already solved, 
    // then we don't need to move anything
    // Note that, the isSolved method is not defined yet
    // let's stub that to return false always, for now
    if (!this.shuffling && this.isSolved()) return false;

    // if the tile can not be moved in the first place ...
    if (!this.canMoveTile(index)) return false;

    // Get the positions of the tile and the empty tile
    const emptyPosition = [...this.board[EMPTY_INDEX]];
    const tilePosition = [...this.board[index]];

    // copy the current board and swap the positions
    let boardAfterMove = [...this.board];    
    boardAfterMove[EMPTY_INDEX] = tilePosition;
    boardAfterMove[index] = emptyPosition;

    // update the board, moves counter and the stack
    if (!this.shuffling) this.stack.push(this.board);
    this.board = boardAfterMove;
    if (!this.shuffling) this.moves += 1;

    return true;
  }

  isSolved () {
    return false; // stub
  }
}

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

class GameState {
  // ...

  moveInDirection (dir) {
    // get the position of the empty square
    const epos = this.board[EMPTY_INDEX];

    // deduce the position of the tile, from the direction
    // if the direction is 'up', we want to move the tile 
    // immediately below empty, if direction is 'down', then 
    // the tile immediately above empty and so on  
    const posToMove = dir === 'up' ? [epos[0]+1, epos[1]]
      : dir === 'down' ? [epos[0]-1, epos[1]]
      : dir === 'left' ? [epos[0], epos[1]+1]
      : dir === 'right' ? [epos[0], epos[1]-1]
      : epos;

    // find the index of the tile currently in posToMove
    let tileToMove = EMPTY_INDEX;
    for (let i=0; i<NUM_TILES; i++) {
      if (this.board[i][0] === posToMove[0] && this.board[i][1] === posToMove[1]) {
        tileToMove = i;
        break;
      }
    }

    // move the tile
    this.moveTile(tileToMove);
  }
}

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

class GameState {
  // ...

  undo () {
    if (this.stack.length === 0) return false;
    this.board = this.stack.pop();
    this.moves -= 1;
  }
}

На данный момент, у нас есть большая часть логики игры, за исключением shuffle и isSloved методов. 

Для простоты мы выполним несколько случайных ходов на доске, чтобы перетасовать ее. И чтобы проверить, решена ли головоломка, мы просто сравним текущее состояние доски со статическим свойством, solvedBoard которое мы определили ранее.

class GameState {
  // ...

  shuffle () {
    this.shuffling = true;
    let shuffleMoves = rand(...SHUFFLE_MOVES_RANGE);
    while (shuffleMoves --> 0) {
      this.moveInDirection (MOVE_DIRECTIONS[rand(0,3)]);
    }
    this.shuffling = false;
  }

  isSolved () {
    for (let i=0; i<NUM_TILES; i++) {
      if (this.board[i][0] !== GameState.solvedBoard[i][0] 
          || this.board[i][1] !== GameState.solvedBoard[i][1]) 
        return false;
    }
    return true;
  }
}

Теперь давайте напишем метод, который предоставит нам текущее состояние игры как простой объект для удобства.

class GameState {
  // ...

  getState () { 
    // inside the object literal, `this` will refer to 
    // the object we are making, not to the current GameState instance.
    // So, we will store the context of `this` in a constant called `self`
    // and use it.
    // Another way to do it is to use GameState.instance instead of self.
    // that will work, because GameState is a singleton class.

    const self = this;    

    return {
      board: self.board,
      moves: self.moves,
      solved: self.isSolved(),
    };
  }
}

На этом реализация нашего GameState класса завершена. Мы будем использовать его в нашем собственном хуке, чтобы активизировать приложение для игры.

Теперь давайте обернем функции GameState в пользовательский React Hook, чтобы мы могли использовать его в нашем приложении React. Мы хотим зарегистрировать обработчики событий для нажатия клавиш, чтобы пользователи могли играть в пазлы с помощью клавиш со стрелками на клавиатуре, генерировать функции обработчиков щелчков, чтобы пользователи могли щелкать плитки, чтобы перемещать их, мы также хотим создавать вспомогательные функции для отмены и начала новой игри.

Мы прикрепим обработчики событий keyup к объекту документа. Это необходимо сделать только один раз, когда приложение готово.

Основная цель этого хука - заключить экземпляр GameState в состояние React, которое компоненты React могут использовать и обновлять. Мы, конечно, не будем раскрывать сырой метод setState для компонентов. Скорее всего, мы будем выставлять такие функции, как newGame, undo и move к компонентам, так что они могут вызвать обновление состояния, когда пользователь хочет начать новую игру или отменить движение или переместить определенную плитку. Мы представим только ту часть состояния и логику обновления, которая абсолютно необходима компонентам. 

function useGameState () {
  // get the current GameState instance
  const gameState = GameState.getInstance();

  // create a react state from the GameState instance
  const [state, setState] = React.useState(gameState.getState());

  // start a new game and update the react state
  function newGame () {
    gameState.startNewGame();
    setState(gameState.getState());
  }

  // undo the latest move and update the react state
  function undo () {
    gameState.undo();
    setState(gameState.getState());
  }

  // return a function that will move the i-th tile 
  // and update the react state 
  function move (i) {
    return function () {
      gameState.moveTile(i);
      setState(gameState.getState());
    }
  }


  React.useEffect(() => {
    // attach the keyboard event listeners to document
    document.addEventListener('keyup', function listeners (event) {

      if (event.keyCode === 37) gameState.moveInDirection('left');
      else if (event.keyCode === 38) gameState.moveInDirection('up');
      else if (event.keyCode === 39) gameState.moveInDirection('right');
      else if (event.keyCode === 40) gameState.moveInDirection('down');

      setState(gameState.getState());
    });

    // remove the evant listeners when the app unmounts
    return (() => window.removeEventListener(listeners));
  }, [gameState]); 
  // this effect hook will run only when the GameState instance changes.
  // That is, only when the app is mounted and the GameState instance
  // is created

  // expose the state and the update functions for the components 
  return [state.board, state.moves, state.solved, newGame, undo, move];
}

Реактивные компоненты головоломки

Теперь, когда у нас есть концептуальная модель головоломки и функции для обновления этой модели на событиях взаимодействия с пользователем, давайте напишем некоторые компоненты для отображения игры на экране. Отображение игры здесь довольно простое, в нем есть часть заголовка, которая показывает количество ходов, сделанных пользователем, и кнопку отмены. Ниже находится доска с головоломками, на которой будут плитки. Панель головоломки также будет отображать PLAY AGAIN кнопку, когда головоломка будет решена.

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

Пазл будет иметь размеры, 400px * 400px и плитки будут располагаться абсолютно по отношению к нему. Каждая плитка будет иметь размер 95px * 95px с 5px промежутком между желобами.

Следующая функция реализует App компонент. Это базовый макет приложения.

function App () {
  const [board, moves, solved, newGame, undo, move] = useGameState();

  return (
    <div className='game-container'>
      <div className='game-header'>
        <div className='moves'>
          {moves}
        </div>
        <button className='big-button' onClick={undo}> UNDO </button>
      </div>
      <div className='board'>
      {
        board.slice(0,-1).map((pos, index) => ( 
          <Tile index={index} pos={pos} onClick={move(index)} />
        ))
      }
      { solved &&
          <div className='overlay'>
            <button className='big-button' onClick={newGame}>
              PLAY AGAIN 
            </button>
          </div>
      }
      </div>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

Теперь давайте реализуем Tile компонент, который будет отображать и размещать каждую отдельную плитку на доске. Как упоминалось ранее, плитки будут располагаться абсолютно по отношению к доске. Учитывая row index и column index плитки, мы можем найти ее положение на доске. Мы знаем, что каждый квадрат на сетке имеет размер 100px * 100px с 5px пространством. Таким образом, мы можем просто умножить row index и column index плитки на 100 и добавить 5, чтобы получить верхнюю и левую позиции плитки.

Точно так же мы можем получить backgroundPosition фонового изображения для каждой плитки, находя, какую часть фонового изображения они отображают, когда расположены в правильном порядке. Для этого сначала нам нужно рассчитать положение плитки, когда она в правильном порядке. Мы знаем, что i-th плитка располагается в правильном порядке в Math.floor(i / 4)строке и i % 4 столбце. Исходя из этого, мы можем рассчитать положение в виде пикселей сверху и пикселей слева, умножив индексы строк и столбцов на 100, а затем сложив 5. Позиции фона будут отрицательными из этих значений.

function Tile ({index, pos, onClick}) {
  const top = pos[0]*100 + 5;
  const left = pos[1]*100 + 5;
  const bgLeft = (index%4)*100 + 5;
  const bgTop = Math.floor(index/4)*100 + 5;

  return <div 
    className='tile'
    onClick={onClick}
    style={{top, left, backgroundPosition: `-${bgLeft}px -${bgTop}px`}} 
  />;
}

Стилизация головоломки

Прежде чем разрабатывать головоломку, нам нужно найти хорошее 400px * 400px изображение для использования в качестве фонового изображения наших плиток. В качестве альтернативы, мы также можем использовать числа для головоломки (как упоминалось в статье в Википедии для 15-Puzzle). В любом случае, давайте посмотрим на некоторые важные моменты стиля этого приложения.

Расположение доски и плитки

Фактическая ширина и высота доски будут 400px + 5px, потому что 4 колонки или ряды нуждаются в 5 желобах вокруг них. Однако это не влияет на размеры плиток, потому что мы можем смело думать, что 5-й желоб находится за пределами доски. Доска должна иметь объявленную позицию relative так, чтобы плитки могли быть позиционированы абсолютно по отношению к ней.

В случае плиток размер будет 95px * 95px учитывать 5px желоба. Их background-size, однако, должно быть 400px * 400px, потому что каждая плитка показывает только определенный квадрат от полноразмерного 400px * 400px изображения. Положение фона будет установлено как встроенный стиль компонентом React.

Чтобы движения плиток выглядели плавными и естественными, мы можем использовать CSS-переходы. Здесь мы использовали 0,1-кратный переход на тайлы.

.board {
  width: 405px;
  height: 405px;
  position: relative;
  background: #ddd;
}

.tile {
  width: 95px;
  height: 95px;
  position: absolute;
  background: white;
  transition: all 0.1s ease-in-out;
  border-radius: 2px;
  background-image: url('@{bg-img}');
  background-size: 400px 400px;
}

Позиционирование оверлея

Наложение является еще одним прямым составляющей доски. Оно должно покрыть доску, когда игра заканчивается. Итак, мы дадим ему те же размеры, что и доска, и разместим его абсолютно в (0, 0). Он должен быть над плитками, поэтому мы дадим ему максимум z-index. Мы также дадим ему полупрозрачный темный цвет фона. Он будет содержать PLAY AGAIN кнопку в центре, поэтому мы сделаем его гибким контейнером с обоими align-items и justify-content установим в center.

.overlay {
  width: 405px;
  height: 405px;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  background: #0004;
  display: flex;
  align-items: center;
  justify-content: center;
}

Вот результат:

Надеюсь, вам понравилось читать об этом маленьком проекте...


0 комментариев
Сортировка:
Добавить комментарий

IT Новости

Смотреть все