Как написать своего сапёра на Java за 15 минут

Нам понадобятся:

  • 15 минут свободного времени;
  • Настроенная рабочая среда, т.е. JDK и IDE (например Eclipse);
  • Библиотека LWJGL (версии 2.x.x) для работы с Open GL. Обратите внимание, что для LWJGL версий выше 3 потребуется написать код, отличающийся от того, что приведён в статье;
  • Иконки для клеток, т.е. цифры, флаг, неверно поставленный флаг, мина, взорвавшаяся мина и закрытое поле. Можно чисто символически нарисовать самому, или скачать использовавшиеся при написании статьи.

Работа с графикой

Для работы с графикой создадим отдельный класс – GUI. От него нам потребуется хранение всех графических элементов управления (т.е. полей клеток), определение элемента, по которому пришёлся клик и передача ему управления, вывод графических элементов на экран и управление основными функциями OpenGL.

Благо класс GUI будет взаимодействовать с графическими элементами, нам нужно создать интерфейс (писать классы сетки, клеток и прочую механику пока рано), который определит, что это такое. Логика подсказывает, что у графического элемента должны быть:

  • Внешний вид (текстура);
  • Координаты;
  • Размеры (ширина и высота);
  • Метод, который по переданным координатам клика определит, попал ли клик по элементу;
  • Метод, который обработает нажатие на элемент.

Таким образом, пишем:

public interface GUIElement {
	
	int getWidth();
	
	int getHeight();

	int getY();

	int getX();

        Sprite getSprite(); ///Создадим enum с таким именем, заполним позже
	
	int receiveClick(int x, int y, int button); /// Возвращаем результат клика
        ///Параметр button определяет кнопку мыши, которой был сделан щелчок.	

        /// Здесь используется фишка Java 8 --- default методы в интерфейсах.
        /// Если у вас более ранняя версия, вы можете использовать абстрактный класс
        /// вместо интерфейса.
	default boolean isHit(int xclick, int yclick){
		return ( (xclick > getX()) && (xclick < getX()+this.getWidth()) )
                    &&( (yclick > getY()) && (yclick < getY()+this.getHeight()) );
	}
}

В GUI должны храниться ячейки поля. Создадим для этих целей двумерный массив:

///CELLS_COUNT_X и CELLS_COUNT_Y -- константы
//Cell -- класс, который реализует GUIElement; им займёмся немного позже
private static Cell[][] cells;

GUI должен передавать клики элементам, которые он содержит. Вычислить адрес клетки, по которой кликнули, нетрудно:

public static int receiveClick(int x, int y, int button){
		int cell_x = x/CELL_SIZE;
		int cell_y = y/CELL_SIZE;
		
		return cells[cell_x][cell_y].receiveClick(x,y,button);
	}

Теперь разберёмся с основными функциями OpenGL. Во-первых, нам нужна инициализация.

///Class GUI

private static void initializeOpenGL(){	
		try {
                       //Задаём размер будущего окна
			Display.setDisplayMode(new DisplayMode(SCREEN_WIDTH, SCREEN_HEIGHT));

                       //Задаём имя будущего окна
			Display.setTitle(NAME);

                       //Создаём окно
			Display.create();		
		} catch (LWJGLException e) {
			e.printStackTrace();
		}
		
		glMatrixMode(GL_PROJECTION);
		glLoadIdentity();
        glOrtho(0,SCREEN_WIDTH,0,SCREEN_HEIGHT,1,-1);
		glMatrixMode(GL_MODELVIEW);
		
		/*
		 * Для поддержки текстур
		 */
        glEnable(GL_TEXTURE_2D);
		
		/*
		 * Для поддержки прозрачности
		 */
		glEnable(GL_BLEND);
		glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
		
                	
		/*
		 * Белый фоновый цвет
		 */
		glClearColor(1,1,1,1);
	}

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

///Этот метод будет вызываться извне
public static void update() {
		updateOpenGL();
}

///А этот метод будет использоваться только локально,
/// т.к. базовым другие классы должны работать на более высоком уровне
private static void updateOpenGL() {
                Display.update();
                Display.sync(60);	
}

И, наконец, нам нужно это изображение вообще рисовать. Для этого пора закончить enum Sprite. Его элементы будут представлять из себя обёртку для текстуры с удобочитаемыми именами.

///enum Sprite

///Файлы со всеми этими именами должны лежать по адресу
/// *папка проекта*/res/*имя текстуры*.png
ZERO("0"), ONE("1"), TWO("2"), THREE("3"), FOUR("4"), FIVE("5"), SIX("6"),
SEVEN("7"), EIGHT("8"), HIDEN("space"), BOMB("bomb"), EXPLOSION("explosion"),
FLAG("flag"), BROKEN_FLAG("broken_flag");
	
private Texture texture;

private Sprite(String texturename){
try {
        this.texture = TextureLoader.getTexture("PNG", new FileInputStream(new File("res/"+texturename+".png")));
} catch (IOException e) {
	e.printStackTrace();
}

}

public Texture getTexture(){
     return this.texture;
}

Теперь мы можем написать метод для GUI, который будет рисовать элементы:

///Рисует все клетки
public static void draw(){
                ///Очищает экран от старого изображения
		glClear(GL_COLOR_BUFFER_BIT);
		
		for(GUIElement[] line:cells){
			for(GUIElement cell:line){
				drawElement(cell);
			}
		}
	}

///Рисует элемент, переданный в аргументе
private static void drawElement(GUIElement elem){


		elem.getSprite().getTexture().bind();
		
		glBegin(GL_QUADS);
			glTexCoord2f(0,0);
			glVertex2f(elem.getX(),elem.getY()+elem.getHeight());
			glTexCoord2f(1,0);
			glVertex2f(elem.getX()+elem.getWidth(),elem.getY()+elem.getHeight());
			glTexCoord2f(1,1);
			glVertex2f(elem.getX()+elem.getWidth(), elem.getY());
			glTexCoord2f(0,1);
			glVertex2f(elem.getX(), elem.getY());
		glEnd();
	}

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

public static void init(){
	initializeOpenGL();
		
        ///Классом генератора мы займёмся чуть позже. Пока можно просто
        ///создать его, вместе с пустым методом generate
	this.cells = Generator.generate();
}

Ячейки

Создадим класс Cell, реализующий интерфейс GUIElement. В методах getWidth() и getHeight() вернём константу, для координат придётся создать поля, которые будут инициализироваться конструктором. Так же конструктором будем передавать состояние клетки: “-1”, если это мина, “-2”, если это взорванная мина, число мин поблизости в остальных случаях. Для этой цели можно было бы использовать enum, но число мин удобнее передавать как integer, имхо. Итак, конструктор:

private int x;
	private int y;
	private int state;
	
	public Cell (int x, int y, int state){
		this.x=x;
		this.y=y;
		this.state=state;
	}

Ещё два поля — boolean isMarked и boolean isHidden будут отвечать за то, отметили ли клетку флажком, и открыли ли её. По умолчанию оба флага выставлены на false.

Разберёмся с методом getSprite().

public Sprite getSprite() {
		if(this.isMarked){
			if(!this.isHidden && this.state!=-1){
				///Если эта клетка не скрыта, и на ней
				///ошибочно стоит флажок...
				return Sprite.BROKEN_FLAG;
			}
			///В другом случае --
			return Sprite.FLAG;				
		}else if(this.isHidden){
			///Если клетка не помечена, притом скрыта...
			return Sprite.HIDEN;
		}else{
			///Если не помечена и не скрыта, выводим как есть:
			switch (state){
			case -2:
				return Sprite.EXPLOSION;
			case -1:
				return Sprite.BOMB;
			default:
				assert (state>=0 && state<=8): "Some crap :c";
				///Сделал массив для удобства. Можете, конечно,
				///Писать 9 кейсов -- ваш выбор 😉
				return  skin_by_number[state];
			}
		}
	}

В случае, если на кнопку нажали, нам снова необходимо рассмотреть несколько простейших случаев:

@Override
	public int receiveClick(int x, int y, int button) {
		if(isHidden){
			///Нет смысла обрабатывать клики по уже открытым полям
			if(button==0 && !this.isMarked){
				///Здесь обработаем щелчки левой кнопкой
				///Заметим, что щёлкать левой кнопкой по флагам
				///абсолютно бессмысленно
				
				///Открываем клетку
				this.isHidden = false;
				
				if(this.state==-1){
					///Если это была мина, меняем состояние
					///на взорванную и передаём сигнал назад
					this.state=-2;
					return -1;
				}
				
				if(this.state == 0){
					///Если мы попали в нолик, нужно открыть
					///Все соседние ячейки. Этим займётся GUI 🙂
					return 1;
				}
				
			}else if(button==1){
				///В любой ситуации, щелчок правой кнопкой
				///либо снимает отметку, либо ставит её
				this.isMarked = ! this.isMarked;
			}
		}
		
		return 0;
	}

Чтобы при поражении клетки можно было вскрыть, добавим метод:

public void show() {
		this.isHidden=false;
	}

Для более удобной реализации генератора добавьте ещё и этот метод:

public void incNearMines() {
		if(state<0){
			//ignore
		}else{		
			state++;
		}
	}

Обработка ответов от клеток

Вернёмся к методу GUI.receiveClick(). Теперь мы не можем просто вернуть результат назад, т.к. если результат выполнения — единица, то нам нужно открыть соседние ячейки, а в главный управляющий класс вернуть уже ноль, в знак того, что всё прошло корректно.

public static int receiveClick(int x, int y, int button){
		int cell_x = x/CELL_SIZE;
		int cell_y = y/CELL_SIZE;
		
		int result =  cells[cell_x][cell_y].receiveClick(x,y,button);
		
		if(result==1){
			///Делаем вид, что тыкнули в клетки
			///Сверху, снизу, справа и слева
            ///Игнорируем выхождение за границы поля
            try{
				receiveClick(x+CELL_SIZE,y,button);
	                }catch(java.lang.ArrayIndexOutOfBoundsException e){
				//ignore
			}
			try{
				receiveClick(x-CELL_SIZE,y,button);
			}catch(java.lang.ArrayIndexOutOfBoundsException e){
				//ignore
			}
			try{
				receiveClick(x,y+CELL_SIZE,button);
			}catch(java.lang.ArrayIndexOutOfBoundsException e){
				//ignore
			}
			try{
				receiveClick(x,y-CELL_SIZE,button);
			}catch(java.lang.ArrayIndexOutOfBoundsException e){
				//ignore
			}
			
			return 0;
		}
		
		return result;
	}

Пишем генератор

Задачка эта не сложнее, чем создать массив случайных boolean-величин. Идея следующая – для каждой ячейки матрицы мы генерируем случайное число от 0 до 100. Если это число  меньше 15, то в этом месте записываем в матрицу мину (таким образом, шанс встретить мину – 15%). Записав мину, мы вызываем у всех клеток вокруг метод incNearMines(), а для тех ячеек, где клетка ещё не создана храним значение в специальном массиве.

public static Cell[][] generate() {
		{
			Random rnd = new Random();
			
			///Карта, которую мы вернём
			Cell[][] map = new Cell[CELLS_COUNT_X][CELLS_COUNT_Y];
			
			///Матрица с пометками, указывается кол-во мин рядом с каждой клеткой
			int[][] counts = new int[CELLS_COUNT_X][CELLS_COUNT_Y];
			
			for(int x=0; x<CELLS_COUNT_X; x++){
				for(int y=0; y<CELLS_COUNT_Y; y++){
					boolean isMine = rnd.nextInt(100)<15;
					
					if(isMine){
						map[x][y] = new Cell(x*CELL_SIZE, y*CELL_SIZE, -1);
						
						for(int i=-1; i<2; i++){
							for(int j=-1; j<2; j++){
								try{
									if(map[x+i][y+j]==null){
										///Если клетки там ещё нет, записываем сведение
										///о мине в матрицу
										counts[x+i][y+j]+=1;
									}else{
										///Если есть, говорим ей о появлении мины
										map[x+i][y+j].incNearMines();
									}
								}catch(java.lang.ArrayIndexOutOfBoundsException e){
									//ignore
								}
							}
						}
					}else{
						///Если была сгенерирована обычная клетка, создаём её, со
						///state равным значению из матрицы
						map[x][y] = new Cell(x*CELL_SIZE, y*CELL_SIZE, counts[x][y]);
					}
				}
			}
			
			return map;
		}
	}

Главный управляющий класс и ввод

Создадим класс Main, в нём входной метод — public static void main(String[] args). Этот метод должен будет делать всего две вещи: вызывать инициализацию GUI и циклически вызывать рабочие методы (input(), GUI.draw() и GUI.update()), пока не получит сигнал закрытия.

private static boolean end_of_game=false;

public static void main(String[] args) {
		GUI.init();
		
		while(!end_of_game){
			input();
			
			GUI.draw();
			
			GUI.update();
		}
	}

Здесь нам не хватает метода input(), займёмся им.

///Если за последний такт произошли какие-то события с мышью,
///перебираем их по очереди
while(Mouse.next()){
                ///Если это было нажатие кнопки мыши, а не
                ///перемещение...
		if(Mouse.getEventButton()>=0 && Mouse.getEventButtonState()){
			int result;
	                ///Отсылаем это на обработку в GUI
			result = GUI.receiveClick(Mouse.getEventX(), Mouse.getEventY(), Mouse.getEventButton());
			
			switch(result){
			case 0:
				//отлично!
				break;
			case -1:
				//не очень :c				
				GUI.gameover();
				break;
			}
		}
	}
	
        ///То же самое с клавиатурой
	while(Keyboard.next()){
		if(Keyboard.getEventKeyState()){
			if(Keyboard.getEventKey()==Keyboard.KEY_ESCAPE){
				isExitRequested = true;
			}
		}
	}

        ///Обрабатываем клик по кнопке "закрыть" окна
        isExitRequested=isExitRequested || Display.isCloseRequested();

Метод GUI.gameover() будет просто вызывать метод show() у каждой клетки, показывая таким образом всё поле:

public static void gameover() {
		for(Cell[] line:cells){
			for(Cell cell:line){
				cell.show();
			}
		}		
	}

Запускаем: gameplay

Готово!

UPD: исходники выложены на GitHub