Этот туториал предназначен в первую очередь для новичков в разработке под андроид, но может быть будет полезен и более опытным разработчикам. Тут рассказано как создать простейшую 2D игру на анроиде без использования каких-либо игровых движков. Для этого я использовал Android Studio, но можно использовать любую другую соответствующее настроенную среду разработки.
Шаг 1. Придумываем идею игры
Для примера возьмём довольно простую идею:
Внизу экрана — космический корабль. Он может двигаться влево и вправо по нажатию соответствующих кнопок. Сверху вертикально вниз движутся астероиды. Они появляются по всей ширине экрана и двигаются с разной скоростью. Корабль должен уворачиваться от метеоритов как можно дольше. Если метеорит попадает в него — игра окончена.
Шаг 2. Создаём проект
В Android Studio в верхнем меню выбираем File → New → New Project.
Тут вводим название приложения, домен и путь. Нажимаем Next.
Тут можно ввести версию андроид. Также можно выбрать андроид часы и телевизор. Но я не уверен что наше приложение на всём этом будет работать. Так что лучше введите всё как на скриншоте. Нажимаем Next.
Тут обязательно выбираем Empty Activity. И жмём Next.
Тут оставляем всё как есть и жмём Finish. Итак проект создан. Переходим ко третьему шагу.
Шаг 3. Добавляем картинки
Скачиваем архив с картинками и распаковываем его.
Находим папку drawable и копируем туда картинки.
Позже они нам понадобятся.
Шаг 4. Создаём layout
Находим activity_main.xml, открываем вкладку Text и вставляем туда это:
На вкладке Design видно как наш layout будет выглядеть.
Сверху поле в котором будет сама игра, а снизу кнопки управления Left и Right. Про layout можно написать отдельную статью, и не одну. Я не буду на этом подробно останавливаться. Про это можно почитать тут.
Шаг 5. Редактируем MainActivity класс
В первую очередь в определение класса добавляем implements View.OnTouchListener. Определение класса теперь будет таким:
Добавим в класс нужные нам статические переменные (переменные класса):
В процедуру protected void onCreate(Bundle savedInstanceState) {
добавляем строки:
Классы LinearLayout, Button и т.д. подсвечены красным потому что ещё не добавлены в Import.
Чтобы добавить в Import и убрать красную подсветку нужно для каждого нажать Alt+Enter.
GameView будет подсвечено красным потому-что этого класса ещё нет. Мы создадим его позже.
Теперь добавляем процедуру:
Если кто-то запутался ― вот так в результате должен выглядеть MainActivity класс:
Итак, класс MainActivity готов! В нём инициирован ещё не созданный класс GameView. И когда нажата левая кнопка — статическая переменная isLeftPressed = true, а когда правая — isRightPressed = true. Это в общем то и всё что он делает.
Для начала сделаем чтобы на экране отображался космический корабль, и чтобы он двигался по нажатию управляющих кнопок. Астероиды оставим на потом.
Шаг 6. Создаём класс GameView
Теперь наконец-то создадим тот самый недостающий класс GameView. Итак приступим. В определение класса добавим extends SurfaceView implements Runnable. Мобильные устройства имею разные разрешения экрана. Это может быть старенький маленький телефон с разрешением 480x800, или большой планшет 1800x2560. Для того чтобы игра выглядела на всех устройствах одинаково я поделил экран на 20 частей по горизонтали и 28 по вертикали. Полученную единицу измерения я назвал юнит. Можно выбрать и другие числа. Главное чтобы отношение между ними примерно сохранялось, иначе изображение будет вытянутым или сжатым.
unitW и unitW мы вычислим позже. Также нам понадобятся и другие переменные:
Конструктор будет таким:
Метод run() будет содержать бесконечный цикл. В начале цикла выполняется метод update()
который будет вычислять новые координаты корабля. Потом метод draw() рисует корабль на экране. И в конце метод control() сделает паузу на 17 миллисекунд. Через 17 миллисекунд run() запустится снова. И так до пока переменная gameRunning == true. Вот эти методы:
Обратите внимание на инициализацию при первом запуске. Там мы вычисляем количество пикселей в юните и добавляем корабль. Корабль мы ещё не создали. Но прежде мы создадим его родительский класс.
Шаг 7. Создаём класс SpaceBody
Он будет родительским для класса Ship (космический корабль) и Asteroid (астероид). В нём будут содержаться все переменные и методы общие для этих двух классов. Добавляем переменные:
и методы
Шаг 8. Создаём класс Ship
Теперь создадим класс Ship (космический корабль). Он наследует класс SpaceBody поэтому в определение класа добавим extends SpaceBody.
Напишем конструктор:
и переопределим метод update()
На этом космический корабль готов! Всё компилируем и запускаем. На экране должен появиться космический корабль. При нажатии на кнопки он должен двигаться вправо и влево. Теперь добавляем сыплющиеся сверху астероиды. При столкновении с кораблём игра заканчивается.
Шаг 9. Создаём класс Asteroid
Добавим класс Asteroid (астероид). Он тоже наследует класс SpaceBody поэтому в определение класса добавим extends SpaceBody.
Добавим нужные нам переменные:
Астероид должен появляться в случайной точке вверху экрана и лететь вниз с случайной скоростью. Для этого x и speed задаются при помощи генератора случайных чисел в его конструкторе.
Астероид должен двигаться с определённой скорость вертикально вниз. Поэтому в методе update() прибавляем к координате x скорость.
Так же нам нужен будет метод определяющий столкнулся ли астероид с кораблём.
Рассмотрим его поподробнее. Для простоты считаем корабль и астероид квадратами. Тут я пошёл от противного. То есть определяю когда квадраты НЕ пересекаются.
((x+size) < shipX) — корабль слева от астероида.
(x > (shipX+shipSize)) — корабль справа от астероида.
((y+size) < shipY) — корабль сверху астероида.
(y > (shipY+shipSize)) — корабль снизу астероида.
Между этими четырьмя выражениями стоит || (или). То есть если хоть одно выражение правдиво (а это значит что квадраты НЕ пересекаются) — результирующие тоже правдиво.
Всё это выражение я инвертирую знаком!. В результате метод возвращает true когда квадраты пересекаются. Что нам и надо.
Про определение пересечения более сложных фигур можно почитать тут.
Шаг 10. Добавляем астероиды в GameView
В GameView добавляем переменные:
также добавляем 2 метода:
И в методе run() добавляем вызовы этих методов перед вызовоом control().
Далее в методе update() добавляем цикл который перебирает все астероиды и вызывает у них метод update().
Такой же цикл добавляем и в метод draw().
Вот и всё! Простейшая 2D игра готова. Компилируем, запускаем и смотрим что получилось!
Если кто-то запутался или что-то не работает можно скачать исходник.
Игра, конечно, примитивна. Но её можно усовершенствовать, добавив новые функции. В первую очередь следует реализовать удаление вылетевших за пределы экрана астероидов. Можно сделать чтобы корабль мог стрелять в астероиды, чтобы игра постепенно ускорялась, добавить таймер, таблицу рекордов и прочее. Если это будет вам интересно — напишу продолжение, где всё это опишу.
На этом всё. Пишите отзывы, вопросы, интересующие вас темы для продолжения.
Шаг 1. Придумываем идею игры
Для примера возьмём довольно простую идею:
Внизу экрана — космический корабль. Он может двигаться влево и вправо по нажатию соответствующих кнопок. Сверху вертикально вниз движутся астероиды. Они появляются по всей ширине экрана и двигаются с разной скоростью. Корабль должен уворачиваться от метеоритов как можно дольше. Если метеорит попадает в него — игра окончена.
Шаг 2. Создаём проект
В Android Studio в верхнем меню выбираем File → New → New Project.
Тут вводим название приложения, домен и путь. Нажимаем Next.
Тут можно ввести версию андроид. Также можно выбрать андроид часы и телевизор. Но я не уверен что наше приложение на всём этом будет работать. Так что лучше введите всё как на скриншоте. Нажимаем Next.
Тут обязательно выбираем Empty Activity. И жмём Next.
Тут оставляем всё как есть и жмём Finish. Итак проект создан. Переходим ко третьему шагу.
Шаг 3. Добавляем картинки
Скачиваем архив с картинками и распаковываем его.
Находим папку drawable и копируем туда картинки.
Позже они нам понадобятся.
Шаг 4. Создаём layout
Находим activity_main.xml, открываем вкладку Text и вставляем туда это:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.spaceavoider.spaceavoider.MainActivity">
<LinearLayout
android:id="@+id/gameLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_weight="100"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/leftButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="50"
android:text="Left" />
<Button
android:id="@+id/rightButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="50"
android:text="Right" />
</LinearLayout>
</LinearLayout>
На вкладке Design видно как наш layout будет выглядеть.
Сверху поле в котором будет сама игра, а снизу кнопки управления Left и Right. Про layout можно написать отдельную статью, и не одну. Я не буду на этом подробно останавливаться. Про это можно почитать тут.
Шаг 5. Редактируем MainActivity класс
В первую очередь в определение класса добавляем implements View.OnTouchListener. Определение класса теперь будет таким:
public class MainActivity extends AppCompatActivity implements View.OnTouchListener {
Добавим в класс нужные нам статические переменные (переменные класса):
public static boolean isLeftPressed = false; // нажата левая кнопка
public static boolean isRightPressed = false; // нажата правая кнопка
В процедуру protected void onCreate(Bundle savedInstanceState) {
добавляем строки:
GameView gameView = new GameView(this); // создаём gameView
LinearLayout gameLayout = (LinearLayout) findViewById(R.id.gameLayout); // находим gameLayout
gameLayout.addView(gameView); // и добавляем в него gameView
Button leftButton = (Button) findViewById(R.id.leftButton); // находим кнопки
Button rightButton = (Button) findViewById(R.id.rightButton);
leftButton.setOnTouchListener(this); // и добавляем этот класс как слушателя (при нажатии сработает onTouch)
rightButton.setOnTouchListener(this);
Классы LinearLayout, Button и т.д. подсвечены красным потому что ещё не добавлены в Import.
Чтобы добавить в Import и убрать красную подсветку нужно для каждого нажать Alt+Enter.
GameView будет подсвечено красным потому-что этого класса ещё нет. Мы создадим его позже.
Теперь добавляем процедуру:
public boolean onTouch(View button, MotionEvent motion) {
switch(button.getId()) { // определяем какая кнопка
case R.id.leftButton:
switch (motion.getAction()) { // определяем нажата или отпущена
case MotionEvent.ACTION_DOWN:
isLeftPressed = true;
break;
case MotionEvent.ACTION_UP:
isLeftPressed = false;
break;
}
break;
case R.id.rightButton:
switch (motion.getAction()) { // определяем нажата или отпущена
case MotionEvent.ACTION_DOWN:
isRightPressed = true;
break;
case MotionEvent.ACTION_UP:
isRightPressed = false;
break;
}
break;
}
return true;
}
Если кто-то запутался ― вот так в результате должен выглядеть MainActivity класс:
package com.spaceavoider.spaceavoider;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
public class MainActivity extends AppCompatActivity implements View.OnTouchListener {
public static boolean isLeftPressed = false; // нажата левая кнопка
public static boolean isRightPressed = false; // нажата правая кнопка
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
GameView gameView = new GameView(this); // создаём gameView
LinearLayout gameLayout = (LinearLayout) findViewById(R.id.gameLayout); // находим gameLayout
gameLayout.addView(gameView); // и добавляем в него gameView
Button leftButton = (Button) findViewById(R.id.leftButton); // находим кнопки
Button rightButton = (Button) findViewById(R.id.rightButton);
leftButton.setOnTouchListener(this); // и добавляем этот класс как слушателя (при нажатии сработает onTouch)
rightButton.setOnTouchListener(this);
}
public boolean onTouch(View button, MotionEvent motion) {
switch(button.getId()) { // определяем какая кнопка
case R.id.leftButton:
switch (motion.getAction()) { // определяем нажата или отпущена
case MotionEvent.ACTION_DOWN:
isLeftPressed = true;
break;
case MotionEvent.ACTION_UP:
isLeftPressed = false;
break;
}
break;
case R.id.rightButton:
switch (motion.getAction()) { // определяем нажата или отпущена
case MotionEvent.ACTION_DOWN:
isRightPressed = true;
break;
case MotionEvent.ACTION_UP:
isRightPressed = false;
break;
}
break;
}
return true;
}
}
Итак, класс MainActivity готов! В нём инициирован ещё не созданный класс GameView. И когда нажата левая кнопка — статическая переменная isLeftPressed = true, а когда правая — isRightPressed = true. Это в общем то и всё что он делает.
Для начала сделаем чтобы на экране отображался космический корабль, и чтобы он двигался по нажатию управляющих кнопок. Астероиды оставим на потом.
Шаг 6. Создаём класс GameView
Теперь наконец-то создадим тот самый недостающий класс GameView. Итак приступим. В определение класса добавим extends SurfaceView implements Runnable. Мобильные устройства имею разные разрешения экрана. Это может быть старенький маленький телефон с разрешением 480x800, или большой планшет 1800x2560. Для того чтобы игра выглядела на всех устройствах одинаково я поделил экран на 20 частей по горизонтали и 28 по вертикали. Полученную единицу измерения я назвал юнит. Можно выбрать и другие числа. Главное чтобы отношение между ними примерно сохранялось, иначе изображение будет вытянутым или сжатым.
public static int maxX = 20; // размер по горизонтали
public static int maxY = 28; // размер по вертикали
public static float unitW = 0; // пикселей в юните по горизонтали
public static float unitH = 0; // пикселей в юните по вертикали
unitW и unitW мы вычислим позже. Также нам понадобятся и другие переменные:
private boolean firstTime = true;
private boolean gameRunning = true;
private Ship ship;
private Thread gameThread = null;
private Paint paint;
private Canvas canvas;
private SurfaceHolder surfaceHolder;
Конструктор будет таким:
public GameView(Context context) {
super(context);
//инициализируем обьекты для рисования
surfaceHolder = getHolder();
paint = new Paint();
// инициализируем поток
gameThread = new Thread(this);
gameThread.start();
}
Метод run() будет содержать бесконечный цикл. В начале цикла выполняется метод update()
который будет вычислять новые координаты корабля. Потом метод draw() рисует корабль на экране. И в конце метод control() сделает паузу на 17 миллисекунд. Через 17 миллисекунд run() запустится снова. И так до пока переменная gameRunning == true. Вот эти методы:
@Override
public void run() {
while (gameRunning) {
update();
draw();
control();
}
}
private void update() {
if(!firstTime) {
ship.update();
}
}
private void draw() {
if (surfaceHolder.getSurface().isValid()) { //проверяем валидный ли surface
if(firstTime){ // инициализация при первом запуске
firstTime = false;
unitW = surfaceHolder.getSurfaceFrame().width()/maxX; // вычисляем число пикселей в юните
unitH = surfaceHolder.getSurfaceFrame().height()/maxY;
ship = new Ship(getContext()); // добавляем корабль
}
canvas = surfaceHolder.lockCanvas(); // закрываем canvas
canvas.drawColor(Color.BLACK); // заполняем фон чёрным
ship.drow(paint, canvas); // рисуем корабль
surfaceHolder.unlockCanvasAndPost(canvas); // открываем canvas
}
}
private void control() { // пауза на 17 миллисекунд
try {
gameThread.sleep(17);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Обратите внимание на инициализацию при первом запуске. Там мы вычисляем количество пикселей в юните и добавляем корабль. Корабль мы ещё не создали. Но прежде мы создадим его родительский класс.
Шаг 7. Создаём класс SpaceBody
Он будет родительским для класса Ship (космический корабль) и Asteroid (астероид). В нём будут содержаться все переменные и методы общие для этих двух классов. Добавляем переменные:
protected float x; // координаты
protected float y;
protected float size; // размер
protected float speed; // скорость
protected int bitmapId; // id картинки
protected Bitmap bitmap; // картинка
и методы
void init(Context context) { // сжимаем картинку до нужных размеров
Bitmap cBitmap = BitmapFactory.decodeResource(context.getResources(), bitmapId);
bitmap = Bitmap.createScaledBitmap(
cBitmap, (int)(size * GameView.unitW), (int)(size * GameView.unitH), false);
cBitmap.recycle();
}
void update(){ // тут будут вычисляться новые координаты
}
void drow(Paint paint, Canvas canvas){ // рисуем картинку
canvas.drawBitmap(bitmap, x*GameView.unitW, y*GameView.unitH, paint);
}
Шаг 8. Создаём класс Ship
Теперь создадим класс Ship (космический корабль). Он наследует класс SpaceBody поэтому в определение класа добавим extends SpaceBody.
Напишем конструктор:
public Ship(Context context) {
bitmapId = R.drawable.ship; // определяем начальные параметры
size = 5;
x=7;
y=GameView.maxY - size - 1;
speed = (float) 0.2;
init(context); // инициализируем корабль
}
и переопределим метод update()
@Override
public void update() { // перемещаем корабль в зависимости от нажатой кнопки
if(MainActivity.isLeftPressed && x >= 0){
x -= speed;
}
if(MainActivity.isRightPressed && x <= GameView.maxX - 5){
x += speed;
}
}
На этом космический корабль готов! Всё компилируем и запускаем. На экране должен появиться космический корабль. При нажатии на кнопки он должен двигаться вправо и влево. Теперь добавляем сыплющиеся сверху астероиды. При столкновении с кораблём игра заканчивается.
Шаг 9. Создаём класс Asteroid
Добавим класс Asteroid (астероид). Он тоже наследует класс SpaceBody поэтому в определение класса добавим extends SpaceBody.
Добавим нужные нам переменные:
private int radius = 2; // радиус
private float minSpeed = (float) 0.1; // минимальная скорость
private float maxSpeed = (float) 0.5; // максимальная скорость
Астероид должен появляться в случайной точке вверху экрана и лететь вниз с случайной скоростью. Для этого x и speed задаются при помощи генератора случайных чисел в его конструкторе.
public Asteroid(Context context) {
Random random = new Random();
bitmapId = R.drawable.asteroid;
y=0;
x = random.nextInt(GameView.maxX) - radius;
size = radius*2;
speed = minSpeed + (maxSpeed - minSpeed) * random.nextFloat();
init(context);
}
Астероид должен двигаться с определённой скорость вертикально вниз. Поэтому в методе update() прибавляем к координате x скорость.
@Override
public void update() {
y += speed;
}
Так же нам нужен будет метод определяющий столкнулся ли астероид с кораблём.
public boolean isCollision(float shipX, float shipY, float shipSize) {
return !(((x+size) < shipX)||(x > (shipX+shipSize))||((y+size) < shipY)||(y > (shipY+shipSize)));
}
Рассмотрим его поподробнее. Для простоты считаем корабль и астероид квадратами. Тут я пошёл от противного. То есть определяю когда квадраты НЕ пересекаются.
((x+size) < shipX) — корабль слева от астероида.
(x > (shipX+shipSize)) — корабль справа от астероида.
((y+size) < shipY) — корабль сверху астероида.
(y > (shipY+shipSize)) — корабль снизу астероида.
Между этими четырьмя выражениями стоит || (или). То есть если хоть одно выражение правдиво (а это значит что квадраты НЕ пересекаются) — результирующие тоже правдиво.
Всё это выражение я инвертирую знаком!. В результате метод возвращает true когда квадраты пересекаются. Что нам и надо.
Про определение пересечения более сложных фигур можно почитать тут.
Шаг 10. Добавляем астероиды в GameView
В GameView добавляем переменные:
private ArrayList<Asteroid> asteroids = new ArrayList<>(); // тут будут харанится астероиды
private final int ASTEROID_INTERVAL = 50; // время через которое появляются астероиды (в итерациях)
private int currentTime = 0;
также добавляем 2 метода:
private void checkCollision(){ // перебираем все астероиды и проверяем не касается ли один из них корабля
for (Asteroid asteroid : asteroids) {
if(asteroid.isCollision(ship.x, ship.y, ship.size)){
// игрок проиграл
gameRunning = false; // останавливаем игру
// TODO добавить анимацию взрыва
}
}
}
private void checkIfNewAsteroid(){ // каждые 50 итераций добавляем новый астероид
if(currentTime >= ASTEROID_INTERVAL){
Asteroid asteroid = new Asteroid(getContext());
asteroids.add(asteroid);
currentTime = 0;
}else{
currentTime ++;
}
}
И в методе run() добавляем вызовы этих методов перед вызовоом control().
@Override
public void run() {
while (gameRunning) {
update();
draw();
checkCollision();
checkIfNewAsteroid();
control();
}
}
Далее в методе update() добавляем цикл который перебирает все астероиды и вызывает у них метод update().
private void update() {
if(!firstTime) {
ship.update();
for (Asteroid asteroid : asteroids) {
asteroid.update();
}
}
}
Такой же цикл добавляем и в метод draw().
private void draw() {
if (surfaceHolder.getSurface().isValid()) { //проверяем валидный ли surface
if(firstTime){ // инициализация при первом запуске
firstTime = false;
unitW = surfaceHolder.getSurfaceFrame().width()/maxX; // вычисляем число пикселей в юните
unitH = surfaceHolder.getSurfaceFrame().height()/maxY;
ship = new Ship(getContext()); // добавляем корабль
}
canvas = surfaceHolder.lockCanvas(); // закрываем canvas
canvas.drawColor(Color.BLACK); // заполняем фон чёрным
ship.drow(paint, canvas); // рисуем корабль
for(Asteroid asteroid: asteroids){ // рисуем астероиды
asteroid.drow(paint, canvas);
}
surfaceHolder.unlockCanvasAndPost(canvas); // открываем canvas
}
}
Вот и всё! Простейшая 2D игра готова. Компилируем, запускаем и смотрим что получилось!
Если кто-то запутался или что-то не работает можно скачать исходник.
Игра, конечно, примитивна. Но её можно усовершенствовать, добавив новые функции. В первую очередь следует реализовать удаление вылетевших за пределы экрана астероидов. Можно сделать чтобы корабль мог стрелять в астероиды, чтобы игра постепенно ускорялась, добавить таймер, таблицу рекордов и прочее. Если это будет вам интересно — напишу продолжение, где всё это опишу.
На этом всё. Пишите отзывы, вопросы, интересующие вас темы для продолжения.