пятница, 27 марта 2009 г.

Пишем игру для Android. Часть 3. Управление игровыми объектами

В этой статье мы рассмотрим две темы: управление игровыми объектами и их взаимодействие. Мячик у нас уже летает, осталось сделать, чтобы он отражался от стен и ракеток; также стоит реализовать управление нижней ракетки игроком, а верхней — неким алгоритмом. Итак, приступим.

Движение мячика

Для начала добавим в GameObject следующие полезные функции:

GameObject.java

/** Верхняя граница объекта */
public int getTop() { return mPoint.y; }

/** Нижняя граница объекта */
public int getBottom() { return mPoint.y + mHeight; }

/** Левая граница объекта */
public int getLeft() { return mPoint.x; }

/** Правая граница объекта */
public int getRight() { return mPoint.x + mWidth; }

/** Центральная точка объекта */
public Point getCenter() { return new Point(mPoint.x + mWidth / 2, mPoint.y + mHeight / 2); }

/** Высота объекта */
public int getHeight() { return mHeight; }

/** Ширина объекта */
public int getWidth() { return mWidth; }

/** @return Прямоугольник, ограничивающий объект */
public Rect getRect() { return mImage.getBounds(); }

/** Проверяет, пересекаются ли два игровых объекта */
public static boolean intersects(GameObject obj1, GameObject obj2)
{
    return Rect.intersects(obj1.getRect(), obj2.getRect());
}

Игровые объекты ничего не знают ни о друг друге, ни об игровом поле, поэтому все столкновения будут обрабатываться GameManager-ом. Итак, рассмотрим сначала такую ситуацию:

Столкновение со стеной

Итак, наш мячик был в некотором состоянии A, потом, пройдя расстояние mSpeed в заданном направлении, перешел в состояние B, и оказалось, что он вышел за пределы поля. Тут надо сделать следующее: поместить шарик в правильное состояние C, получившееся при отражении соответствующей координаты от стены, и изменить направление движения так, чтобы угол падения был равен углу отражения.

Расчет координат при столкновении

На рисунке выше показано, какие координаты будет иметь мячик при столкновении со стеной слева. Аналогично можно рассчитать и остальные случаи.

Вычисление нового направления движения

Рассмотрим варианты столкновения. Пусть α — угол, под которым движется мячик, а β — угол, получившийся после столкновения. Посмотрим, как β зависит от α в различных случаях:

Значения αСтолкновение с вертикальюСтолкновение с горизонталью
10 < α < π / 2β = π − αβ = 2π − α
2π / 2 < α < πβ = π − αβ = 2π − α
3π < α < 3π / 2β = 3π − αβ = 2π − α
43π / 2 < α < 2πβ = 3π − αβ = 2π − α

Выяснив всй это, можно добавить в класс Ball следующие функции:

Ball.java

/** Отражение мячика от вертикали */
public void reflectVertical()
{
    if (mAngle > 0 && mAngle < PI)
        mAngle = PI - mAngle;
    else
        mAngle = 3 * PI - mAngle;
}

/** Отражение мячика от горизонтали */
public void reflectHorizontal()
{
    mAngle = 2 * PI - mAngle;
}

Обновление же в GameManager изменится таким образом:

GameManager.java

private void updateObjects()
{
    mBall.update();
    mUs.update();
    mThem.update();

    // проверка столкновения мячика с вертикальными стенами
     if (mBall.getLeft() <= mField.left)
     {
      mBall.setLeft(mField.left + Math.abs(mField.left - mBall.getLeft()));
      mBall.reflectVertical();
     }
     else if (mBall.getRight() >= mField.right)
     {
      mBall.setRight(mField.right - Math.abs(mField.right - mBall.getRight()));
      mBall.reflectVertical();
     }

    // проверка столкновения мячика с горизонтальными стенами
     if (mBall.getTop() <= mField.top)
     {
      mBall.setTop(mField.top + Math.abs(mField.top - mBall.getTop()));
      mBall.reflectHorizontal();
     }
     else if (mBall.getBottom() >= mField.bottom)
     {
      mBall.setBottom(mField.bottom - Math.abs(mField.bottom - mBall.getBottom()));
      mBall.reflectHorizontal();
     }
}

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

Управление ракеткой

Перемещать ракетку можно двумя способами. Первый — отлавливать нажатие кнопок вправо и влево, и при нажатии смещать ракетку в нужную сторону. Однако, как показала практика, такой способ не очень хорош, так как ракетка двигается резко и подтормаживает при первом нажатии. Более прогрессивным способом оказался другой: при нажатии клавиши назначать ракетке соответствующий Direction, а при отпускании обнулять его. А в методе updateObjects ракетка сама перемещается в том направлении, которое у нее указано.

Итак, теперь надо бы написать код для обработки нажатия клавиш. Вообще, нажатие клавиш ловит View, и для обработки нужно перегрузить функции onKeyDown и onKeyUp. Однако, игровыми объектами у нас ведает GameManager, так что фактическая обработка будет происходить именно там. Так что добавляем в GameView следующее:

GameView.java

@Override
public boolean onKeyDown(int keyCode, KeyEvent event)
{
    return mGameManager.doKeyDown(keyCode);
}

@Override
public boolean onKeyUp(int keyCode, KeyEvent event)
{
    return mGameManager.doKeyUp(keyCode);
}

А в GameManager нужно добавить методы doKeyDown и doKeyUp, который будут выполнять всю работу:

GameView.java

/**
 * Обработка нажатия кнопки
 * @param keyCode Код нажатой кнопки
 * @return Было ли обработано нажатие
 */
public boolean doKeyDown(int keyCode)
{
    switch (keyCode)
    {
        case KeyEvent.KEYCODE_DPAD_LEFT:
            mUs.setDirection(GameObject.DIR_LEFT);
            return true;
        case KeyEvent.KEYCODE_DPAD_RIGHT:
            mUs.setDirection(GameObject.DIR_RIGHT);
            return true;
        default:
            return false;
    }
}
/**
 * Обработка отпускания кнопки
 * @param keyCode Код кнопки
 * @return Было ли обработано действие
 */
public boolean doKeyUp(int keyCode)
{
    if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
        keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)
    {
        mUs.setDirection(GameObject.DIR_NONE);
        return true;
    }
    return false;
}

Однако, если мы запустим сейчас наше приложение, окажется, что обработка клавиш не работает. View попросту не получает эти события. Чтобы исправить положение, нужно в конструктор GameView добавить одну строчку:

GameView.java

public GameView(Context context, AttributeSet attrs)
{
    super(context, attrs);

    // подписываемся на события Surface
    mSurfaceHolder = getHolder();
    mSurfaceHolder.addCallback(this);

    mGameManager = new GameManager(mSurfaceHolder, context);
    setFocusable(true);
}

Теперь все работает, ракетка управляется.

Искусственный интеллект для противника

У компьютера логика будет простая: по возможности не допускать, чтобы мячик выходил за пределы ракетки. Если мячик окажется правее ракетки, ракетка едет направо, если левее — налево. Реализуем всю эту логику в методе moveAI() у GameManager-а:

GameManager.java

private void moveAI()
{
    if (mThem.getLeft() > mBall.getRight())
        mThem.setDirection(GameObject.DIR_LEFT);
    else if (mThem.getRight() < mBall.getLeft())
        mThem.setDirection(GameObject.DIR_RIGHT);
    mThem.update();
}

А GameManager.updateObjects() будет выглядеть так:

GameManager.java

private void updateObjects()
{
    mBall.update();
    mUs.update();
    moveAI();
    // обработка столкновений     ... }

Еще пара доработок

Ракетки выходят за пределы экрана

Собственно, никто им этого делать не запрещает. Напишем в GameManager такой метод:

GameManager.java

private void placeInBounds(Racquet r)
{
    if (r.getLeft() < mField.left)
        r.setLeft(mField.left);
    else if (r.getRight() > mField.right)
        r.setRight(mField.right);
}

А в GameManager.updateObjects() добавим:

GameManager.java

private void updateObjects()
{
    mBall.update();
    mUs.update();
    moveAI();
    // чтобы ракетки не выходили за пределы поля     placeInBounds(mUs);     placeInBounds(mThem);
    // обработка столкновений     ... }

Теперь все стало хорошо, ракетки не выходят за пределы поля.

Столкновение мячика с ракетками

Сейчас у нас мячик летает, отражаясь от стен, а хотелось бы, чтобы он летал, отражаясь только от вертикальных стен и от ракеток. Убираем из GameManager.updateObjects() весь код, осуществляющий отражение от горизонтальных стен, и пишем код для отражения от ракеток. Код достаточно прост:

GameManager.java

private void updateObjects()
{
    // Обновление положений объектов
    ...

    // Обработка столкновений с вертикальными стенами
    ...
    // проверка столкновений мячика с ракетками     if (GameObject.intersects(mBall, mUs))   {   mBall.setBottom(mUs.getBottom() - Math.abs(mUs.getBottom() - mBall.getBottom()));   mBall.reflectHorizontal();   }   else if (GameObject.intersects(mBall, mThem))   {   mBall.setTop(mThem.getTop() + Math.abs(mThem.getTop() - mBall.getTop()));   mBall.reflectHorizontal();   }
}

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

Итак

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

Исходники для этой части

12 комментариев:

std.denis комментирует...

А разве отражение от стенки это не просто инверсия излишка вовнутрь? :)
Т.е. если удар был об левую стенку, то инвертируем знак у отрицательной координаты X. А если об правую, то координата правой стенки минус интервал, на который координата X объекта вылез за стенку.
А если обобщить, то получаем формулу: correctX = borderX - ( badX - borderX ),
где correctX - правильная/скорректированная координата; borderX - координата стенки (напр. "0" для левой и "300" для правой); badX - вылезшая за пределы координата

И еще вместо угла направления можно было бы использовать dX и dY (= Тогда не нужно математики с углами, а при ударах просто инвертировать скорость по абсциссе или ординате. А при столкновениях с ракетками можно не только изменять знак горизонтальной скорости, но и изменять её абсолютную величину в зависимости от скорости ракетки в момент удара

darja комментирует...

От углов я бы отказываться не стала, там и так все достаточно просто. А вот отражение у меня сделано коряво, Ваша идея гораздо лучше.
Теперь надо полстатьи переписывать...

Nick Pepper комментирует...

std.denis имхо и по поводу углов прав абсолютно - то, что он предлагает, правда, уже лет 20 как минимум является стандартом обработки такого рода движения в играх (если я, конечно, не ошибаюсь и ничего не путаю ;)

Дамы и господа, а у меня вот вопрос возник - помогите нубу, пожалуйста, если можете: как побороть "залипание клавиш" при перемещении ракетки игрока?

serhii solodilov комментирует...

есть ли возможность вернуть картинки на свои места?

darja комментирует...

2 Солодилов Сергей Викторович:
Да, злой яндекс залочил мои картинки. Теперь они на месте

OnDroid комментирует...

Так экземпляр класса GameManager в предыдущих статьях назывался mThread, а тут для событий onKeyDown и onKeyUp предлагается использовать mGameManager.

Второй косяк:

«А в GameManager нужно добавить методы doKeyDown и doKeyUp, который будут выполнять всю работу:»
А далее кусок исходника, но имя файла помечено как «GameView.java», хотя по логике должно быть «GameManager.java».

Анонимный комментирует...

OnDroid - ты зануда! Я в восторге от мануала

Sharkanry комментирует...

Сюда бы AndEngine прикрутить, в плане физики шарика, эх уроков мало по нему :( А цикл статей про игру очень познавательный спасибо.
Давно хочу сделать свою игру. Но поскольку сам художник; вот и разбираюсь потихоньку с помощью таких материалов).

Анонимный комментирует...

Логика отражения мячика от стены не совсем правильная и запутанная.

Если мячик оказывается за границей, в точке B, то надо его переместить сперва в точку пересечения с границей. А во втором шаге уже ставить в точку C.

Проверки на углы и т.д. тоже можно убрать. Это только запутывает код.

Т.е. можно вынести приращение в отдельные перменные
vx = (int)Math.round(mSpeed * Math.cos(angle));
vy = (int)Math.round(mSpeed * Math.sin(angle));

и если выходит за границу экрана, умнажать vx*=-1;

Тогда приращение будет автоматом меняться.

А вобщем хороший блог. Помогает начинающим.

Леша комментирует...

А чем собственно ракетка управляется? ведь на форме, где ракетки, нет никаких кнопок. Подскажите начинающему. пожалуйста. Я пытался собрать. На виртуальной машине ракетка мертво стоит... и никак и ничем не движется.

darja комментирует...

В 2009 году на андроидах были хардверные джойстки или кнопки. Сейчас их, увы, нет, Можно запустить на эмуляторе

Анонимный комментирует...

А как зделать сенсорное управление ракеткой?