«Пятнашка» на Java — как разработать полноценную игру

Автор оригинала: Sylvain Saurel
  • Перевод


«Пятнадцать», или «Пятнашка» — отличный пример простой логической игры, популярной во всем мире. Для того чтобы решить головоломку, необходимо расставить квадратики с цифрами по порядку, от меньшего к большему. Это непросто, но интересно.

В сегодняшнем туториале показываем, как разработать «Пятнашку» на Java 8 с Eclipse. Для разработки UI мы будем использовать Swing API.

Напоминаем: для всех читателей «Хабра» — скидка 10 000 рублей при записи на любой курс Skillbox по промокоду «Хабр».

Skillbox рекомендует: Образовательный онлайн-курс «Профессия Java-разработчик».

Проектирование игры


На этом этапе нужно определить свойства:
  • Size — размер игрового поля;
  • nbTiles — количество пятнашек в поле. nbTiles = size*size — 1;
  • Tiles — пятнашка, которая представляет собой одномерный массив целых чисел. Каждая из пятнашек получит уникальное значение в диапазоне [0, nbTiles]. Ноль обозначает пустой квадрат;
  • blankPos — позиция пустого квадрата.

Логика игры


Нужно определить метод сброса (reset), используемый для инициализации новой игровой позиции. Так мы устанавливаем значение для каждого элемента массива пятнашек. Ну а потом помещаем blankPos в последнюю позицию массива.

Также нужен метод shuffle для перетасовки массива пятнашек. Мы не включаем пустую пятнашку в процесс перетасовки, чтобы оставить ее в прежнем положении.

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

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

Затем важно определить метод isSolved, чтобы проверить, решен ли наш расклад Game Of Fifteen. Сначала мы смотрим, где находится пустая пятнашка. Если в начальной позиции, то текущий расклад — новый, не решенный ранее. Затем мы перебираем плитки в обратном порядке, и, если значение пятнашки отличается от соответствующего индекса +1, возвращаем false. В противном случае в конце метода пора вернуть true, потому что головоломка уже решена.

Еще один метод, который нужно определить, — newGame. Он требуется для создания нового экземпляра игры. Для этого мы сбрасываем игровое поле, затем перетасовываем его и продолжаем до тех пор, пока игровая позиция не будет разрешима.

Вот пример кода с ключевой логикой пятнашек:
private void newGame() {
  do {
    reset(); // reset in initial state
    shuffle(); // shuffle
  } while(!isSolvable()); // make it until grid be solvable
 
  gameOver = false;
}
 
private void reset() {
  for (int i = 0; i < tiles.length; i++) {
    tiles[i] = (i + 1) % tiles.length;
  }
 
  // we set blank cell at the last
  blankPos = tiles.length - 1;
}
 
private void shuffle() {
  // don't include the blank tile in the shuffle, leave in the solved position
  int n = nbTiles;
 
  while (n > 1) {
    int r = RANDOM.nextInt(n--);
    int tmp = tiles[r];
    tiles[r] = tiles[n];
    tiles[n] = tmp;
  }
}
 
// Only half permutations of the puzzle are solvable/
// Whenever a tile is preceded by a tile with higher value it counts
// as an inversion. In our case, with the blank tile in the solved position,
// the number of inversions must be even for the puzzle to be solvable
private boolean isSolvable() {
  int countInversions = 0;
 
  for (int i = 0; i < nbTiles; i++) {
    for (int j = 0; j < i; j++) {
      if (tiles[j] > tiles[i])
        countInversions++;
    }
  }
 
  return countInversions % 2 == 0;
}
 
private boolean isSolved() {
  if (tiles[tiles.length - 1] != 0) // if blank tile is not in the solved position ==> not solved
    return false;
 
  for (int i = nbTiles - 1; i >= 0; i--) {
    if (tiles[i] != i + 1)
      return false;
  }
 
  return true;
}

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

Вот пример кода:
// get position of the click
int ex = e.getX() - margin;
int ey = e.getY() - margin;
 
// click in the grid ?
if (ex < 0 || ex > gridSize  || ey < 0  || ey > gridSize)
  return;
 
// get position in the grid
int c1 = ex / tileSize;
int r1 = ey / tileSize;
 
// get position of the blank cell
int c2 = blankPos % size;
int r2 = blankPos / size;
 
// we convert in the 1D coord
int clickPos = r1 * size + c1;
 
int dir = 0;
 
// we search direction for multiple tile moves at once
if (c1 == c2  &&  Math.abs(r1 - r2) > 0)
  dir = (r1 - r2) > 0 ? size : -size;
else if (r1 == r2 && Math.abs(c1 - c2) > 0)
  dir = (c1 - c2) > 0 ? 1 : -1;
 
if (dir != 0) {
  // we move tiles in the direction
  do {
    int newBlankPos = blankPos + dir;
    tiles[blankPos] = tiles[newBlankPos];
    blankPos = newBlankPos;
  } while(blankPos != clickPos);
 
tiles[blankPos] = 0;

Разрабатываем UI на Swing API


Самое время заняться интерфейсом. Сначала берем класс Jpanel. Потом на поле рисуем пятнашки — для расчета размеров каждой воспользуемся данными, заданными в параметре конструктора игры:
gridSize = (dim  -  2 * margin);
tileSize = gridSize / size;

Margin также является параметром, заданным в конструкторе игры.

Теперь нужно определить метод drawGrid для отрисовки сетки и пятнашек на экране. Анализируем массив пятнашек и конвертируем координаты в координаты пользовательского интерфейса. Затем прорисуем каждую пятнашку с соответствующим номером в центре:
private void drawGrid(Graphics2D g) {
  for (int i = 0; i < tiles.length; i++) {
    // we convert 1D coords to 2D coords given the size of the 2D Array
    int r = i / size;
    int c = i % size;
    // we convert in coords on the UI
    int x = margin + c * tileSize;
    int y = margin + r * tileSize;
 
    // check special case for blank tile
    if(tiles[i] == 0) {
      if (gameOver) {
        g.setColor(FOREGROUND_COLOR);
        drawCenteredString(g, "\u2713", x, y);
      }
 
      continue;
    }
 
    // for other tiles
    g.setColor(getForeground());
    g.fillRoundRect(x, y, tileSize, tileSize, 25, 25);
    g.setColor(Color.BLACK);
    g.drawRoundRect(x, y, tileSize, tileSize, 25, 25);
    g.setColor(Color.WHITE);
 
    drawCenteredString(g, String.valueOf(tiles[i]), x , y);
  }
}

Наконец, переопределим метод paintComponent, являющийся производным класса JPane. Затем используем метод drawGrid, а после — метод drawStartMessage для отображения сообщения, предлагающего кликнуть для запуска игры:
private void drawStartMessage(Graphics2D g) {
  if (gameOver) {
    g.setFont(getFont().deriveFont(Font.BOLD, 18));
    g.setColor(FOREGROUND_COLOR);
    String s = "Click to start new game";
    g.drawString(s, (getWidth() - g.getFontMetrics().stringWidth(s)) / 2,
        getHeight() - margin);
  }
}
 
private void drawCenteredString(Graphics2D g, String s, int x, int y) {
  // center string s for the given tile (x,y)
  FontMetrics fm = g.getFontMetrics();
  int asc = fm.getAscent();
  int desc = fm.getDescent();
  g.drawString(s,  x + (tileSize - fm.stringWidth(s)) / 2,
      y + (asc + (tileSize - (asc + desc)) / 2));
}
 
@Override
protected void paintComponent(Graphics g) {
  super.paintComponent(g);
  Graphics2D g2D = (Graphics2D) g;
  g2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
  drawGrid(g2D);
  drawStartMessage(g2D);
}

Реагируем на действия пользователя в UI


Для того чтобы игра шла своим чередом, необходимо обрабатывать действия пользователя в UI. Для этого добавляем имплементацию MouseListener на Jpanel и код для перемещения пятнашек, уже показанный выше:
addMouseListener(new MouseAdapter() {
  @Override
  public void mousePressed(MouseEvent e) {
    // used to let users to interact on the grid by clicking
    // it's time to implement interaction with users to move tiles to solve the game !
    if (gameOver) {
      newGame();
    } else {
      // get position of the click
      int ex = e.getX() - margin;
      int ey = e.getY() - margin;
 
      // click in the grid ?
      if (ex < 0 || ex > gridSize  || ey < 0  || ey > gridSize)
        return;
 
      // get position in the grid
      int c1 = ex / tileSize;
      int r1 = ey / tileSize;
 
      // get position of the blank cell
      int c2 = blankPos % size;
      int r2 = blankPos / size;
 
      // we convert in the 1D coord
      int clickPos = r1 * size + c1;
 
      int dir = 0;
 
      // we search direction for multiple tile moves at once
      if (c1 == c2  &&  Math.abs(r1 - r2) > 0)
        dir = (r1 - r2) > 0 ? size : -size;
      else if (r1 == r2 && Math.abs(c1 - c2) > 0)
        dir = (c1 - c2) > 0 ? 1 : -1;
 
      if (dir != 0) {
        // we move tiles in the direction
        do {
          int newBlankPos = blankPos + dir;
          tiles[blankPos] = tiles[newBlankPos];
          blankPos = newBlankPos;
        } while(blankPos != clickPos);
 
        tiles[blankPos] = 0;
      }
 
      // we check if game is solved
      gameOver = isSolved();
    }
 
    // we repaint panel
    repaint();
  }
});

Код размещаем в конструкторе класса GameOfFifteen. В самом конце вызываем метод newGame для начала новой игры.

Полный код игры


Последний шаг, прежде чем увидеть игру в действии, — нужно собрать все элементы кода вместе. Вот что получится:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.Random;
 
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
 
// We are going to create a Game of 15 Puzzle with Java 8 and Swing
// If you have some questions, feel free to read comments ;)
public class GameOfFifteen extends JPanel { // our grid will be drawn in a dedicated Panel
 
  // Size of our Game of Fifteen instance
  private int size;
  // Number of tiles
  private int nbTiles;
  // Grid UI Dimension
  private int dimension;
  // Foreground Color
  private static final Color FOREGROUND_COLOR = new Color(239, 83, 80); // we use arbitrary color
  // Random object to shuffle tiles
  private static final Random RANDOM = new Random();
  // Storing the tiles in a 1D Array of integers
  private int[] tiles;
  // Size of tile on UI
  private int tileSize;
  // Position of the blank tile
  private int blankPos;
  // Margin for the grid on the frame
  private int margin;
  // Grid UI Size
  private int gridSize;
  private boolean gameOver; // true if game over, false otherwise
 
  public GameOfFifteen(int size, int dim, int mar) {
    this.size = size;
    dimension = dim;
    margin = mar;
    
    // init tiles
    nbTiles = size * size - 1; // -1 because we don't count blank tile
    tiles = new int[size * size];
    
    // calculate grid size and tile size
    gridSize = (dim - 2 * margin);
    tileSize = gridSize / size;
    
    setPreferredSize(new Dimension(dimension, dimension + margin));
    setBackground(Color.WHITE);
    setForeground(FOREGROUND_COLOR);
    setFont(new Font("SansSerif", Font.BOLD, 60));
    
    gameOver = true;
    
    addMouseListener(new MouseAdapter() {
      @Override
      public void mousePressed(MouseEvent e) {
        // used to let users to interact on the grid by clicking
        // it's time to implement interaction with users to move tiles to solve the game !
        if (gameOver) {
          newGame();
        } else {
          // get position of the click
          int ex = e.getX() - margin;
          int ey = e.getY() - margin;
          
          // click in the grid ?
          if (ex < 0 || ex > gridSize  || ey < 0  || ey > gridSize)
            return;
          
          // get position in the grid
          int c1 = ex / tileSize;
          int r1 = ey / tileSize;
          
          // get position of the blank cell
          int c2 = blankPos % size;
          int r2 = blankPos / size;
          
          // we convert in the 1D coord
          int clickPos = r1 * size + c1;
          
          int dir = 0;
          
          // we search direction for multiple tile moves at once
          if (c1 == c2  &&  Math.abs(r1 - r2) > 0)
            dir = (r1 - r2) > 0 ? size : -size;
          else if (r1 == r2 && Math.abs(c1 - c2) > 0)
            dir = (c1 - c2) > 0 ? 1 : -1;
            
          if (dir != 0) {
            // we move tiles in the direction
            do {
              int newBlankPos = blankPos + dir;
              tiles[blankPos] = tiles[newBlankPos];
              blankPos = newBlankPos;
            } while(blankPos != clickPos);
            
            tiles[blankPos] = 0;
          }
          
          // we check if game is solved
          gameOver = isSolved();
        }
        
        // we repaint panel
        repaint();
      }
    });
    
    newGame();
  }
 
  private void newGame() {
    do {
      reset(); // reset in intial state
      shuffle(); // shuffle
    } while(!isSolvable()); // make it until grid be solvable
    
    gameOver = false;
  }
 
  private void reset() {
    for (int i = 0; i < tiles.length; i++) {
      tiles[i] = (i + 1) % tiles.length;
    }
    
    // we set blank cell at the last
    blankPos = tiles.length - 1;
  }
 
  private void shuffle() {
    // don't include the blank tile in the shuffle, leave in the solved position
    int n = nbTiles;
    
    while (n > 1) {
      int r = RANDOM.nextInt(n--);
      int tmp = tiles[r];
      tiles[r] = tiles[n];
      tiles[n] = tmp;
    }
  }
 
  // Only half permutations of the puzzle are solvable.
  // Whenever a tile is preceded by a tile with higher value it counts
  // as an inversion. In our case, with the blank tile in the solved position,
  // the number of inversions must be even for the puzzle to be solvable
  private boolean isSolvable() {
    int countInversions = 0;
    
    for (int i = 0; i < nbTiles; i++) {
      for (int j = 0; j < i; j++) {
        if (tiles[j] > tiles[i])
          countInversions++;
      }
    }
    
    return countInversions % 2 == 0;
  }
 
  private boolean isSolved() {
    if (tiles[tiles.length - 1] != 0) // if blank tile is not in the solved position ==> not solved
      return false;
    
    for (int i = nbTiles - 1; i >= 0; i--) {
      if (tiles[i] != i + 1)
        return false;      
    }
    
    return true;
  }
 
  private void drawGrid(Graphics2D g) {
    for (int i = 0; i < tiles.length; i++) {
      // we convert 1D coords to 2D coords given the size of the 2D Array
      int r = i / size;
      int c = i % size;
      // we convert in coords on the UI
      int x = margin + c * tileSize;
      int y = margin + r * tileSize;
      
      // check special case for blank tile
      if(tiles[i] == 0) {
        if (gameOver) {
          g.setColor(FOREGROUND_COLOR);
          drawCenteredString(g, "\u2713", x, y);
        }
        
        continue;
      }
      
      // for other tiles
      g.setColor(getForeground());
      g.fillRoundRect(x, y, tileSize, tileSize, 25, 25);
      g.setColor(Color.BLACK);
      g.drawRoundRect(x, y, tileSize, tileSize, 25, 25);
      g.setColor(Color.WHITE);
      
      drawCenteredString(g, String.valueOf(tiles[i]), x , y);
    }
  }
 
  private void drawStartMessage(Graphics2D g) {
    if (gameOver) {
      g.setFont(getFont().deriveFont(Font.BOLD, 18));
      g.setColor(FOREGROUND_COLOR);
      String s = "Click to start new game";
      g.drawString(s, (getWidth() - g.getFontMetrics().stringWidth(s)) / 2,
          getHeight() - margin);
    }
  }
 
  private void drawCenteredString(Graphics2D g, String s, int x, int y) {
    // center string s for the given tile (x,y)
    FontMetrics fm = g.getFontMetrics();
    int asc = fm.getAscent();
    int desc = fm.getDescent();
    g.drawString(s,  x + (tileSize - fm.stringWidth(s)) / 2,
        y + (asc + (tileSize - (asc + desc)) / 2));
  }
 
  @Override
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2D = (Graphics2D) g;
    g2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    drawGrid(g2D);
    drawStartMessage(g2D);
  }
 
  public static void main(String[] args) {
    SwingUtilities.invokeLater(() -> {
      JFrame frame = new JFrame();
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.setTitle("Game of Fifteen");
      frame.setResizable(false);
      frame.add(new GameOfFifteen(4, 550, 30), BorderLayout.CENTER);
      frame.pack();
      // center on the screen
      frame.setLocationRelativeTo(null);
      frame.setVisible(true);
    });
  }
 
 
}

Наконец, играем!


Самое время запустить игру и проверить ее в действии. Поле должно выглядеть следующим образом:



Пробуем решить головоломку. Если все прошло успешно, получаем вот что:



Вот и всё. А вы ожидали большего? :)

Skillbox рекомендует:

Skillbox
172,00
Онлайн-университет профессий будущего
Поделиться публикацией

Комментарии 24

    +2
    Интересно, почему не на JavaFX?
      –1

      Оно мертво, успокойтесь уже.

        +1
        Я недавно немного читал про графику в Java, писали наоборот, что вроде как Swing — штука устаревшая, а JavFX — наоборот на смену ему пришло. Неужели наоборот?
          +2

          Во-первых, JFX — фреймворк. Он навязывает вам своё видение жизненного цикла приложения, и, скажем честно, далеко не всякое приложение захочет, чтобы им рулила GUI'шная библиотека.
          Во-вторых, он имеет проблемы с производительностью, единообразностью работы на разных платформах, проблемы с поддержкой архитектур, отличных от x86 (и это в экосистеме Java, ага). В конце концов, Оракл выкинул JavaFX из базовой платформы, начиная с Java 11, и будущее её весьма туманно.

            0
            Ну, я, вот, сравниваю Swing и часть переписанного на FX, то Свинг просто «режет» глаз по сравнению с ФХ. Но, это мой отдельный случай десктопного приложения.
              0
              >проблемы с поддержкой архитектур, отличных от x86
              И поэтому оно работает на мобильных платформах? Причем как IOS, так и андроид?
                0

                Вот щас бы не различать сторонние порты и официальную поставку.

                  0
                  А при чем тут это? Оно либо работает, либо не работает. Если вас волнует официальность поставки — то да, вам не сюда. JavaFX нынче в open source, также как и JavaEE, кстати.
                    0
                    Сделать хороший, годный софт более открытым и спихнуть умирающую технологию на шею комьюнити — две большие разницы. Тут, впрочем, в обоих случаях именно второй вариант, увы.
                    Вспомните, например, что произошло с глассфишем, когда Оракл перестал его поддерживать.
                      0
                      Вот никогда не пользовался глассфишем, и не огорчаюсь по этому поводу.

                      Вы можете более конкретно рассказать о проблемах, в идеале — разделив свое мнение от объективных фактов? Я вполне допускаю, что проблемы с технологией есть, но слабая поддержка со стороны оракла была всегда — и это как раз никогда не мешало этой технологией пользоваться. Ну, по крайней мере мне. И для таких простых применений, как пятнашки, оно всегда было на порядок проще других альтернатив.
                        0
                        Для пятнашек вообще не важно, что использовать, там же окно с холстом и одна кнопка, даже визуальный редактор запускать смысла нет.
                        Поддержка со стороны Оракла, как мне кажется, многого стоит. Пока Оракл поддерживал Netbeans, он жил, пусть и не пользовался популярностью. Сейчас, через пару лет после снятия с поддержки, Netbeans 10 не может без ковыряния в его кишках даже модуль поддержки Java EE запустить. Пока Glassfish был под Ораклом, он был эталонной реализацией. Как только поддержка закончилась — всё, Glassfish 4-4.1.1 не мог из веб-панели даже пул соединений JDBC создать, падая в 500 по приведению типов. 4.1.2 не проверял, может, так до сих пор и падает. Ну не может сообщество поддерживать качество продуктов, которые ему малоинтересны.
                          +1
                          У JavaFX есть сообщество. И оно вполне себе отдельное от оракла — хотя наверное и не такое большое. Ну то есть, я пессимизм такой понимаю, но как-то разделить его не могу. Пока что для бизнеса (про игры и т.п. не знаю, это не мое) JavaFX очень удобно.

                          Хотя по большому счету почти весь подобный UI, все фреймворки — они испытывают сильную конкуренцию со стороны веб технологий, и если это считать умиранием — то да, они все потихоньку сдают свои позиции.
                +1
                И какова же альтернатива?
                  0
                  по большей части не java
                  очень мало десктопных приложений построено на джаве, а те большие что построены используют чаще всего свинг
                  c++ qt
                  c# wpf\uwp (подвин платформы) + cx с той же xaml разметкой
                  а из хороших примеров свинга — продукты jet brains
          +3
          А я на джаве написал, тетрис, симулятор «жизнь» конуэя ну и змейку естественно до кучи. То же что ли статью выложить???
            +3
            Самое время заняться интерфейсом. Сначала берем класс Jpanel.

            и… пишем всю бизнесс логику прямо там.

            Этому нас учит
            Онлайн-университет профессий будущего
              0
              Я вот день-два назад читал статью о сбросе настроек и удалении своих учёток в телефоне. Другой день — пересказ документации. Также многочисленны дни, когда я читал статьи из 255 символов, с пометкой перевод, чаще всего пары абзацев документации, с пометкой «остальное в следующей статье». Сколько статей о самом лучшем и самом новом девайсе/хостере с эксклюзивной скидкой в 5% для аудитории хабра по промокоду habrcake по ссылке в конце статьи, думаю, говорить не надо. В общем да, пишите. Серьезно. Пожалуйста. Мне было бы куда интересней почитать как вы сделали тетрис (а я вот ни разу его не писал, например), какими подходами пользовались, как решали возникающие проблемы, если были, чем вот это вот все описанное выше. Прошу прощения за ошибки, пишу с телефона. Я вообще почти ничего не комментирую, но вот ваш комментарий как-то зацепил. Не смог сдержаться. Простите
                0

                Я, кстати, несколько лет назад написал на libgdx игру "жизнь" на шейдерах. Видеокарта поле в 1к*1к ячеек по десять тысяч раз в секунду успевала обновлять. Хотел написать об этом на хабр, но подумал, что это будет недостаточно серьёзно)

                0
                а вдруг засмеют… лучше я сначала видео короткое выложу
                ТРИ В ОДНОМ
                  0
                  Годно!
                  Это конечно всего-лишь мой личный вкус, я предпочитаю всегда использовать системный LookAndFeel :)
                    0
                    Блгдрю:)
                  +1

                  Писал "пятнашки" на другом языке, в последствии я переписал часть кода и получилась головоломка. То есть берём картинку, делаем из неё квадрат, пилим на sqrt(size) квадратов, лёгкий уровень 9, средний 16(классический вариант) ну и сложный 25. Вместо плиток были части изображения, один квадратик приходилось выпиливать. Ну там по мелочи всякие рекорды и прочее. Ну и меня смутило в статье то, что после шуфла могут быть нерешаемые варианты, она всегда складывается. Сам в детстве имел эту пластмассовую коробочку, которая была затёрта почти до дыр

                    0

                    *pow не sqrt

                      0
                      Ну и меня смутило в статье то, что после шуфла могут быть нерешаемые варианты, она всегда складывается
                      Не всегда.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.