Pull to refresh

Туториал: Создание простейшей 2D игры на андроид

Reading time10 min
Views152K
Этот туториал предназначен в первую очередь для новичков в разработке под андроид, но может быть будет полезен и более опытным разработчикам. Тут рассказано как создать простейшую 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 и вставляем туда это:

<?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 игра готова. Компилируем, запускаем и смотрим что получилось!
Если кто-то запутался или что-то не работает можно скачать исходник.

Игра, конечно, примитивна. Но её можно усовершенствовать, добавив новые функции. В первую очередь следует реализовать удаление вылетевших за пределы экрана астероидов. Можно сделать чтобы корабль мог стрелять в астероиды, чтобы игра постепенно ускорялась, добавить таймер, таблицу рекордов и прочее. Если это будет вам интересно — напишу продолжение, где всё это опишу.

На этом всё. Пишите отзывы, вопросы, интересующие вас темы для продолжения.
Tags:
Hubs:
+13
Comments8

Articles