«Пятнашка» на Java — как да разработим пълноценна игра

«Пятнашка» на Java — как да разработим пълноценна игра

„Петнадесет“, или „Петнашка“ — прекрасен пример на лесна логическа игра, популярна по целия свят. За да разрешите гатанката, трябва да подредите квадратчетата с цифри по ред, от по-малко към повече. Това не е лесно, но е интересно.

В днешния учебник показваме как да разработим „Петнашка“ на Java 8 с Eclipse. За разработката на UI ще използваме Swing API.

Напомняме: за всички читатели на "Хабра" — отстъпка от 10 000 рубли при записване на всякакъв курс на Skillbox с промокод "Хабр".

Skillbox препоръчва: Образователен онлайн курс «Професия Java разработчик».

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

На този етап трябва да определите свойствата:

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

Логика на играта

Трябва да определите метода за нулиране (reset), използван за инициализиране на нова игрална позиция. По този начин задаваме стойност за всеки елемент от масива на петнашките. След това поставяме blankPos в последната позиция на масива.

Също така е необходим метод за разбъркване за размесване на масива на петнашките. Ние не включваме празната петнашка в процеса на разбъркване, за да я оставим в предишната й позиция.

Тъй като само половината от възможните начални позиции на гатанката имат решение, трябва да проверим получения резултат от разбъркването, за да се уверим, че текущото подреждане изобщо е разрешимо. За да направим това, определяме метода isSolvable.

Ако конкретна петнашка е предшествана от петнашка с по-висока стойност, това се счита за инверсия. Когато празната петнашка е на своето място, броят на инверсиите трябва да бъде четен, за да може гатанката да бъде разрешима. Така че, ние броим броя инверсии и връщаме true, ако броят е четен.

След това е важно да определим метода isSolved, за да проверим дали нашето подреждане Game Of Fifteen е разрешено. Първо проверяваме къде се намира празната петнашка. Ако е в началната позиция, то текущото подреждане е ново, което все още не е решено. След това преминаваме през плочките в обратен ред, и ако стойността на петнашката се различава от съответния индекс +1, връщаме false. В противен случай, в края на метода можем да върнем true, защото гатанката вече е решена.

Друг метод, който трябва да определим, е newGame. Той е необходим за създаване на нов екземпляр на играта. За целта ние нулираме игралното поле, след това го разбъркваме и продължаваме, докато игралната позиция не стане разрешима.

Ето пример на код с ключовата логика на пъзела:

private void newGame() {
  do {
    reset(); // възстановяване на първоначално състояние
    shuffle(); // разбъркване
  } while(!isSolvable()); // правим го, докато мрежата стане разрешима
 
  gameOver = false;
}
 
private void reset() {
  for (int i = 0; i  1) {
    int r = RANDOM.nextInt(n--);
    int tmp = tiles[r];
    tiles[r] = tiles[n];
    tiles[n] = tmp;
  }
}
 
// Само половината от пермутациите на пъзела са разрешими/
// Всяка пътка, предшествана от пътка с по-висока стойност, се брои
// като инверсия. В нашия случай, с празната плочка в разрешената позиция,
// броят на инверсиите трябва да бъде четен, за да бъде пъзелът разрешим
private boolean isSolvable() {
  int countInversions = 0;
 
  for (int i = 0; i < nbTiles; i++) {
    for (int j = 0; j  tiles[i])
        countInversions++;
    }
  }
 
  return countInversions % 2 == 0;
}
 
private boolean isSolved() {
  if (tiles[tiles.length - 1] != 0) // ако празната плочка не е в разрешената позиция ==> не е разрешен
    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;

Разработваме интерфейс на 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++) {
    // преобразуем 1D координаты в 2D координаты с учетом размера 2D массива
    int r = i / size;
    int c = i % size;
    // преобразуем в координаты на UI
    int x = margin + c * tileSize;
    int y = margin + r * tileSize;

    // проверяем специальный случай для пустой плитки
    if(tiles[i] == 0) {
      if (gameOver) {
        g.setColor(FOREGROUND_COLOR);
        drawCenteredString(g, "u2713", x, y);
      }

      continue;
    }

    // для других плиток
    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) {
  // центрираме стринг s за дадената плочка (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) {
    // използва се, за да позволи на потребителите да взаимодействат с полето, кликвайки
    // време е да имплементираме взаимодействие с потребителите, за да преместим плочките и да решим играта!
    if (gameOver) {
      newGame();
    } else {
      // получаваме позицията на клика
      int ex = e.getX() - margin;
      int ey = e.getY() - margin;

      // кликваме ли в полето?
      if (ex  gridSize || ey  gridSize)
        return;

      // получаваме позицията в полето
      int c1 = ex / tileSize;
      int r1 = ey / tileSize;

      // получаваме позицията на празната клетка
      int c2 = blankPos % size;
      int r2 = blankPos / size;

      // преобразуваме в 1D координати
      int clickPos = r1 * size + c1;

      int dir = 0;

      // търсим посока за множество движения на плочките наведнъж
      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) {
        // преминаваме плочките в посоката
        do {
          int newBlankPos = blankPos + dir;
          tiles[blankPos] = tiles[newBlankPos];
          blankPos = newBlankPos;
        } while(blankPos != clickPos);

        tiles[blankPos] = 0;
      }

      // проверяваме дали играта е решена
      gameOver = isSolved();
    }

    // преизчертаване на панела
    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;
 
// Ще създадем играта Пъзел от 15 с Java 8 и Swing
// Ако имате въпроси, не се колебайте да прочетете коментарите ;)
public class GameOfFifteen extends JPanel { // нашата решетка ще бъде нарисувана в отделен панел
 
  // Размер на нашата инстанция на играта Пъзел от 15
  private int size;
  // Брой плочки
  private int nbTiles;
  // Размер на графичния интерфейс на решетката
  private int dimension;
  // Цвят на преден план
  private static final Color FOREGROUND_COLOR = new Color(239, 83, 80); // използваме произволен цвят
  // Обект Random за разместване на плочките
  private static final Random RANDOM = new Random();
  // Съхранение на плочките в едномерен масив от цели числа
  private int[] tiles;
  // Размер на плочка в графичния интерфейс
  private int tileSize;
  // Позиция на празната плочка
  private int blankPos;
  // Марж за решетката на екрана
  private int margin;
  // Размер на графичния интерфейс на решетката
  private int gridSize;
  private boolean gameOver; // true ако играта е свършила, false в противен случай
 
  public GameOfFifteen(int size, int dim, int mar) {
    this.size = size;
    dimension = dim;
    margin = mar;
    
    // инициализация на плочките
    nbTiles = size * size - 1; // -1, защото не броим празната плочка
    tiles = new int[size * 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) {
        // използва се за взаимодействие с потребителите чрез щракане в решетката
        // време е да внедрим взаимодействие с потребителите за преместване на плочките за решаване на играта!
        if (gameOver) {
          newGame();
        } else {
          // получаваме позицията на щракването
          int ex = e.getX() - margin;
          int ey = e.getY() - margin;
          
          // щраквам в решетката?
          if (ex  gridSize  || ey  gridSize)
            return;
          
          // получаваме позицията в решетката
          int c1 = ex / tileSize;
          int r1 = ey / tileSize;
          
          // получаваме позицията на празната клетка
          int c2 = blankPos % size;
          int r2 = blankPos / size;
          
          // конвертираме в 1D координати
          int clickPos = r1 * size + c1;
          
          int dir = 0;
          
          // търсим посока за множество премествания на плочки наведнъж
          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) {
            // преместваме плочките в посоката
            do {
              int newBlankPos = blankPos + dir;
              tiles[blankPos] = tiles[newBlankPos];
              blankPos = newBlankPos;
            } while(blankPos != clickPos);
            
            tiles[blankPos] = 0;
          }
          
          // проверяваме дали играта е решена
          gameOver = isSolved();
        }
        
        // прерисуваме панела
        repaint();
      }
    });
    
    newGame();
  }
 
  private void newGame() {
    do {
      reset(); // нулираме в начално състояние
      shuffle(); // разбъркваме
    } while(!isSolvable()); // правим го, докато решетката стане разрешима
    
    gameOver = false;
  }
 
  private void reset() {
    for (int i = 0; i  1) {
      int r = RANDOM.nextInt(n--);
      int tmp = tiles[r];
      tiles[r] = tiles[n];
      tiles[n] = tmp;
    }
  }
 
  // Само половината перестановки на пъзела са разрешими.
  // Всяка плочка, предшествана от плочка с по-висока стойност, се брои
  // като инверсия. В нашия случай, с празната плочка в решената позиция,
  // броят на инверсиите трябва да е четен, за да бъде пъзелът разрешим
  private boolean isSolvable() {
    int countInversions = 0;
    
    for (int i = 0; i < nbTiles; i++) {
      for (int j = 0; j  tiles[i])
          countInversions++;
      }
    }
    
    return countInversions % 2 == 0;
  }
 
  private boolean isSolved() {
    if (tiles[tiles.length - 1] != 0) // ако празната плочка не е в разрешената позиция ==> не е решен
      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  {
      JFrame frame = new JFrame();
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.setTitle("Игра на Петнадесет");
      frame.setResizable(false);
      frame.add(new GameOfFifteen(4, 550, 30), BorderLayout.CENTER);
      frame.pack();
      // центрираме на екрана
      frame.setLocationRelativeTo(null);
      frame.setVisible(true);
    });
  }
 
 
}

Най-сетне, играем!

Настъпи времето да стартирате играта и да я видите в действие. Полето трябва да изглежда по следния начин:

«Пятнашка» на Java — как да разработим пълноценна игра

Опитваме се да решим пъзела. Ако всичко е минало успешно, получаваме следното:

«Пятнашка» на Java — как да разработим пълноценна игра

Е, това е всичко. Очаквахте ли нещо повече? 🙂

Skillbox препоръчва:

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster