Привет, Хабр! ???? В этом топике хочу поговорить о незаслуженно забытом, бесплатном фреймворке для разработки кросс-платформенных игр - LibGDX. Поделиться секретами своей кухни и решениями, которые я использую при разработке своих игр-головоломок. Ворнинг! Много кода под катом.
Экран
Начать, наверное, нужно с вьюпорта - того как игра будет выглядеть и масштабироваться на различных устройствах. Моя основная цель - это мобильные устройства, Android / iOS. Соответственно, актуальные соотношения сторон экрана будут плавать между 19.5:9 и 4:3. Узкие и более квадратные экраны, смартфоны и планшеты, проще говоря.
В LibGDX есть несколько видов вьюпортов. Нас интересует FillViewport
, потому что он сохраняет соотношение сторон, не растягивая и не сжимая игровой мир на экране устройства. Как это работает? Да просто картинка обрезается сверху-снизу, когда реальное соотношение сторон экрана не соответствует "базовому". То есть на планшете мы будем видеть полную картину, больше декораций, а на смартфоне такую же по ширине, но несколько обрезанную по высоте.
Из этого, получаем один ключевой принцип: при размещении игровых объектов, мы должны следить за тем, чтобы все важное/интерактивное размещалось в "игровой области" - части игрового мира, которая видна всегда, на любом устройстве. Также, есть возможность в рантайме определить фактический верх-низ экрана, чтобы "прикрепить" к нему какие-то объекты. Например: кнопку меню, счетчик очков и т.п. Далее, я покажу как это сделать.
Настала пора разбавить текст кодом. Основной класс игры, наследуемый от ApplicationAdapter
, отвечает за отрисовку каждого кадра, в нем крутится и "игровой цикл" - код оживляющий мир, передвигающий объекты, меняющий кадры анимации и т.д. Все это происходит в методе render()
.
public class GdxGame extends ApplicationAdapter {
private OrthographicCamera camera;
private Viewport viewport;
private SimpleStage stage;
private AssetManager manager;
private Snd sound;
public static GdxGame self() {
return (GdxGame) Gdx.app.getApplicationListener();
}
@Override
public void create() {
camera = new OrthographicCamera();
viewport = new FillViewport(GdxViewport.WORLD_WIDTH, GdxViewport.WORLD_HEIGHT, camera);
manager = new AssetManager();
sound = new Snd();
final SimpleStage splash = new Splash(viewport);
splash.load();
setStage(splash);
}
public void setStage(SimpleStage stage) {
this.stage = stage;
}
@Override
public void render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); // очищаем экран
if (stage != null) {
stage.act();
stage.draw();
}
}
@Override
public void resize(int width, int height) {
viewport.update(width, height, true);
GdxViewport.resize(width, height);
}
}
Из интересного: здесь есть статический метод self()
- для удобства получения главного класса из любого места игры. А уже через его поля, мы можем взаимодействовать с различными вспомогательными классами, такими как менеджер ресурсов, звук, хранимые настройки, переменные игровой сессии, в общем, все что нам может пригодиться.
Событие resize()
вызывается один раз при запуске приложения и его я использую как раз для того чтобы получить реальные TOP
и BOTTOM
экрана в игровых координатах. Обратите внимание на размеры игрового мира - 1280х960, исходя из этого разрешения подготавливается и вся графика. Такого разрешения, на мой взгляд, вполне достаточно как компромисса между качеством графики и разумным размером текстурных атласов.
public class GdxViewport {
public static final float WORLD_WIDTH = 1280f;
public static final float WORLD_HEIGHT = 960f;
public static float TOP;
public static float BOTTOM;
public static float HEIGHT;
public static void resize(int width, int height) {
float ratio = (float) height / width;
float viewportHeight = WORLD_WIDTH * ratio;
BOTTOM = (WORLD_HEIGHT - viewportHeight) / 2;
TOP = BOTTOM + viewportHeight;
HEIGHT = TOP - BOTTOM;
}
}
Сцена
Каждый игровой такт, метод render()
вызывает у текущий сцены методы act()
и draw()
. Первый дает возможность игровым объектам двигаться, а не оставаться статичным изображением, второй - отрисовывает содержимое сцены на экран.
Я использую один базовый класс для всех сцен игры - SimpleStage
. Он реализует события для загрузки / выгрузки ресурсов и размещения объектов на сцене. Здесь же переход между сценами и работа со всплывающими диалогами (подтверждение выхода, найден предмет, использовать предмет и тому подобное). Они у меня в игре повсеместно, поэтому вынесены в базовый класс для всех сцен.
public class SimpleStage extends Stage {
private Label loading;
private boolean ready;
public SimplePopup popup;
public Actor blind;
public Group content;
public SimpleStage(Viewport viewport) {
super(viewport);
content = new Group();
content.setSize(GdxViewport.WORLD_WIDTH, GdxViewport.WORLD_HEIGHT);
blind = new SimpleActor((int) GdxViewport.WORLD_WIDTH, (int) GdxViewport.WORLD_HEIGHT, new Color(0, 0, 0, 1));
loading = new Label(Loc.getString(Loc.LOADING), GdxGame.self().getFontStyle());
loading.setAlignment(Align.right);
loading.setPosition(GdxViewport.WORLD_WIDTH - loading.getWidth() - 15f, GdxViewport.BOTTOM + 10f);
addActor(content);
addActor(blind);
addActor(loading);
}
public void openPopup(SimplePopup nPopup) {
if (popup != null) {
return;
}
popup = nPopup;
popup.setPosition(GdxViewport.WORLD_WIDTH / 2 - popup.getWidth() / 2, GdxViewport.WORLD_HEIGHT / 2 - popup.getHeight() / 2);
blind.addAction(Actions.sequence(
Actions.alpha(.6f, .3f),
Actions.run(new Runnable() {
@Override
public void run() {
addActor(popup);
}
})
));
}
public void closePopup(final int onCloseAction) {
if (popup != null) {
popup.clear();
popup.remove();
popup = null;
}
blind.addAction(Actions.sequence(
Actions.alpha(0f, .3f),
Actions.run(new Runnable() {
@Override
public void run() {
onPopupClose(onCloseAction);
}
})
));
}
public void onPopupClose(int action) {
}
public void load() {
Gdx.app.log(GdxGame.TAG, "Load stage: " + getClass().getSimpleName());
}
public void unload() {
Gdx.app.log(GdxGame.TAG, "Unload stage: " + getClass().getSimpleName());
}
public void populate() {
Gdx.app.log(GdxGame.TAG, "Populate stage: " + getClass().getSimpleName());
}
public void transitionTo(final SimpleStage stage) {
Gdx.input.setInputProcessor(null);
stage.load();
blind.addAction(Actions.sequence(
Actions.alpha(1, .4f),
Actions.run(new Runnable() {
@Override
public void run() {
unload();
dispose();
GdxGame.self().setStage(stage);
}
})
));
}
private void show() {
Gdx.app.log(GdxGame.TAG, "Show stage: " + getClass().getSimpleName());
populate();
blind.addAction(Actions.sequence(
Actions.alpha(0, .4f),
Actions.run(new Runnable() {
@Override
public void run() {
onFocus();
}
})));
}
public void onFocus() {
Gdx.input.setInputProcessor(this);
}
@Override
public void act(float delta) {
super.act(delta);
if (!ready && GdxGame.getManager().update()) {
ready = true;
loading.setVisible(false);
show();
}
}
}
Я загружаю ресурсы для каждой сцены при переходе на нее. Нужно сказать, что стратегии загрузки ресурсов могут быть разные: можно грузить все при старте игры (для небольших игр), можно разделить ресурсы на общие и подгружаемые по мере необходимости. Я, со временем, пришел к такой схеме: грузим все что нужно для сцены при ее инициализации переопределяя событие load()
и выгружаем в unload()
, когда игрок покидает сцену. Минус такого подхода в загрузке ресурсов при каждом переходе между сценами. Но так как ресурсы у меня не особо тяжеловесные, этих загрузок почти не видно.
Ну а плюс, в том что мы держим в памяти только необходимое в текущий момент и можем стартовать игру с любой сцены. В LibGDX нет визуального редактора, как в том же Unity, где мы могли бы отлаживать сцену в процессе работы. Поэтому, возможность запустить сразу нужную сцену, а не прокликивать игру до нее, будет полезна.
Для этого я использую параметры командной строки, которые анализирую в DesktopLauncher
классе отвечающем за запуск игры на ПК. Здесь мы можем запускать игру в окне 16:9 / 4:3, либо в полноэкранном режиме, выводить/не выводить FPS, ну и собственно параметр -stage
отвечающий за то, какая сцена будет инициализирована после splash screen.
public class DesktopLauncher {
private static final String FULL_SIZE = "-full";
private static final String WINDOWED_MODE = "-windowed";
private static final String STAGE = "-stage";
public static void main(String[] arg) {
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
// 16:9 (default)
config.width = 800;
config.height = 450;
boolean windowed = false;
for (int i = 0; i < arg.length; i++) {
if (arg[i].equals(FULL_SIZE)) {
// 4:3
config.height = 600;
} else if (arg[i].equals(WINDOWED_MODE)) {
windowed = true;
} else if (arg[i].equals(STAGE)) {
if (i + 1 < arg.length) Prefs.STAGE = arg[i + 1];
}
}
if (!windowed) {
config.width = LwjglApplicationConfiguration.getDesktopDisplayMode().width;
config.height = LwjglApplicationConfiguration.getDesktopDisplayMode().height;
config.fullscreen = true;
}
new LwjglApplication(new GdxGame(new DesktopPlatform()), config);
}
}
Осталось добавить обработку этого параметра в сцене Splash
:
@Override
public void onFocus() {
super.onFocus();
addAction(Actions.sequence(
Actions.delay(1.8f),
Actions.run(new Runnable() {
@Override
public void run() {
SimpleStage stage = new Intro(getViewport());
if (Prefs.STAGE != null) {
try {
Class<?> roomClass = Class.forName("com.puzzle.stage." + Prefs.STAGE);
Constructor<?> constructor = roomClass.getConstructor(Viewport.class);
stage = (SimpleStage) constructor.newInstance(getViewport());
} catch (Exception e) {
e.printStackTrace();
}
}
transitionTo(stage);
}
})
));
}
Ну а дальше, просто настраиваем нужные нам конфигурации запуска. Кстати, забыл сказать что для разработки используется Android Studio. У меня это окно выглядит вот так:
Мотор!.. То есть Актер :)
В LibGDX все объекты на сцене являются наследниками класса Actor
. Но он совсем базовый и почти ничего не умеет. Поэтому я сделал собственное его расширение, от которого уже и наследуются все объекты в игре. По традиции, я назвал его SimpleActor
. Вы уже могли заметить его использование в SimpleStage
выше. Основная его функция - рисовать спрайт на сцене, либо примитив - квадрат, линию заданного цвета и т.п.
public class SimpleActor extends Actor {
public final TextureRegion region;
private Rectangle clipBounds;
public SimpleActor(TextureRegion region) {
this.region = region;
setSize(region.getRegionWidth(), region.getRegionHeight());
setBounds(0, 0, getWidth(), getHeight());
}
public SimpleActor(int width, int height, Color color) {
Pixmap pixmap = new Pixmap(width, height, Pixmap.Format.RGBA4444);
pixmap.setColor(color);
pixmap.fillRectangle(0, 0, width, height);
Texture texture = new Texture(pixmap);
texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
region = new TextureRegion(texture);
pixmap.dispose();
setSize(width, height);
setBounds(0, 0, width, height);
}
public void enableClipping(Rectangle clipBounds) {
this.clipBounds = clipBounds;
}
public Polygon getHitbox() {
final Polygon polygon = new Polygon(new float[]{0, 0, getWidth(), 0, getWidth(), getHeight(), 0, getHeight()});
polygon.setPosition(getX(), getY());
polygon.setOrigin(getOriginX(), getOriginY());
polygon.setScale(getScaleX(), getScaleY());
polygon.setRotation(getRotation());
return polygon;
}
@Override
public void draw(Batch batch, float parentAlpha) {
Color color = getColor();
batch.setColor(color.r, color.g, color.b, color.a * parentAlpha);
if (clipBounds != null) {
Rectangle scissors = new Rectangle();
final Viewport viewport = getStage().getViewport();
ScissorStack.calculateScissors(getStage().getCamera(), viewport.getScreenX(), viewport.getScreenY(), viewport.getScreenWidth(), viewport.getScreenHeight(), batch.getTransformMatrix(), clipBounds, scissors);
ScissorStack.pushScissors(scissors);
}
batch.draw(region, getX(), getY(), getOriginX(), getOriginY(), getWidth(), getHeight(), getScaleX(), getScaleY(), getRotation());
if (clipBounds != null) {
batch.flush();
ScissorStack.popScissors();
}
}
}
Из интересного: метод getHitbox()
для проверки коллизий (столкновений с другими объектами класса SimpleActor
). Вообще, решение создавать каждый раз полигон для этого - спорное. Но в моих играх, проверка коллизий идет во взаимодействиях типа drag-and-drop, проверяем поставил ли игрок предмет на нужное место для его использования, например. То есть получение хитбокса не очень активно вызывается, поэтому такое решение приемлемо. В результате, код на проверку коллизии выглядит так:
if (Intersector.overlapConvexPolygons(battery.getHitbox(), box.getHitbox())) {
// some actions
}
Второе - это метод enableClipping()
- маска, правда, только прямоугольная. Говоря образно, это прорезь в границах которой, спрайт будет отрисовываться, а вне ее, будет не виден. Бывает полезно, когда надо сделать какой-нибудь выдвигающийся, например объект, не подкладывая спрайты друг под друга.
Прочие полезности
Еще одна, необходимая почти в любой игре вещь - это локализация. Я храню все строковые ресурсы в xml файлах с именами типа strings_lang_code.xml
. В моих играх язык можно менять динамически, в настройках игры. Это, конечно, разрушает концепцию Android App Bundle с загрузкой из стора только нужных ресурсов для конкретного устройства, локации и т.д., но позволяет пользователю иметь более гибкие языковые настройки.
public static void loadStringsAndFont() {
final String langCode = Prefs.getLanguage();
final AssetManager manager = GdxGame.getManager();
final FileHandleResolver resolver = new InternalFileHandleResolver();
manager.setLoader(FreeTypeFontGenerator.class, new FreeTypeFontGeneratorLoader(resolver));
manager.setLoader(BitmapFont.class, ".ttf", new FreetypeFontLoader(resolver));
final FreetypeFontLoader.FreeTypeFontLoaderParameter size2Params = new FreetypeFontLoader.FreeTypeFontLoaderParameter();
final FontParams params = FontParams.BY_CODE.get(langCode);
size2Params.fontFileName = "font/" + params.fontFileName;
size2Params.fontParameters.size = params.size;
size2Params.fontParameters.characters = params.characters;
if (!manager.isLoaded(params.fontFileName)) {
manager.load(params.fontFileName, BitmapFont.class, size2Params);
manager.finishLoading();
Gdx.app.log(GdxGame.TAG, "Loaded font: " + params.fontFileName);
}
VALUES.clear();
String langFile = ("xml/strings_" + langCode + ".xml").toLowerCase();
try {
XmlReader reader = new XmlReader();
XmlReader.Element root = reader.parse(Gdx.files.internal(langFile).reader("UTF-8"));
for (int i = 0; i < root.getChildCount(); ++i) {
XmlReader.Element element = root.getChild(i);
VALUES.put(element.getAttribute("name"), element.getText());
}
Gdx.app.log(GdxGame.TAG, "Loaded strings from file: " + langFile);
} catch (Exception e) {
Gdx.app.log(GdxGame.TAG, "Error loading strings file: " + langFile);
}
}
При старте игры, определяем код языка из настроек устройства, либо берем ранее установленный игроком вручную код языка (он имеет более высокий приоритет). Читаем соответствующий xml файл и помещаем строки в HashMap
. Из кода, установка какой-нибудь надписи выглядит примерно так:
final Label text = new Label(Loc.getString(Loc.EXIT_CONFIRM), GdxGame.self().getFontStyle());
Настройки параметров шрифта, я храню в классе FontParams
. Он ничем особо не примечателен, просто класс для хранения связки "код языка" - "файл шрифта, размер, алфавит".
Ну и последнее, что я хотел бы показать в рамках этого топика - это работа со звуком. Класс для работы со звуком умеет плавно включать / выключать музыку, автоматически проигрывать разные семплы из одного набора звуков, например, шаги или нажатия. Для этого достаточно в ресурсы поместить все однотипные звуки, добавив счетчик в конце: "glass_tap_1", "glass_tap_2" и т.д. Я использую формат звуковых файлов mp3 для iOS и ogg на всех остальных платформах, метод getPath()
нужен для того чтобы правильно определить расширение файла.
public class Snd {
private static final HashMap<String, Float> VOLUME = new HashMap<String, Float>() {
{
put(mus_puzzle, .7f);
}
};
private static final HashMap<String, Integer> COUNTER_MAX = new HashMap<String, Integer>() {
{
put(glitch, 3);
}
};
private HashMap<String, Integer> counterMap = new HashMap<String, Integer>() {
{
put(glitch, 1);
}
};
private HashMap<String, Music> musicMap = new HashMap<String, Music>();
public static String getPath(String name) {
if (Gdx.app.getType() == Application.ApplicationType.iOS) return "mp3/" + name + ".mp3";
return "ogg/" + name + ".ogg";
}
private void musicFadeIn(final Music music, final float volume) {
Timer.schedule(new Timer.Task() {
@Override
public void run() {
if (music.getVolume() < volume)
music.setVolume(music.getVolume() + .01f);
else {
this.cancel();
}
}
}, 0f, .01f);
}
private void musicFadeOut(final Music music, final String path) {
Timer.schedule(new Timer.Task() {
@Override
public void run() {
if (music.getVolume() >= .01f)
music.setVolume(music.getVolume() - .01f);
else {
music.stop();
musicMap.remove(path);
this.cancel();
}
}
}, 0f, .01f);
}
public void playSound(String name) {
if (counterMap.containsKey(name)) {
int counter = counterMap.get(name);
String fullName = name + "_" + counterMap.get(name);
counter++;
if (counter > COUNTER_MAX.get(name)) {
counter = 1;
}
counterMap.put(name, counter);
name = fullName;
}
GdxGame.getManager().get(getPath(name), Sound.class).play();
}
public void playMusic(String name, boolean force, boolean once) {
final String path = getPath(name);
final AssetManager manager = GdxGame.getManager();
Music music = musicMap.get(path);
if (music == null) {
music = manager.get(path, Music.class);
musicMap.put(path, music);
}
if (music.isPlaying()) return;
music.setLooping(!once);
music.setVolume(0);
music.play();
float volume = VOLUME.containsKey(name) ? VOLUME.get(name) : 1;
if (force) {
music.setVolume(volume);
} else {
musicFadeIn(music, volume);
}
}
public void stopMusic(String name, boolean force) {
final String path = getPath(name);
if (!musicMap.containsKey(path)) return;
if (force) {
musicMap.get(path).stop();
musicMap.remove(path);
} else {
musicFadeOut(musicMap.get(path), path);
}
}
}
По коду, наверное, все. Можно еще рассказать про listener
, типа перетаскивания или нажатий. Но не хочется скатываться в детали, характерные только для моих игр. Задавайте вопросы в комментах, с удовольствием покажу как у меня устроен тот или иной аспект!
В последнее время, я работаю в жанре point-and-click. Наверное, называть квестом мою игру будет слишком громко, скорее набор головоломок в 2D. Вот так выглядит типичный геймплей (поэтому, рассказать что-то про физику или 3D в LibGDX - не смогу, к сожалению).
Заключение
В заключение, приведу субъективные плюсы и минусы LibGDX как движка для разработки видеоигр.
Плюсы:
Бесплатный (безусловно)
Небольшой размер билда (это не очень касается ПК, где нужно добавлять JRE в сборку)
Java, разработка в Android Studio
Простота и гибкость, можно влезть в любой аспект игры и сделать так как нужно именно вам. Вы не связаны реализацией которая навязывается, например, конструкторами
Для Android не нужно плагинов, есть доступ ко всем возможностям Android SDK
Минусы:
Нет визуального редактора. Я знаю про VisEditor, но лично у меня он не прижился, не особо удобный, да и редактор - это не только размещение объектов на сцене. Должна быть какая-нибудь система сообщений для последующего их взаимодействия
Базовые классы движка совсем базовые, для многих вещей нужно делать свою реализацию
Сложная реализация платформо-зависимых функций на iOS, готовых решений катастрофически не хватает. По факту, в моих играх на iOS, почти нет интеграции с экосистемой. Внутриигровые покупки реализованы в движке, остальное - головная боль
Нет (?) порта на консоли. Для меня этот момент не особо актуален, так высоко я не летаю :)
Комментарии (13)
GospodinKolhoznik
20.09.2021 12:05+4LibGDX мой любимый gamedev фрэймворк. Пробовал другие, и всегда возвращался к нему.
Я делаю только 2d игры, 3d мне совершенно неинтересно и для 2d как мне кажется LibGDX лучший. В нём игра именно полноценно програмируется, с продумыванием архитектуры, написанием логики, а не отщелкиванием мышкой в бесчисленных менюшках, как в Unity и ему подобных.
Ключевой момент - я не профессиональный гейм девелопер, это хобби. И как я понимаю, причина того, что фрэймворк незаслуженно забыт именно в том, что студии не хотят разрабатывать игры на инструменте, где потребуются настоящие программисты. Для бизнеса гораздо приятнее, чтобы их сотрудники были просто операторы ЭВМ с опытом работы в Unity. И да, в Unity все делается быстрее. Для бизнеса это критично. Для меня важнее не то, насколько быстро я делаю, насколько сам процесс мне приятен.
eshim2009
20.09.2021 12:36Мне кажется причина в плохой интеграции с Ios. Насколько я помню libgdx тащит с собой собственную виртуальную машину. Нет гарантий, что всё будет работать после очередного обновления apple.
dkimitsa
22.09.2021 12:36libgdx использует MobiVM(RoboVM fork) в качестве виртуальной машины. Последний -- обновляется и не зависит от обновлений apple.
Не совсем понятны сложности интеграции с iOS -- вероятно нет готовых высокоуровневых оберток, но весь cocoa touch имеет биндинги и доступен. Т.е. нужно самостоятельно писать специфичный нативный код, но на java.
namee
20.09.2021 18:01+1Я вас возможно удивлю. Наш средний проект 300тыс+ строк кода. В оболочке Unity3D. Пишем фермовые игры в основном.
И мне сложно представить механику, которая настраивалась бы кликами мышки. Базовые вещи, возможно. Но сделано это для более простого входа в сферу. Шаг влево / вправо и возникают сложные структуры и километры кода.
GospodinKolhoznik
20.09.2021 20:27+3Да, я наверное неправильно сделал. Я хотел похвалить LibGDX, а зачем то стал критиковать Unity, не имея достаточно опыта работы в нём. Это было глупо с моей стороны.
Это вообще вопрос вкусовщины - попробовал LibGDX, он мне сразу понравился, попробовал остальное, не зашло. То, что Unity очень долго загружается сразу опечалило. Ну и у меня первые впечатления от его бесконечных меню были - хоспадя, тут потребуются годы, чтобы во всем этом разобраться! А ведь можно ещё ставить кучу аддонов, ещё больше дополняющих функционал меню. Наверное надо было сразу забить на меню и просто начать кодить, а я провозился несколько дней с менюшками, мне стало грустно и я просто забросил.
namee
21.09.2021 08:14Вы описали ситуацию в отрасли в целом. От того сколько вокруг инструментов и мне становится грустно :). Да ещё по нескольку под каждую задачу. Да ещё и каждый норовит громче покричать о полезности.
java73
21.09.2021 14:48+1По мне, так дополнительный движок Ashley к LibGDX в разы упрощает понимание игростроя в связке с ООП и дает правильную концепцию деления игровых сущностей, игровых систем и рендеринга. Не знаю насчет Java, но с Kotlin оно спаривается очень хорошо, плюс есть свои ktx на все библиотеки, еще более упрощающие синтаксис.
dkimitsa
22.09.2021 12:39Можно немного подробней про трудности с iOS? Для работы с нативными (в том числе third party) фреймворками необходимо иметь java биндинги к ним. CocoaTouch покрыт на 99% к third party (Firebase/Facebook etc) есть RoboPods/AltPods -- это все больше из мира MobiVM/RoboVM, который используется как java backend на iOS
coder1cv8 Автор
22.09.2021 13:59В основном, трудности с высокоуровневыми обертками. Да. Мне так и не удалось вникнуть в эту тему с java биндингами к нативному коду iOS. Выглядит непонятно. Но проблема эта субъективная, конечно. Так-то, техническая возможность есть, согласен.
petrovichtim
22.09.2021 16:14+1Спасибо за статью, многие об этом инструменте и не слышали, а он крутой.
DarkSavant
Когда-то баловался, чисто любительскими разработками на этой платформе. Припоминаю, что была возможность собирать на веб платформу в html5.
Не в курсе, как оно нынче работает?
coder1cv8 Автор
Я как-то собирал под веб, вроде все работало, но это было давно...