Game Dev: Начало начал

Примитивная игра на LibGDX
Предполагается, что вы уже знаете как создавать проекты для этой библиотеки. Перевод и сокращение оффициальной статьи.
В данной статье мы создадим примитивнейшую игру и познакомимся с азами библиотеки на простом и понятном примере. Ну, я надеюсь. С чем конкретно мы столкнёмся:
  • Доступ к файлам (любой игре нужны картинки, звук и прочие прелести, которые не хранятся в коде)
  • Выводить картинки на экран (базовая составляющая любой нетекстовой игры)
  • Познакомимся с игровой камерой (хотя пример несколько неудачный для этого)
  • Сделаем управление (как-то же мы должны в игру играть)
  • И добавим звуковых эффектов
При создании проекта были использованы следующие данные:
  • Application name: drop
  • Package name: com.badlogic.drop
  • Game class: Drop
Ок, проект создали. Переходим к игре, точнее, к её сути: сверху капают капли, а по нижней части экрана игрок перемещает ведро и ловит эти капли. Вроде всё просто.
Соответственно, для игры нам потребуются изображения ведра и капли. А заодно фоновая музыка и звук капли, упавшей в ведро.
Скачали, что делать дальше? Заходим в папку с проектом. Нам нужна папка assets. Если проект расчитан на андроид, то эта папка находится в подпроекте "android". Если же вы плюнули на андроид, то эта папка лежит в подпроекте "core". Нашли? Теперь кидаем картинки со звуками в папку assets.
Drop\android\assets
Почему ресурсы хранятся именно в паке assets? Потому что в андроид-приложениях основные ресурсы хранятся в этой папке. Все остальные проекты (десктопы, иос...) получают ссылку на эту папку и обращаются к ней, т.е. игра создаётся мультиплатформенная, но ресурсы хранятся для всех платформ в одном месте.)

Файлы запуска

Итак, у нас есть идея и необходимые ресурсы. Осталось написать код (ага, как всё "просто").
Первым делом рассмотрим точки входа в программу, т.е. классы, запускающие игру. У каждой платформы свои особенности архитектуры, поэтому они находятся в отдельных подпроектах.
Файл для запуска на ПК:
Drop\desktop\src\com\badlogic\drop\desktop\DesktopLauncher.java
package com.badlogic.drop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;

public class Main {
   public static void main(String[] args) {
      LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
      config.title = "Drop";	// Задаем название окну игры
      config.width = 800;	// Задаем размеры окна
      config.height = 480;	// Вообще, в конфигурации есть ещё полезные вещи, но о них не в этот раз
      new LwjglApplication(new Drop(), config);
   }
}
Как вы могли заметить, мы используем альбомную ориентацию для игры. Для ПК мы изменили всего один класс, но с андроидом у нас чуть больше возни: помимо запускающего класса, у него есть конфигурационный xml-файл, описывающий то, как должна запускаться программа.
Drop\android\src\com\badlogic\drop\android\AndroidLauncher.java
package com.badlogic.drop;

import android.os.Bundle;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class AndroidLauncher extends AndroidApplication {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);

      AndroidApplicationConfiguration config= new AndroidApplicationConfiguration();
      config.useAccelerometer = false;	// Просто отключаем ненужные нам штуки
      config.useCompass = false;

      initialize(new Drop(), config);
   }
}
Для андроида нельзя указать конкретное разрешение - оно зависит от устройства, поэтому мы просто будем масштабировать игру, изначально сделанную для разрешения 800х480.
Drop\android\AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.badlogic.drop"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="19" />
    <uses-feature android:glEsVersion="0x00020000" android:required="true" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:configChanges="keyboard|keyboardHidden|orientation">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
В нашем примере суть данного файла в том, что он включает использование OpenGL и устанавливает альбомную ориентацию при запуске приложения.
Файлами запуска для HTML и ios являются только классы-лаунчеры, как в случае с ПК.

Класс с игрой

С файлами запуска разобрались, остался класс с самой игрой. Для простоты примера мы сделаем только геймплей.
Структура класса нашей игры выглядит следующим образом:
public class Drop implements ApplicationListener {
   public void create () {
	// Вызывается при запуске игры.
   }

   public void render () {
	// Главный цикл, в котором происходит обновление данных игрового мира, ловится управление и рисуется графика.
   }

   public void resize (int width, int height) {
	// Вызывается при изменении размеров игры.
   }

   public void pause () {
	// На андроиде вызывается при сворачивании игры, на остальных платформах - непосредственно перед dispose().
   }

   public void resume () {
	// Вызывается на андроиде при разворачивании игры.
   }

   public void dispose () {
	// Вызывается при закрытии игры.
   }
}

Использование файлов

Первое, что нам нужно сделать в этом классе - загрузить картинки и звуки. Объявим для них переменные и в методе create() загрузим их.
public class Drop implements ApplicationListener {
   // Блок переменных. Вы ведь знаете, что это значит? Здесь создаём все "глобальные" переменные, к которым будем обращаться по ходу дела.
   private Texture dropImage;
   private Texture bucketImage;
   private Sound dropSound;
   private Music rainMusic;

   @Override
   public void create() {
      // Загружаем картинки ведра и капли (64x64 пикселей каждая).
      dropImage = new Texture(Gdx.files.internal("droplet.png"));
      bucketImage = new Texture(Gdx.files.internal("bucket.png"));

      // Загружаем звук упавшей капли и фоновую музыку.
      dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
      rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));

      // Сразу же запускаем музыку.
      rainMusic.setLooping(true);
      rainMusic.play();

      ...
   }
Библиотека GDX состоит из нескольких статичных модулей, методы которых вызываются из любой точки приложения. Это модули Gdx.files, Gdx.audio и т.д. Каждый модуль имеет методы для работы с соответствующими сущностями, например, files - с файлами, audio - со звуками.
Как вы помните, все наши ресурсы лежат в папке assets. Это корневая папка для внутренних ресурсов игры, поэтому для метода
Gdx.files.internal("bucket.png")
в качестве аргумента идёт относительный путь к файлу, т.е. только само название. Если бы мы положили картинки в папку "images", а звуки - в "sounds", то в качестве аргумента брали "images\bucket.png" и "sounds\rain.mp3".
Разница между "Sound" и "Music" в том, что звуки - короткие аудиофайлы, предназначенные для проигрывания при определённом действии, а музыка - длинный аудиофайл, например, фоновый трек, играющий всё время.

Camera и SpriteBatch

Камера в LibGDX отображает видимую ею область (Viewport) на весь игровой экран. Даже если мы сделаем обзор камеры квадратом в один единственный пиксель, то этот пиксель будет растянут на весь экран.
SpriteBatch отвечает за рисование картинок и связан с OpenGL. OpenGL штука ленивая, вследствие чего картинки стоит загружать не по одной, а одним файлом, который потом уже самостоятельно в коде разбивать на регионы и распихивать по переменным. В нашем случае этого делать не обязательно, у нас всего-то 2 изображения.
Создаём переменные для камеры и батча в блоке переменных:
   private OrthographicCamera camera;
   private SpriteBatch batch;
И сразу же инициализируем их в методе create():
public void create() {
   ...

   camera = new OrthographicCamera();
   camera.setToOrtho(false, 800, 480);

   batch = new SpriteBatch();

   ...
}

Создание ведра

Что из себя представляет ведро (да и капля тоже)? В нашем случае это прямоугольник, который имеет координаты (х,у), ширину/высоту и, разумеется, картинку, которую мы уже загрузили и готовы использовать.
Создаём переменную для нашего единственного и неповторимого ведёрка:
private Rectangle bucket;
и инициализируем его:
public void create() {
   ...

   bucket = new Rectangle();
   bucket.x = 800 / 2 - 64 / 2;
   bucket.y = 20;
   bucket.width = 64;
   bucket.height = 64;

   ...
}
Надеюсь, вы знаете что такое конструктор и внутренние переменные объекта.
Заметьте одну важную особенность LibGDX - ось Y идёт снизу вверх, т.е. начало координат находится в левом нижнем углу. Можно, конечно, включить для камеры стандартную систему отсчета, но сейчас не об этом.

Рисование ведра

Мы создали ведро и загрузили для него картинку, теперь его нужно вывести на экран. Сперва очистим экран и обновим состояние камеры:
public void render() {
   Gdx.gl.glClearColor(0, 0, 0.2f, 1);	// Устанавливает цвет в формате RGBA (от 0 до 1)
   Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);	// Очищает экран выбранным цветом

   camera.update();	// Обновляем обзор камеры

   // Теперь нарисуем само ведро. Для этого используется созданный ранее SpriteBatch.
   batch.setProjectionMatrix(camera.combined);	// Связывает батч с камерой. Теперь то, что рисует батч, видно только этой камере.
   batch.begin();
   batch.draw(bucketImage, bucket.x, bucket.y); // Рисуем ведро в соответсвующих координатах.
   batch.end();

   ...
}
Прицнип работы батча таков, что он накапливает действия после метода begin(), а после вызова end() OpenGL отрисовывает сразу всё, а не по отдельности. Это даёт выигрыш в скорости отрисовки, позволяя нарисовать много спрайтов при большом фпс.
Примечание: во многих официальных и не очень примерах и статьях, написанных давно, используется класс GL10, который в текущей версии LibGDX вообще отсутствует. А вместе с ним и неюзабельны некоторые методы других классов. Будьте внимательнее с кодом.

Перемещение ведра

Для простоты эксперимента пусть наше ведро мгновенно перемещается туда, где мы кликнем мышью (или прикоснёмся пальцем).
public void render() {
   ...

   if(Gdx.input.isTouched()) {		//Если было прикосновение или нажата кнопка мыши
      Vector3 touchPos = new Vector3();				// Создаём точку
      touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);	// Присваиваем ей координаты точки прикосновения
      camera.unproject(touchPos);				// Переводим точку на плоскость камеры
      bucket.x = touchPos.x - 64 / 2;				// Перемещаем туда ведро
   }

   ...
}
За взаимодествия игрока и игры отвечает модуль Gdx.input. Передвижение курсора, нажатие клавиш, использование джостика - за всем этим следит input.
Точку создаём именно трёхмерную в виду того, что камера у нас тоже трёхмерная. Не заморачивайтесь над этим, главное, что мы показываем игроку двумерное пространство, а трётью ось не трогаем и координаты по ней всегда будут равны 0.
Что значит "Переводим точку на плоскость камеры"? У каждого устройства своя архитектура не только в плане электроники, но и железа. Пиксели на экранах устройств тоже иногда отличаются: где-то они квадратные, где-то прямоугольные, где-то шире, где-то уже. Чтобы на всех устройствах приложение работало одинаково, у камеры есть своя разметка пространства, в которую и переводится полученная с экрана точка.
Примечание: плодить локальные переменные в методе, который отрабатывает десятки раз в секунду крайне нехорошо, поэтому вот вам первое "домашнее задание" - перенесите объявление точки в блок переменных и инициализируйте её в методе create().
Как ловить мышку - мы узнали, теперь узнаем, как ловить клавиатуру.
public void render() {
   ...

   if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
   if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

   if(bucket.x < 0) bucket.x = 0;		// Эти две строки не дают ведру
   if(bucket.x > 800 - 64) bucket.x = 800 - 64;	// 	выйти за границы экрана

   ...
}
Тут всё просто:
Если нажата "стрелочка влево" - двигаем ведро левее. Двигаем со скоростью 200 единиц в секунду. Поскольку у нас игра отрисовывается несколько раз в секунду, то каждый кадр мы перемещаем ведро не на 200 единиц, а на часть от 200. Метод getDeltaTime() возвращает время, прошедшее с прошлого выполнения метода. Аналогично двигаем вправо.

Создаём капли

В отличие от ведра, капля у нас будет не одна. Они будут падать, мы будем их ловить. Сперва объявим динамический массив для капель.
   private Array<Rectangle> raindrops;
Необходимо заметить, что LibGDX имеет свои виды коллекций. Они аналогичны стандартным, но "легче и быстрее". Лежат они в пакете "com.badlogic.gdx.utils".
Также нам необходимо знать, когда было предыдущее падение капли.
  private long lastDropTime;
Тип long используется по той причине, что будут сохраняться наносекунды.
Теперь нам нужно создать каплю и заставить её падать вниз. Для этих целей мы, пожалуй, выделим целый метод.
public class Drop implements ApplicationListener {
   public void create () {
	...
   }

   public void render () {
	...
   }

   ...

   private void spawnRaindrop() {
      Rectangle raindrop = new Rectangle();	// Создаём новую каплю
      raindrop.x = MathUtils.random(0, 800-64);	// Располагаем её в случайном месте
      raindrop.y = 480;
      raindrop.width = 64;
      raindrop.height = 64;
      raindrops.add(raindrop);			// Добавляем каплю в массив
      lastDropTime = TimeUtils.nanoTime();	// Засекаем время появления этой капли
   }
}
Теперь мы можем инициализировать массив и создать первую каплю.
public void create () {
   ...

   raindrops = new Array<Rectangle>();
   spawnRaindrop();
}
Теперь сделаем так, чтобы капли периодически появлялись.
public void render () {
   ...

   if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();

   ...
}
И сразу же опишем процесс их падения:
public void render () {
   ...

   Iterator<Rectangle> iter = raindrops.iterator();
   while(iter.hasNext()) {
      Rectangle raindrop = iter.next();
      raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
      if(raindrop.y + 64 < 0) iter.remove();
   }
}
Напоминаю, что итератор позволяет нам работать с объектами как с элементами динамического массива.
Данный цикл устанавливает положение каждой капли всё ниже и ниже, т.е. создаёт падение капель. И уничтожает их, когда капля касается нижней части экрана.
Но капли тоже нужно отрисовывать, иначе как же мы будем ловить их в ведро?
Помните, где мы рисовали ведро? Добавляем туда же отрисовку капель:
public void render() {
   ...

   // Для отрисовки объектов используется один и тот же SpriteBatch.
   batch.setProjectionMatrix(camera.combined);	// Связывает батч с камерой. Теперь то, что рисует батч, видно только этой камере.
   batch.begin();
   batch.draw(bucketImage, bucket.x, bucket.y); // Рисуем ведро в соответсвующих координатах.
   for(Rectangle raindrop: raindrops) {		// Рисуем все капли
      batch.draw(dropImage, raindrop.x, raindrop.y);
   }
   batch.end();

   ...
}
И последнее, что нам осталось сделать - это отловить тот момент, когда капля попадает в ведро, т.е. их прямоугольники (вы ведь помните, что наши объекты имеют форму прямоугольника?) соприкосаются. У Rectangle для определения этого есть метод overlaps(Rectangle r).
public void render() {
   ...

   Iterator<Rectangle> iter = raindrops.iterator();
   while(iter.hasNext()) {
      Rectangle raindrop = iter.next();
      raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
      if(raindrop.y + 64 < 0) iter.remove();
	  if(raindrop.overlaps(bucket)) {
	     dropSound.play();	// Проигрываем звук падения капли
         iter.remove();		// И удаляем каплю
      }
   }
}

Очистка памяти

Пользователь в любой момент может выключить игру и нам надо будет освободить всё, что мы взяли у операционной системы. Любой класс данной библиотеки, расширенный интерфейсом Disposable должен быть уничтожен методом dispose(). В нашем случае это текстуры и звуки.
public class Drop implements ApplicationListener {
   ...

   public void dispose() {
      dropImage.dispose();
      bucketImage.dispose();
      dropSound.dispose();
      rainMusic.dispose();
      batch.dispose();
   }
}
Всё потому, что такие объекты мало связаны с Java и зависят от системы, и сборщик мусора их просто не видит.
Вот и всё, можете немножко поиграться.
» Весь код файла Drop.java
package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;

public class Drop implements ApplicationListener {
	   // Блок переменных. Вы ведь знаете, что это значит? Здесь создаём все "глобальные" переменные, к которым будем обращаться по ходу дела.
	   private Texture dropImage;
	   private Texture bucketImage;
	   private Sound dropSound;
	   private Music rainMusic;
	   private OrthographicCamera camera;
	   private SpriteBatch batch;
	   private Rectangle bucket;
	   private Array<Rectangle> raindrops;
	   private long lastDropTime;

	   @Override
	   public void create() {
	      // Загружаем картинки ведра и капли (64x64 пикселей каждая).
	      dropImage = new Texture(Gdx.files.internal("droplet.png"));
	      bucketImage = new Texture(Gdx.files.internal("bucket.png"));

	      // Загружаем звук упавшей капли и фоновую музыку.
	      //dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
	      rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));

	      // Сразу же запускаем музыку.
	      rainMusic.setLooping(true);
	      rainMusic.play();

	      // Создание камеры
	      camera = new OrthographicCamera();
	      camera.setToOrtho(false, 800, 480);
	      // и батча
	      batch = new SpriteBatch();
	      
	      bucket = new Rectangle();
	      bucket.x = 800 / 2 - 64 / 2;
	      bucket.y = 20;
	      bucket.width = 64;
	      bucket.height = 64;

		  raindrops = new Array<Rectangle>();
		  spawnRaindrop();
		    
	   }

	@Override
	public void resize(int width, int height) {
		// TODO Auto-generated method stub
		
	}

	@Override
	public void render() {
		Gdx.gl.glClearColor(0, 0, 0.2f, 1);	// Устанавливает цвет в формате RGBA (от 0 до 1)
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);	// Очищает экран выбранным цветом

	    camera.update();	// Обновляем обзор камеры

	    // Для отрисовки объектов используется один и тот же SpriteBatch.
	    batch.setProjectionMatrix(camera.combined);	// Связывает батч с камерой. Теперь то, что рисует батч, видно только этой камере.
	    batch.begin();
	    batch.draw(bucketImage, bucket.x, bucket.y); // Рисуем ведро в соответсвующих координатах.
	    for(Rectangle raindrop: raindrops) {		// Рисуем все капли
	       batch.draw(dropImage, raindrop.x, raindrop.y);
	    }
	    batch.end();
	    
	    if(Gdx.input.isTouched()) {		//Если было прикосновение или нажата кнопка мыши
	        Vector3 touchPos = new Vector3();				// Создаём точку
	        touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);	// Присваиваем ей координаты точки прикосновения
	        camera.unproject(touchPos);				// Переводим точку на плоскость камеры
	        bucket.x = touchPos.x - 64 / 2;				// Перемещаем туда ведро
	     }
	    
	    
	    if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
	    if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

	    if(bucket.x < 0) bucket.x = 0;		// Эти две строки не дают ведру
	    if(bucket.x > 800 - 64) bucket.x = 800 - 64;	// 	выйти за границы экрана
	    
	    if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();
	    
	    Iterator<Rectangle> iter = raindrops.iterator();
	    while(iter.hasNext()) {
	       Rectangle raindrop = iter.next();
	       raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
	       if(raindrop.y + 64 < 0) iter.remove();
	       
	       if(raindrop.overlaps(bucket)) {
	    	      //dropSound.play();	// Проигрываем звук падения капли
	    	      iter.remove();	// И удаляем каплю
	       }
	    }
	    
	    
		
	}

	@Override
	public void pause() {
		// TODO Auto-generated method stub
		
	}

	@Override
	public void resume() {
		// TODO Auto-generated method stub
		
	}

	@Override
	public void dispose() {
		dropImage.dispose();
	    bucketImage.dispose();
	    dropSound.dispose();
	    rainMusic.dispose();
	    batch.dispose();
		
	}
	
	private void spawnRaindrop() {
	      Rectangle raindrop = new Rectangle();	// Создаём новую каплю
	      raindrop.x = MathUtils.random(0, 800-64);	// Располагаем её в случайном месте
	      raindrop.y = 480;
	      raindrop.width = 64;
	      raindrop.height = 64;
	      raindrops.add(raindrop);			// Добавляем каплю в массив
	      lastDropTime = TimeUtils.nanoTime();	// Засекаем время появления этой капли
	}
	
}

Просмотров: 1 765

Комментарии пока отсутcтвуют