Corona Native для Android — использование произвольного Java кода в игре написанной на Corona

Игровой движок Corona позволяет создавать кроссплатформенные приложения и игры. Но иногда предоставляемого им API бывает недостаточно. Для таких случаев есть Corona Native, позволяющий расширять функциональность с использованием родного кода для каждой платформы.

В статье пойдёт речь об использовании Java в проектах Corona для android

Для понимания происходящего в статье требуются базовые знания Java, Lua и движка Corona

На компьютере должны быть установлены Corona и Android Studio

В папке с установкой Corona также находится шаблон проекта: Native\Project Template\App. Копируем всю папку и переименовываем в имя своего проекта.


Настройка шаблона

Примечание: я использовал последний доступный public build для Corona — 2017.3184. В новых версиях шаблон может измениться, и некоторые приготовления из этой главы перестанут быть нужны.

Для android нам нужны 2 папки внутри: Corona и android

Из папки Corona удаляем Images.xcassets и LaunchScreen.storyboardc — эти папки нам не понадобятся. В файле main.lua также удаляем весь код — мы начнём создание проекта с нуля. Если вы хотите использовать существующий проект, то замените все файлы в папке Corona на свои

Папка android — это готовый проект для Android Studio, нам нужно открыть его. Первым же сообщением от студии будет «Gradle sync failed». Нужно исправить build.gradle:

build gradle

Чтобы исправить ситуацию, добавляем ссылку на repositories в buildscript. Я также изменил версию в classpath 'com.android.tools.build: gradle' на более новую.


Код build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    dependencies {
        classpath 'com.android.tools.build:gradle:3.1.3'
    }
    repositories {
        jcenter()
        google()
    }
}

allprojects {
    repositories {
        jcenter()
        google()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Следующим шагом будет изменение gradle-wrapper.properties. Изменить можно вручную, заменив в distributionUrl версию gradle. Или позволить студии всё сделать за вас.

gradle-wrapper.properties

Дополнительно нужно поправить build.gradle для модуля app: в cleanAssets нужно добавить строчку delete »$projectDir/build/intermediates/jniLibs», без которой придётся делать clean проекта перед каждым запуском (взято отсюда)

Теперь синхронизация удалась, осталось только несколько warnings, связанных с устаревшей buildToolsVersion и старым синтаксисом в конфигурации. Поправить их не составит труда.

Теперь в студии мы видим 2 модуля: app и plugin. Стоит переименовать приложение (com.mycompany.app) и плагин (plugin.library), перед тем как продолжить работу.

Далее в коде плагин будет называться plugin.habrExamplePlugin

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


Код LuaLoader
package plugin.habrExamplePlugin;

import com.naef.jnlua.JavaFunction;
import com.naef.jnlua.LuaState;

@SuppressWarnings({"WeakerAccess", "unused"})
public class LuaLoader implements JavaFunction {
    @Override
    public int invoke(LuaState luaState) {
        return 0;
    }   
}

Для биндинга между java и lua кодом в Corona Native используется jnlua. LuaLoader реализует интерфейс jnlua.JavaFunction, таким образом его метод invoke доступен из lua кода. Чтобы удостовериться, что всё в порядке, добавим логгирующий код в LuaLoader.invoke и сделаем require плагина в main.lua

    @Override
    public int invoke(LuaState luaState) {
        Log.d("Corona native", "Lua Loader invoke called");
            return 0;
    }
local habrPlugin = require("plugin.habrExamplePlugin")
print("test:", habrPlugin)

Запустив приложение, среди логов увидим следующие 2 строчки:


D/Corona native: Lua Loader invoke called
I/Corona: test true

Итак, наше приложение загрузило плагин, а require возвращает true. Теперь попробуем вернуть из Java кода lua-таблицу с функциями.

Для добавления функций в модуль воспользуемся интерфейсом jnlua.NamedJavaFunction. Пример простой функции без аргументов и без возвращаемого значения:

class HelloHabrFunction implements NamedJavaFunction {
    @Override
    public String getName() {
        return "helloHabr";
    }

    @Override
    public int invoke(LuaState L) {
        Log.d("Corona native", "Hello Habr!");

        return 0;
    }
}

Для регистрации нашей новой функции в lua используем метод LuaState.register:

public class LuaLoader implements JavaFunction {
    @Override
    public int invoke(LuaState luaState) {
    Log.d("Corona native", "Lua Loader invoke called");

        String libName = luaState.toString(1); // получаем имя модуля из стека (первый параметр require)
        NamedJavaFunction[] luaFunctions = new NamedJavaFunction[]{
                new HelloHabrFunction(), // создаём экземпляр нашей функции
        };
        luaState.register(libName, luaFunctions); // регистрируем наш модуль, он помещается наверх стека

        // Цифра 1 показывает сколько аргументов из стека вернётся в lua код. 
        // Но в случае с require это ни на что не повлияет, require вернёт только наш модуль
        return 1;
    }

Данный код требует дополнительных пояснений:

LuaState, параметр метода invoke, по сути представляет обёртку над виртуальной машиной Lua (прошу меня скорректировать если я неверно выразился). Для тех, кто знаком с использованием lua кода из C, LuaState представляет собой то же, что и указатель lua_State в C.

Для тех, кто хочет углубиться в дебри работы с lua, рекомендую почитать мануал, начиная с The Application Program Interface

Итак, при вызове функции invoke мы получаем LuaState. У него есть стек, который содержит параметры, переданные в нашу функцию из lua кода. В данном случае это имя модуля, поскольку LuaLoader исполняется в момент вызова require («plugin.habrExamplePlugin»).

Возвращаемое функцией invoke число показывает количество переменных из стека, которое вернётся в lua код. В случае с вызовом require это число ни на что не влияет, но мы воспользуемся этим знанием позже, создав функцию, возвращающую несколько значений

Помимо функций мы также можем добавить в модуль дополнительные поля, например версию:

    luaState.register(libName, luaFunctions); // регистрируем наш модуль, он будет расположен на вершине стека
    luaState.pushString("0.1.2"); // кладём в стек строку
    luaState.setField(-2, "version"); // установка поля version у нашего модуля.

В данном случае мы воспользовались индексом -2, чтобы указать, что поле нужно установить у нашего модуля. Отрицательный индекс означает, что отсчёт начинается с конца стека. -1 будет указывать на строку »0.1.2» (в lua индексы начинаются с единицы).

Чтобы не засорять стек, после установки поля я рекомендую вызывать luaState.pop (1) — выбрасывает из стека 1 элемент.


Полный код LuaLoader
@SuppressWarnings({"WeakerAccess", "unused"})
public class LuaLoader implements JavaFunction {
    @Override
    public int invoke(LuaState luaState) {
        Log.d("Corona native", "Lua Loader invoke called");

        String libName = luaState.toString(1); // получаем имя модуля из стека (первый параметр require)
        NamedJavaFunction[] luaFunctions = new NamedJavaFunction[]{
                new HelloHabrFunction(), // создаём экземпляр нашей функции
        };
        luaState.register(libName, luaFunctions); // регистрируем наш модуль, он помещается наверх стека

        luaState.register(libName, luaFunctions); // регистрируем наш модуль, он будет расположен на вершине стека
        luaState.pushString("0.1.2"); // кладём в стек строку
        luaState.setField(-2, "version"); // установка поля version у нашего модуля.
        // Цифра 1 показывает сколько аргументов из стека вернётся в lua код.
        // Но в случае с require это ни на что не повлияет, require вернёт только наш модуль
        return 0;
    }
}


Пример функции, которая принимает несколько строк и конкатенирует их через String builder

Реализация:

class StringJoinFunction implements NamedJavaFunction{
    @Override
    public String getName() {
        return "stringJoin";
    }

    @Override
    public int invoke(LuaState luaState) {
        int currentStackIndex = 1;
        StringBuilder stringBuilder = new StringBuilder();
        while (!luaState.isNone(currentStackIndex)){
            String str = luaState.toString(currentStackIndex);
            if (str != null){ //toString возвращает null для non-string и non-number, игнорируем
                stringBuilder.append(str);
            }
            currentStackIndex++;
        }

        luaState.pushString(stringBuilder.toString());

        return 1;
    }
}

Использование в lua:

local joinedString = habrPlugin.stringJoin("this", " ", "was", " ", "concated", " ", "by", " ", "Java", "!", " ", "some", " ", "number", " : ", 42);

print(joinedString)


Пример возврата нескольких значений

class SumFunction implements NamedJavaFunction{
Override
public String getName () {
return «sum»;
}

@Override
public int invoke(LuaState luaState) {
    if (!luaState.isNumber(1)  || !luaState.isNumber(2)){
        luaState.pushNil();
        luaState.pushString("Arguments should be numbers!");
        return 2;
    }

    int firstNumber = luaState.toInteger(1);
    int secondNumber = luaState.toInteger(1);

    luaState.pushInteger(firstNumber + secondNumber);

    return 1;
}

}

В библиотеке jnlua есть специальный класс JavaReflector, который отвечает за создание lua таблицы из java объекта. Таким образом можно писать классы на java и отдавать их в lua код для дальнейшего использования.

Сделать это достаточно просто:

Пример класса

@SuppressWarnings({"unused"})
public class Calculator {
    public int sum(int number1, int number2){
        return number1 + number2;
    }

    public static int someStaticMethod(){
        return 4;
    }
}

Добавление экземпляра этого класса к нашему модулю

        luaState.pushJavaObject(new Calculator());
        luaState.setField(-2, "calc");
        luaState.pop(1);

Использование в Lua:

local calc = habrPlugin.calc

print("call method of java object", calc:sum(3,4))
print("call static method of java object", calc:getClass():someStaticMethod())

Обратите внимание на двоеточие в вызове метода класса. Для статических методов также нужно использовать двоеточие.

Тут я заметил интересную особенность рефлектора: если мы передаём в lua только экземпляр класса, то вызов его статического метода возможен через getClass (). Но после вызова через getClass () последующие вызовы буду срабатывать и на самом объекте:

print("call method of java object", calc:sum(3,4)) -- ok
print("exception here", calc:someStaticMethod()) -- бросает исключение "com.naef.jnlua.LuaRuntimeException: no method of class plugin.habrExamplePlugin.Calculator matches 'someStaticMethod()'"
print("call static method of java object", calc:getClass():someStaticMethod()) -- ok
print("hmm", calc:someStaticMethod()) -- после вызова через getClass мы получили возможность работать с этим методом напрямую

Также, используя getClass (), мы можем создавать новые объекты прямо в lua:

local newInstance = calc:getClass():new()

К сожалению, сохранить Calculator.class в поле модуля мне не удалось из-за «java.lang.IllegalArgumentException: illegal type» внутри setField.

Этот раздел появился по причине того, что корона не предоставляет возможность обратиться к функциям из своего api напрямую в Java. Но jnlua.LuaState позволяет загружать и выполнять произвольный lua код:

class CreateDisplayTextFunction implements NamedJavaFunction{
    // Вызываем функцию из API короны
    private static String code = "local text = ...;" +
            "return display.newText({" +
            "text = text," +
            "x = 160," +
            "y = 200," +
            "});";

    @Override
    public String getName() {
        return "createText";
    }

    @Override
    public int invoke(LuaState luaState) {
        luaState.load(code,"CreateDisplayTextFunction code"); // загружаем код в стек, создавая из него функцию
        luaState.pushValue(1); // помещаем первый параметр функции на вершину стека
        luaState.call(1, 1); // вызываем нашу функцию, указываем что она должна получить 1 параметр, а также вернуть 1

        return 1;
    }
}

Не забудьте зарегистрировать функцию через LuaLoader.invoke, аналогично предыдущим примерам

Вызов в lua:

habrPlugin.createText("Hello Habr!")

Таким образом, ваше приложение на android может использовать все нативные возможности платформы. Единственный недостаток этого решения — вы лишаетесь возможности использовать Corona Simulator, что замедляет разработку (перезапуск симулятора практически мгновенен, в отличие от отладки на эмуляторе или устройстве, который требует build + install)


  1. Полный код доступен на гитхабе


  2. Документация по Corona Native


3) Один из репозиториев jnlua. Помог мне разобраться в назначении некоторых функций.

© Habrahabr.ru