Pull to refresh

Кроссплатформенность — это круто

Reading time 8 min
Views 30K
Этот пост участвует в конкурсе „Умные телефоны за умные посты“.

Ни для кого не секрет, что сегодня мобильные игры очень популярны. Возможность написать одну из таких игр есть у каждого разработчика, даже начинающего. Часто возникает вопрос с выбором платформы. Конечно, хочется, чтобы игра была сразу везде: на iOS и Android, на WP7 и MeeGo, на десктопе и в браузере. И чтобы все это можно было лекго реализовать с помощью бесплатных инструментов.



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

Цель игры, изображенной на рисунке выше — успеть попасть по яблоку, пока оно летит вниз. Со временем количество яблок увеличивается, и не пропускать их становится все сложнее. Яблоки падают под произвольным углом, вращаясь и реалистично отскакивая от границ благодаря физическому движку Box2D. Игра будет запускаться на Android, платформах с поддержкой Qt (Symbian, Maemo, MeeGo, Windows, Linux, Mac OS X) и в браузере Google Chrome.

Выбор удобных инструментов



Так как основную часть кода я буду писать на чистом С++ (почему, читайте в конце статьи), IDE для этого подойдет любая. Я выберу Qt Creator, хотя ничего не мешает мне использовать Microsoft Visual Studio или Eclipse, например.


Для платформы Android я остановлюсь на библиотеке libgdx. С ее помощью легко можно рисовать текстуры, проигрывать звуки и делать другие необходимые вещи.


В качестве инструмента для разработки игры на десктопе я возьму Qt. Я давно знаком с этой библиотекой, и она не перестает меня радовать. При использовании Qt я также получу приятный бонус в виде поддержки мобильных операционных систем Symbian, Maemo и MeeGo.


Также специально для этой статьи я с помощью HTML5, javascript и Google Native Client сделаю так, чтобы игра запускалась в браузере Google Chrome. Я буду использовать HTML5 Canvas и Audio, и вы увидите, насколько это легко и просто.


Реализация логики не сложная, поэтому я не буду писать о ней (желающие могут взглянуть на код). Вместо этого я сконцентрируюсь на том, как заставить игру работать на всех операционных системах.

Абстрагируемся от конечной платформы


Как я уже говорил, основная часть кода будет общей для всех платформ. Назовем ее «движок». Мне нужно будет решить две задачи. Первая — вызов методов движка на каждой платформе:

Для этого движок предоставит платформам следующий интерфейс:
class Application
{
public:
    Application();
    ~Application();

    void render();
    void touch(int x, int y);
    //...
};

Вызовы обработчиков рисования и ввода на различных платформах будут вызывать методы из класса Application, например, при использовании Qt это будет выглядеть так:
void QtPlatrom::paintEvent(QPaintEvent *)
{
    QPainter painter(this);
    m_painter = &painter;
    m_app->render();
}

void QtPlatrom::mousePressEvent(QMouseEvent *e)
{
    QWidget::mousePressEvent(e);
    m_app->touch(e->x(), height() - e->y());
}

На Android выйдет немного сложнее, потому что из Java нужно попасть в C++:
private native void renderNative();
private native void touchNative(int x, int y);

static {
    System.loadLibrary("fruitclick");
}

public void render() {
    renderNative();
}

public boolean touchDown(int x, int y, int pointer, int button) {
    touchNative(x, Gdx.graphics.getHeight() - y);
    return false;
}

После этого в C++ вызываются соответствующие методы:
void Java_com_fruitclick_Application_renderNative(JNIEnv* env, jobject thiz)
{
    g_app->render();
}

void Java_com_fruitclick_Application_touchNative(JNIEnv* env, jobject thiz, jint x, jint y)
{
    g_app->touch(x, y);
}

При использовании Native Client в браузере из javascript нельзя напрямую обращаться к С++, вместо этого надо отправлять сообщения модулю, например, строки:
function onTouch(e) {
    var coords = getCursorPosition(e);
    var x = coords[0];
    var y = canvas.height - coords[1];
    var message = "touch " + x.toString() + " " + y.toString();
    FruitclickModule.postMessage(message);
}

function simulate() {
    FruitclickModule.postMessage('render');
    setTimeout("simulate()", 16);
}

В С++ сообщения анализируются, и в зависимости от содержания вызывается тот или иной метод:
void NaclPlatform::HandleMessage(const pp::Var& var)
{
    if (!var.is_string())
        return;

    std::stringstream stream(var.AsString());
    std::string type;
    stream >> type;
    if (type == "render")
    {
        m_app.render();
    }
    else if (type == "touch")
    {
        int x;
        int y;
        stream >> x >> y;
        m_app.touch(x, y);
    }
}

В итоге движку не важно, из какой платформы был вызов, он абстрагировался от этого. Но он знает, что произошло касание экрана в точке (x, y) или пришло время для обработки физики и вывода изображений на экран.

Обратное взаимодействие


Вторая задача — обратное взаимодействие движка с платформой:

Это нужно для того, чтобы движок командовал, когда выводить изображения и текст на экран, проигрывать звук, вибрировать. Для этого все платформы должны реализовать общий интерфейс. Назовем этот интерфейс Platform:
class Platform
{
public:
    enum Texture
    {
        APPLE = 0,
        BACKGROUND
    };

    static void draw(Texture id, float x, float y, float angle = 0);
    static void drawText(const char* text, float x, float y);

    enum Sound
    {
        CRUNCH = 0,
        CRASH
    };

    static void playSound(Sound id);
    static void vibrate();
    //...
};

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


Platform::draw(Platform::BACKGROUND, screenWidth/2, screenHeight/2);
Platform::playSound(Platform::CRASH);


Таким образом движок абстрагируется от деталей реализации различных операций на каждой платформе. Привожу для наглядности диаграмму классов:

Сложно ли все это сделать? Вы убедитесь в том, что нет. Время, конечно, придется потратить, но в большинстве случаев им можно пренебречь в сравнении со временем, потраченным на программирование логики приложения. Я приведу код для платформ Android, Qt и Native Client для каждой необходимой операции:
Рисование изображения, Android (libgdx):
public void draw(int id, float x, float y, float angle) {
    TextureRegion region = null;
    switch (id) {
        case BACKGROUND:
            region = background;
            break;
        case APPLE:
            region = apple;
            break;
        default:
            break;
        }

        float w = region.getRegionWidth();
        float h = region.getRegionHeight();
        batch.draw(region, x - w/2, y - h/2, w/2, h/2, w, h, 1, 1, angle);
}

Рисование изображения, Qt:
void QtPlatrom::drawImpl(Texture id, float x, float y, float angle)
{
    QPixmap* pixmap = NULL;
    switch(id)
    {
    case FruitClick::Platform::APPLE:
        pixmap = &m_apple;
        break;
    case FruitClick::Platform::BACKGROUND:
        pixmap = &m_background;
        break;
    default:
        break;
    }

    y = height() - y;
    m_painter->translate(x, y);
    m_painter->rotate(-angle);

    int w = pixmap->width();
    int h = pixmap->height();
    m_painter->drawPixmap(-w/2, -h/2, w, h, *pixmap);

    m_painter->rotate(angle);
    m_painter->translate(-x, -y);
}

Рисование изображения, javascript (HTML5 Canvas):
function draw(id, x, y, angle) {
    y = canvas.height - y;
    var image = null;
    switch(id) {
    case 0:
        image = apple;
        break;
    case 1:
        image = background;
        break;
    }

    context.translate(x, y);
    context.rotate(-angle);
    context.drawImage(image, -image.width/2, -image.height/2);
    context.rotate(angle);
    context.translate(-x, -y);
}

Рисование текста, Android (libgdx):

public void drawText(String text, float x, float y) {
    font.draw(batch, text, x, y);
}


Рисование текста, Qt:
void QtPlatrom::drawTextImpl(const char *text, float x, float y)
{
    y = height() - y;
    m_painter->drawText(x, y, text);
}

Рисование текста, javascript (HTML5 Canvas):
function drawText(text, x, y) {
    y = canvas.height - y;
    context.fillText(text, x, y);
}

Проигрывание звука, Android (libgdx):
public void playSound(int id) {
    switch (id) {
    case CRUNCH:
        crunch.play();
        break;
    case CRASH:
        crash.play();
        break;
    }
}

Проигрывание звука, Qt:
void QtPlatrom::playSoundImpl(Sound id) {
    switch (id)
    {
    case FruitClick::Platform::CRUNCH:
        m_crunch.play();
        break;
    case FruitClick::Platform::CRASH:
        m_crash.play();
        break;
    default:
        break;
    }
}

Проигрывание звука, javascript (HTML5 Audio):
function playSound(id) {
    var sound = null;
    switch(id) {
    case 0:
        sound = crunch;
        break;
    case 1:
        sound = crash;
        break;
    }

    sound.currentTime = 0;
    sound.play();
}

Вибрация, Android(libgdx):

void vibrate() {
    Gdx.input.vibrate(100);
}


При реализации для Android придется немного повозиться с вызовом java кода из C++ — один раз получить ID нужных java методов:
void setupEnv(JNIEnv* env, jobject thiz)
{
    g_env = env;
    g_activity = thiz;
    g_activityClass = env->GetObjectClass(thiz);

    drawID = env->GetMethodID(g_activityClass, "draw", "(IFFF)V");
    drawTextID = env->GetMethodID(g_activityClass, "drawText", "(Ljava/lang/String;FF)V");
    playSoundID = env->GetMethodID(g_activityClass, "playSound", "(I)V");
}

и потом вызывать их:
void AndroidPlatform::drawImpl(FruitClick::Platform::Texture id, float x, float y, float angle)
{
    g_env->CallVoidMethod(g_activity, drawID, id, x, y, angle);
}

void AndroidPlatform::drawTextImpl(const char* text, float x, float y)
{
    jstring javaString = g_env->NewStringUTF(text);
    g_env->CallVoidMethod(g_activity, drawTextID, javaString, x, y);
}

void AndroidPlatform::playSoundImpl(FruitClick::Platform::Sound id)
{
    g_env->CallVoidMethod(g_activity, playSoundID, id);
}

Нетривиальная ситуация и с Native Client — нужно отправлять сообщения из С++ кода в javascript:
const char* sep = "|";

void NaclPlatform::drawImpl(FruitClick::Platform::Texture id, float x, float y, float angle)
{
    std::stringstream stream;
    stream << "draw" << sep << id << sep << x << sep << y << sep << angle;
    PostMessage(pp::Var(stream.str()));
}

void NaclPlatform::drawTextImpl(const char* text, float x, float y)
{
    std::stringstream stream;
    stream << "drawText" << sep << text << sep << x << sep << y;
    PostMessage(pp::Var(stream.str()));
}

void NaclPlatform::playSoundImpl(FruitClick::Platform::Sound id)
{
    std::stringstream stream;
    stream << "playSound" << sep << id;
    PostMessage(pp::Var(stream.str()));
}

И в javascript эти сообщения парсить:
function handleMessage(message_event) {
    params = message_event.data.split("|");
    if (params[0] == "draw") {
        draw(parseInt(params[1]),
             parseInt(params[2]),
             parseInt(params[3]),
             parseFloat(params[4]));
    }
    else if (params[0] == "drawText") {
        drawText(params[1], parseInt(params[2]), parseInt(params[3]));
    }
    else if (params[0] == "playSound") {
        playSound(parseInt(params[1]));
    }
}


Результат


Эта простая игра называется «Поймай яблочко». Предлагаю запустить и попробовать продержаться пару минут, у меня вначале не получалось:
Native Client версия (убедитесь, что у вас последняя версия браузера Google Chrome, и Native Client включен в about:plugins и about:flags). Размер исполняемого файла nexe — 4.2Мб для 32-битных систем и 4.9Мб для 64-битных, при медленном соединении придется немного подождать;
Windows версия — для тех, кто не любит Google Chrome.

Видео:

Игра прекрасно запускается на Android эмуляторе и моем LG Optimus. Та же ситуация с Qt Simulator (скриншот с Nokia N9 в самом начале темы).

Код

Код можно взять тут, я думаю, он может пригодиться кому-нибудь, особенно участки, которые отвечают за связку Java и C++, javascript и C++ (если по этому поводу у вас возникнут вопросы — задавайте, не стесняйтесь, с удовольствием отвечу).

Зачем все это?


Многие из вас подумают, зачем писать велосипед? Если есть Marmalade или Unity, например. Есть, но они стоят денег, да и зачем такие тяжеловесы для простой 2D игрушки? Некоторые говорят также, что Qt заводится на Android и iOS, но на самом деле на Android не очень так заводится, без звука и OpenGL, а на iOS так вообще, только ролики на YouTube. Мне очень нравится Qt, и я надеюсь, что в недалеком будущем приложения для iOS и Android можно будет писать так же просто, как сейчас для MeeGo, но пока лучше пользоваться другими инструментами для этих платформ.

Преимущества

Используя подход, описанный в этой статье, вы не привязаны к платформе, вы можете использовать те инструменты, которые хотите, а в последующем легко их менять. На десктопе — Qt или GTK, на Android — libgdx или AndEngine, на iOS — cocos2d, выбор за вами. Можете вовсе отказаться от движков, используя API, предоставляемое платформой. Большую часть времени вы можете писать и отлаживать код в вашей любимой IDE на великом и могучем C++.

Недостатки

Недостатки, конечно, тоже есть, например, вы не сможете пользоваться готовыми UI компонентами — вам нужно будет реализовать их на C++. Либо выносить UI часть приложения в каждую платформу. Также вам обязательно придется тесно познакомиться с каждой платформой, но как показывает практика, полностью уйти от этого знакомства никогда не удается.

Продолжение следует?


Вы все еще думаете, что игра для мобильных платформ на C++ — это плохая идея? Посмотрите на Angry Birds. Послушайте замечательное выступление Герба Саттера. Подумайте о том, что поддержка C++ есть почти везде, и что после того, как новый стандарт C++11 реализуют во всех NDK, будет еще лучше.
Tags:
Hubs:
+157
Comments 69
Comments Comments 69

Articles