Ностальгии пост: j2me, Gravity Defied, 64kb

    Эта статья не преследует никаких практических целей — просто мне стало интересно, как лет 15 назад разработчикам удавалось делать вполне функциональные приложения и игры для слабых телефонов того времени.



    • Если что, я не имею к этой игре никакого отношения.

    Например, игра с картинки выше не использовала floating-point числа, так как не все телефоны их поддерживали. "3д" и физика — полностью самописные на fixed-point вычислениях поверх целых чисел. Но мне кажется, перечисление особенностей одного приложения будет не сильно информативным. Для полноты картины я немного затрону возможности телефонов, j2me платформу и заодно сравню это с современной разработкой под Android.


    Кроме того, j2me — это полноценная java старой версии (кажется, 1.3), я дописал некоторые недостающие классы и смог запустить .jar файлик с игрой на своём PC. Скриншот выше — оттуда. Не скажу, что от этого есть какая-то польза — просто API для j2me было очень простым и мне захотелось попробовать.


    Телефоны того времени.


    В техническом плане современные телефоны ушли очень далеко, но в плане функциональности, на мой взгляд, даже старые телефоны позволяли делать всякие интересные вещи.


    Моим первым телефоном была Nokia 5200, и я вместо "абстрактного смартфона в вакууме" лучше опишу его особенности:


    • появился в 2006 году
    • дисплей 128x160 пискелей размером в целых 1.8 дюйма
    • Дисплей поддерживал аж 262 тысяч цветов. (если я понимаю, это по 6 бит на R, G, B составляющие).
    • Ик-порт и bluetooth для передачи файлов на другие телефоны.
    • несколько мегабайт долговременной внутренней памяти (точно не помню, в интернете пишут про 7 Мб)
    • слот для microSd карточки, у меня, кажется, была на 256 Мб.
    • уже тогда были какие-то примитивные браузеры и opera mini (которая, кстати, тоже весила буквально сотню килобайт)
    • камера 640x480 пикселей.
    • поддержка MIDP 2.0 (wiki)
    • телефон умел притворяться флешкой при подключении через usb
    • но заряжался через какой-то свой разъём
    • Я не нашёл, какая производительность была у процессоров, но она явно была очень скромной — не больше 100Мгц частоты и, возможно, очень медленная или отсутствующая реализация floating-point чисел. К сожалению, телефон давно умер и запустить какие-либо бенчмарки на нём я не смогу.

    Не буду перечислять мобильные игры, в которые играл на нём — просто скажу, что они все весили 50-200кб и при этом обладали достаточно глубоким геймплеем.


    Write once, run everywhere


    Изначально java позиционировалась именно для всяких маломощных бытовых устройств. И она очень удачно прописалась на телефонах того времени, позволяя запускать одну и ту же игру на телефонах различных производителей. Конечно, существовали platform-специфичные особенности, но чисто теоретически код должен был одинаково работать везде.


    Язык был простым, байткод — тоже. В нём команды длиной в байт для виртуальной стековой машины — написать наивный интерпретатор не так уж и сложно. А ещё по этой причине байткод занимал мало места. Что интересно, внутри .jar у j2me приложения лежат самые настоящие .class файлы от полноценной java. Разница была лишь в том, что мобильное приложение для взаимодействия с внешним миром использовало классы Canvas, MIDlet и прочие из пакета javax.microedition.


    На мой взгляд, это гениально просто. Android по сравнению с этим кажется набором костылей: код компилируется в .class файлы, потом конвертируется в .dex (или несколько, т.к. один dex файл не поддерживает больше 65к методов), пакуется в apk, а потом мобильное устройство перекомпилирует это под ART.


    Кроме того, мобильная джава поддерживала многопоточность. Как мы увидим дальше, при написании приложений без неё было не обойтись. Что интересно, взгляд на многопоточность тогда был немного иной. Например, у стандартного класса Vector все методы были synchronized. Сейчас Vector считается устаревшим, и предлагается использовать обычный ArrayList в однопоточном коде и тот же самый ArrayList в многопоточном, но явно захватывать блокировку на нём.


    Ещё интересная особенность — в старой java не было дженериков. Система типов состояла из примитивов (int, boolean, ...) и объектов. Тот же класс Vector хранил внутри себя объекты и возвращал их как Object, а программист после этого кастовал полученное к нужному типу.


    Кстати, когда я вижу go и ручные касты к нужным типам, я вспоминаю java 1.3. Только go почему-то застрял в развитии, а в случае в java уже в 2004 году вышла версия 1.5 с поддержкой дженериков. Но в мобильной разработке всё ещё использовалась 1.3.


    Так как мобильное j2me приложение — это самый обычный .jar с нормальными .class файлами внутри (пусть и в формате 1.3, которому уже 20 лет), можно этим воспользоваться и запустить Gravity defied прямо на PC. В "большой" java классов из javax.microedition нет, но их можно написать самому. Задача даже проще, так как есть классы с почти таким же набором методов: например, java.awt.Image и javax.microedition.lcdui.Image.


    Архитектура J2ME приложений



    Для взаимодействия с внешним миром использовались классы из javax.microedition. Возможно, из-за ограниченных возможностей телефонов, а может, просто из-за чувства прекрасного у разработчиков, набор классов очень маленький, а сами они — максимально простые. Для примера можно посмотреть классы в javax.microedition.lcdui.


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


    При старте приложения у него вызывался метод startApp().
    Если приложение сворачивалось — вызывался pauseApp() и снова startApp() при повторном открытии. При полноценном закрытии вызывалось destroyApp().


    Кроме того, приложение могло само вызвать методы notifyPaused() и notifyDestroyed() — чтобы сообщить о том, что оно поставилось на паузу или завершилось.
    Кроме того, приложение могло попроситься "выйти из паузы" методом resumeRequest()


    Честно говоря, я не очень понимаю смысл notifyPaused(), так как и без него приложения нормально работали, никто их не убивал.


    Мне кажется, именно таким и должен быть lifecycle приложения здорового человека.


    На практике обычно получалось, что класс приложения наследовался от Runnable и реализация выглядела как-то так:


    Thread thread;
    boolean isRunning;
    boolean needToDestroy = false;
    
    public void startApp() {
        isRunning = true;
        if (thread != null) {
            thread = new Thread(this);
            thread.start();
        }
    }
    
    public void pauseApp() {
        isRunning = false;
    }
    
    public void destroyApp(boolean unconditional) {
        needToDestroy = true;
    }

    И всё, дальше приложение жило в своём потоке.


    void run(){
        while(!needToDestroy) {
            // игровой цикл здесь
            // и если на паузе - спим.
            while (!isRunning) {
                Thread.sleep(100);
            }
        }
        notifyDestroyed();
    }

    Мне кажется, будить поток каждые 100 миллисекунд — не страшно, и такой подход вполне мог бы существовать и на современных телефонах. Вдобавок, никто не запрещает остановить поток после вызова pauseApp() и снова запустить в startApp()


    Canvas


    Для рисования на экране использовался ещё один класс — Canvas. Он похож на десктопный Canvas в java.


    Можно в любом потоке вызывать метод repaint(), который намекнёт системе, что надо бы обновить изображение. После этого в UI-потоке система вызовет paint(Graphics g). Возможно, у кого-то снова возникнут вьетнамские флешбеки, но при сворачивании приложения с Canvas ничего страшного не происходило — объект оставался валидным на всём протяжении жизни программы. Единственное отличие — у приложения в фоне вызовы repaint() игнорировались и метод paint(...) не вызывался.


    Что примечательно, уже тогда поддерживалсь сенсорные дисплеи: были методы
    hasPointerEvents(), hasPointerMotionEvents(), hasRepeatEvents(), которые на сенсорном телефоне возвращали true. При нажатиях вызывались методы типа pointerDragged(int x, int y) (перемещение указателя), а так же версии для pressed(начало нажатия) и released(нажатие завершилось).
    Поддержки мультитача нет — ну и ладно, тогда и подходящих дисплеев не было.


    Шрифты и меню


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


    Сейчас это как-то унифицировалось, но раньше кнопка "назад" на смартфонах могла располагаться как слева, так и справа. В j2me существовал какой-то механизм создания менюшек, чтобы система сама рисовала пункты меню, поддерживала скроллинг и т.п., а приложение просто узнавало номер выбранного пункта. Например, на nokia 5800 такие менюшки можно было перематывать пальцем, даже если разработчики приложения об этом и не подозревали.


    MIDlet Pascal


    Когда-то я учился в школе, там освоил паскаль и других языков не знал. С нуля въехать в java2me разработку у меня не получилось, но на моё счастье, я узнал про существование MIDlet pascal и свои первые приложения на телефон я писал именно на нём. В дальнейшем на j2me я перешёл довольно забавным способом — делал приложение на мидлет паскале, декомпилировал и смотрел, что получалось в java.


    Что же внутри?


    Ну ладно, хватит ностальгии, лучше посмотрим, как сделана Gravity Defied и как это уместилось в 64 килобайта.


    Во-первых, .jar — это zip архив, содержимое которого весит 122.1 kB


    • В папке META-INF лежит MANIFEST.MF на 3.8 kB, в котором перечислены файлики игры и SHA-1 и MD5 хеши для них. А так же название главного класса и файла с иконкой приложения.
    • файл levels.mrg на 5.1 kB в довольно компактном виде содержит информацию о всех 30 игровых уровнях. В среднем 170 байт на игровой уровень. Подробнее про столь компактный формат хранения я расскажу позже.
    • 11 картинок. В сумме около 10.8 kB. Некоторые из них являются атласами с кучкой спрайтов
    • .class файлы, в сумме 98.3 kB, 15 штук. Самый маленький — 127 байт, самый большой 24.3 kB. Их по размеру можно разделить на группы:
      • два интерфейса, (4 метода в каждом), 127 и 174 байт.
      • простой класс с шестью полями и парой методов — 470 байт.
      • 9 классов разумного размера — от 1.6 до 6 kB.
      • god-like классы:
        • m (я его потом переименовал в MenuManager) — 24.3kB, в нём захардкожены все возможные меню и сообщения игры. Никаких layout.xml и strings.txt :). Игра не подразумевала поддержку нескольких языков.
        • i (GameCanvas) 15.2 kB: много кода, связанного с рисованием, но, как ни странно, не весь.
        • b (GamePhysics) 20.6 kB: расчёт игровой физики и почему-то много кода, связанного с рисованием.

    Декомпиляция


    Нам понадобится декомпилятор. Я взял fernflower — это тот, который встроен в intelliJ IDEA. Он вроде нормально работает — на выходе получился код, который вполне реально скомпилировать обратно. Несколько лет назад я пробовал другими декомпиляторами и они не справлялись.


    Репозиторий с IDEA весит больше гигабайта и клонировать его долго — вместо этого можно воспользоваться зеркалом, в котором лежит только декомпилятор.


    Сборка декомпилятора тривиальна: /gradlew jar, в папке build появится нужный .jar
    Декомпиляция чуть сложнее: mkdir decompiled && java -jar fernflower.jar -ren=1 GravityDefied_java decompiled


    По дефолту опция ren не включена и без неё получится код, в котором переменные и методы могут иметь забавные имена типа if или for. Байткод такие имена не запрещает, но компилятору java такое не понравится. С ren=1 декомпилятор приведёт поля к виду типа field_42 или method_135 — хуже уже не станет, зато код станет валидным.


    Игра вышла ещё в 2004 году — и, что примечательно, уже тогда использовалась обфускация:


    • Байткод в каком-то виде хранит имена классов, методов и полей. И имена в одну или две буквы позволяли уменьшить размер .class файлов.
    • Есть сюрпизы в виде невалидных имён типа else, int и т.п.
    • Можно было вырезать неиспользуемые методы и опять же делать код компактнее. В игре "переименованиеям" не подвёргся только класс Micro, и только в нём я нашёл неиспользуемые методы.
    • возможно, для fixed-point вычислений использовались методы, которые были заинлайнены. Я сомневаюсь, что разработчики вручную писали код типа (int)((long)a * b >> 16)
    • я не знаю, вручную были склеены несколько классов в один или с помощью обфускатора, но в игре можно найти такой класс Шрёдингера.

    Собираем обратно


    Смотреть на код — это хорошо, но неинформативно. Хочется его запускать и, возможно, добавлять отладочный вывод или ещё что-то. А ещё хочется восстановить нормальные имена переменных, методов и классов.


    Много-много лет назад я писал код на Netbeans, и для сборки j2me приложений надо было скачивать специальное SDK. Для сборки, кажется, использовался ant. Подробнее можно посмотреть здесь.


    Но старенькая нокия уже не работала, а я хотел собрать и запустить код прямо на своём компе и желательно в Linux. Так что я подготовил gradle проект и попробовал собрать код. Код не собирался — не хватало классов из javax.microedition.*. Логично — их же нет в PC-версии java. Я решил заняться велосипедостроением и просто механически добавил все несуществующие классы и методы. Игра использует маленькое подмножество из доступных методов и классов, так что это заняло не больше часа времени.


    Для удобства я вывел в терминал watch ./gradlew run и в реальном времени смотрел на список ошибок. После добавления каждого нового метода по ctrl+S сохранял изменения.


    Реализованные классы можно посмотреть здесь.
    Их всего лишь 19 штук:


    • 8 для сохранений в javax.microedition.rms.
    • 10 в .lcdui, которые отвечают за картинки, шрифты, Canvas и т.п.).
    • MIDlet — главный класс, от которого приложение должно наследоваться.

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


    В приложении есть класс Micro, в котором имена методов не обфусцированы. В общем-то логично, методы типа startApp унаследованы от MIDlet и переименовать их невозможно.


    Пример кода:


    protected void pauseApp() {
        c = true;
        if (!b) {
            this.gameToMenu();
        }
        System.gc();
    }

    Вполне можно догадаться, что переменная c могла бы называться isPaused.


    Для "простых" методов тоже довольно часто можно понять происходящее:


    public void a() {
        if (this.recordStore != null) {
            try {
                this.e.closeRecordStore();
                return;
            } catch (RecordStoreException var1) {
            }
        }
    }

    и переименовать во что-то типа closeMethodStore.


    Кроме того, в intelliJ IDEA можно посмотреть все вызовы каких-то методов. Особенно — тех, заглушки для которых мы написали, типа Image.loadImage(name):


    this.p = Image.createImage("/splash.png");

    Вполне очевидно, что p можно переименовать в splashImage.


    С какой-то точки зрения это похоже на разгадывание судоку — находишь очевидные моменты, даёшь значащие названия переменным и методам. Это упрощает понимание остальных методов, даёшь названия им… Огромный респект разработчикам Jetbrains — я как минимум часов десять лазил по коду, переименовывая переменные с методами — и ни разу код не поломался. Впрочем, я всё равно время от времени компилировал код и убеждался, что он остался рабочим.


    В какой-то момент мне это надоело. Я попробовал запустить код и он упал — потому что все мои заглушки типа loadImage()… возвращали null и ничего не делали. Настало время писать реализацию для заглушек.


    Большая часть из них делалась тривиально: например, класс Image:


    public class Image {
        public final java.awt.Image image;
    
        private Image(java.at.Image image) {
            this.image = image;
        }
    
        public Graphics getGraphics() {
            throw new RuntimeException();
        }
    
        public int getWidth() {
            return image.getWidth(null);
        }
    
        public static Image createImage(int w, int h) {
            return new Image(new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB));
        }
        ...
    }

    Конечно, API местами не совпадает, но оно всё равно очень похоже.


    Для рисования я сделал класс CanvasImpl, который наследуется от JPanel. Он обрабатывал нажатия кнопок и вызовы отрисовки и превращал их в вызовы нашего игрового Canvas.
    Я даже вошёл во вкус и сделал апскейлинг картинки, чтобы не приходилось всматриватьcя в крохотное окошко:


    public void paintComponent(Graphics g) {
        if (upscale == 1) {
            canvas.paint(new javax.microedition.lcdui.Graphics(g));
        } else {
            canvas.paint(new javax.microedition.lcdui.Graphics(screen.getGraphics()));
            g.drawImage(screen, 0, 0, width * upscale, height * upscale, Color.WHITE, null);
        }
    }

    javax.microedition.lcdui.Graphics(g) — это моя обёртка над awt.Graphics, транслирующая вызовы рисования.


    Раз за разом я запускал код, смотрел как он падает в разных местах и дописывал реализации для своих заглушек. Некоторые исключения игра "безболезненно" проглатывала, но потом работала некорректно.


    Например, если в игре не работает загрука картинок, то игра работает, рисует мотоциклиста простыми линияим, колёса — палочками. Но тормозит на PC. А почему? Зачем-то в игре фон замащивается картинками 64 * 64, и если картинка не грузится, подставляется пустая картина размером 1 * 1 пиксель, и при замащивании дисплея она нарисуется 240 * 320 раз или типа того. Видимо, игра изначально работала и без картинок с графикой из палочек и кружочков, потом разработчики добавили картинки, но не тестировали, как же работает без них.


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


    Честно говоря, я не гуглил, какие есть готовые эмуляторы j2me для PC. Скорее всего есть — году эдак в 2010 я пользовался каким-то, когда только-только осваивал программирование и пробовал писать игры. Если в существующих эмуляторах не хватает каких-то возможностей и захочется доработать мой для поддержки ещё каких-то игр — пишите в личку.


    Заработало!


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


    После этого я попробовал к заглушкам подключить оригинальный .jar с игрой. Появилось несолько ошибок. Оказывается, важно не только чтобы метод назывался как надо, а чтобы он ещё и был у правильного родительского класса. Из-за этого пришлось унаследовать Alert и Canvas от Displayable и разместить там абстрактные методы, которые я изначально объявил в Canvas. Ну и ладно, исправлений не так уж и много понадобилось.


    Сейчас в gradle проекте такая структура:


    • Модуль emulator с кодом, собственно, моего самописного эмулятора. Многих методов не хватает, я реализовал только тот минимум, который был необходим для работы игры.
    • Модуль app-original, который в зависимостях содержит оригинальный .jar и позволяет его запускать
    • Модуль app-from-sources с декомпилированными и приведёнными к более-менее приличному виду исходниками. Тоже запускается.

    Приводим код к красивому виду


    После того, как декомпилированный код стало можно компилировать и запускать, разбираться в нём стало проще. Можно добавлять отладочный вывод или "выключать" отдельные методы, чтобы понять, что же они делают.


    Думаю, теперь можно более подробно обсудить особенности реализации.


    Fixed-point physics


    Для рассчётов используются обычные целые числа. Хранятся в виде обычных интов и считается, что младшие 16 бит — это дробная часть. Таким образом, получаются числа, которые принимают значения от -32768.0 до 32767.999984 c шагом в 1.0 / 65536.


    Мне это решение показалось очень красивым — приличная фиксированная точность сочетается со вполне большим диапазоном используемых значений.


    Сложение и вычитание таких чисел ничем не отличается от аналогичных операци с int.


    Умножение: если перемножить просто int, то мы получим дробную часть, а целая "переполнится". Для умножения числа сначала преобразовывались в long. При перемножении получалось число на 64 бита с дробной частью из 32 бит. После побитовым сдвигом вправо на 16 можно вернуться обратно к дробным 16-ти битам и обрезать число обратно к int.


    Деление: при простом делении дробная часть потеряется. Вместо этого опять нужно преобразование в long, сдвинуть делимое на 16 бит влево и поделить. В игре почему-то сделали иначе, сдвиг влево на 32 и после — вправо на 16.


    В игре есть реализация для sin, cos и atan2.


    Сделано довольно просто — есть захардкоженный массив на 64 значения — угол от 0 до 90 градусов. При вычислении синуса или косинуса угол приводится к этому диапазону, потом вычисляется индекс — и по нему читается значение из массива. Получается значение с точностью до полутора градусов. Видимо, для игры этого достаточно.


    Сенсорный ввод



    Что забавно, эта игра уже в две тысячи четвёртом году его поддерживала. Если метод Canvas.hasPointerEvents() возвращает true, то в игре рисуется дополнительный кружочек для управления, в который можно тыкать. Режим сделан скорее для галочки — всё равно вводимые значения квантуются в "полный газ", "полный наклон", "полный тормоз" и сделать что-либо "наполовинку" не получится. Но, как бы то ни было, поддержка сенсорного ввода в игре есть.


    Level format


    Выше я упоминал, что в среднем размер игрового уровня — 170 байт. Как так получилось? Очень просто — для хранения каждой точки используется всего пара байт.


    Вернее, сделано чуть хитрее — сначала хранится всякая информация типа позиции старта/финиша т.п., а так же количество точек. Для хранения точек сделано два режима. Если первый байт 0xff, то дальше в качестве координат идёт пара int с абсолютными координатами, а вот если байт отличается, что этот байт — смещение dx относительно предыдущей точки, и за ним байт со смещением dy.


    for (int i = 1; i < pointsCount; ++i) {
        byte modeOrDx;
        if ((modeOrDx = var1.readByte()) == -1) {
            offsetY = 0;
            offsetX = 0;
            pointX = var1.readInt();
            pointY = var1.readInt();
        } else {
            pointX = modeOrDx;
            pointY = var1.readByte();
        }
        offsetX += pointX;
        offsetY += pointY;
        this.addPointSimple(offsetX, offsetY);
    }

    Код целиком


    Просто и эффективно.


    Текстурные атласы


    В отличие от современных телефонов с GPU и full hd экранами, в старых телефончиках стояли весьма скромные дисплеи типа (128*160 или 240*320). Необходимости крутить изображения и рисовать 3д объекты как-то не возникало, и в api для картинок такой возможности даже и нет. Единственное, что было можно при рисовании картинки — повернуть её на 90-180-270 градусов и зеркально отразить.


    Мне кажется, для того времени это не было проблемой — спрайт размером в десяток пикселей имеет не так уж и много видимых вариантов поворота.
    Конкретно в этой игре для корпуса мотоцикла, частей тела и шлема мотоциклиста использовалось по 32 или по 16 спрайтов. Для спрайтов шлема потребовалась картинка размером аж 48*48 пикселей и весом в 1091 байт.


    Впрочем, надо отметить, что в то время уже существовало какое-то очень примитивное API для 3д графики с фиксированным 3д пайплайном и я даже играл во что-то 3д-шное. Текстуры были с огромными различимыми пикселями, а восьмиугольные колёса машинок воспринимались как что-то нормальное и высокодетализованное.


    Как мне кажется — размеры дисплеев и возможности телефонов очень хорошо подходили для спрайтовой 2д графики и не тянули 3д.


    Кеширование строк


    Во время игры в нижем правом углу экрана рисуется время. Что забавно — разработчики решили не создавать по новой строчке каждый кадр и сделали лениво заполняемый кеш на 100 строчек вида "23" и "64".


    if (time10MsToStringCache[time10MsPart] == null) {
        String zeroPadding;
        if (time10MsPart >= 10) {
            zeroPadding = "";
        } else {
            zeroPadding = "0";
        }
        time10MsToStringCache[time10MsPart] = zeroPadding + time10Ms % 100L;
    }

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


    No-MVP архитектура


    Класс MenuManager в виде хардкода содержит в себе все менюшки (они все создаются один раз при инициализации класса) и при необходимости рисует их. Если какому-то компоненту игры надо узнать, какой сейчас текущий уровень — он просто идёт к объекту-менюшке с выбором уровня и спрашивает, какая позиция активна.


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


    • Есть layout файл с расположением кнопок. Отдельный для каждой менюшки.
    • Строчки типа названий кнопок выносятся в отдельный файл stings.txt.
    • Есть Activity, которая пересоздаётся по каждому чиху.
    • Есть LifecycleObserver, который даёт возможность биндить текстовое поле к Adapter так, чтобы при смерти Activity та могла уже умереть и не занимать память.
    • Adapter преобразует данные из DataSource.
    • Просто так передавать классы между потоками "не ок", поэтому ещё будем паковать их в Bundle и после получения из DataSource распаковывать обратно.
    • В DataSouce данные закидываются из разных потоков или вообще синхронизуются с SQLite базой данных.

    Вопрос: а это всё точно нужно, чтобы обработать перемещение пользователя по внутриигровому меню? Я могу ошибаться в деталях, но суть проблемы должна быть очевидна.


    Несколько классов склеены в один


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


    • кусочка мотоцикла с координатами и прочим
    • элемента меню с каким-то текстом
    • таймера, который можно завести на "через секунду" или типа того

    Соответсвенно, если класс используется как элемент меню, испрользуются одни поля в классе, если как элемент мотоцикла — другие.


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


    Долгая загрузка


    Я обнаружил, что игра, которая собирается с нуля быстрее, чем за секунду, при этом долго грузится. "Хм, подозрительно" — подумал я и полез искать источник тормозов.


    Что же я нашёл:


    public void init() {
        long timeToLoading = 3000L;
        Thread.yield();
        this.gameCanvas = new GameCanvas(this);
        Display.getDisplay(this).setCurrent(this.gameCanvas);
        this.gameCanvas.requestRepaint(1);
    
        while (!this.gameCanvas.isShown()) {
            this.goLoadingStep();
        }
    
        long deltaTimeMs;
        while (timeToLoading > 0L) {
            deltaTimeMs = this.goLoadingStep();
            timeToLoading -= deltaTimeMs;
        }
    
        this.gameCanvas.requestRepaint(2);
    
        for (timeToLoading = 3000L; timeToLoading > 0L; timeToLoading -= deltaTimeMs) {
            deltaTimeMs = this.goLoadingStep();
        }
    
        while (gameLoadingStateStage < 10) {
            this.goLoadingStep();
        }
    
        this.gameCanvas.requestRepaint(0);
        this.isInited = true;
    }

    Игра вызывает обновление экрана и потом вызывает goLoadingStep() раз за разом на протяжении трёх секунд. Потом вызывает снова обновление экрана с переключением картинки на другую и снова три секунды вызывает goLoadingStep(). И после этого вызывает goLoadingStep до тех пор, пока они не завершатся.


    Сам goLoadingStep() сделан тоже довольно забавно:


    private long goLoadingStep() {
        ++gameLoadingStateStage;
        this.gameCanvas.repaint();
        long startTimeMillis = System.currentTimeMillis();
        switch (gameLoadingStateStage) {
            case 1:
                this.levelLoader = new LevelLoader();
                break;
            case 2:
                this.gamePhysics = new GamePhysics(this.levelLoader);
                this.gameCanvas.init(this.gamePhysics);
                break;
            case 3:
                this.menuManager = new MenuManager(this);
                this.menuManager.initPart(1);
                break;
                .... ///аналогичный код для 5-8
            case 9:
                this.menuManager.initPart(7);
                break;
            case 10:
                this.gameCanvas.setMenuManager(this.menuManager);
                this.gameCanvas.setViewPosition(-50, 150);
                this.setMode(1);
                break;
            default:
                --gameLoadingStateStage;
    
                try {
                    Thread.sleep(100L);
                } catch (InterruptedException var3) {
                }
        }
    
        return System.currentTimeMillis() - startTimeMillis;
    }

    А в MenuManager.initPart() тоже содержится огромный switch case блок с шагами загрузки.


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


    История создания


    Оригинальная игра изначально появилась в 2004 году. Она была написана для конкурса Excitera Mobile Awards 2004 (EMA04) и выиграла в номинации best-in-show. В компании Codebrew Software было три шведских разработчика:


    • Tors Björn Henrik Johansson — system/game logic/interface, testing, levels design
    • Set Elis Norman — graphics/physics/mathematics/system/tools programming, levels design
    • Per David Jacobsson — physics programming, game graphics, levels design

    Кроме того, есть порт этой игры под андроид, сделаный нашими соотечественниками, но в нём просто используется декомпилированный код.


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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 30

      +5
      С j2me вообще можно было гораздо проще поступить, на github есть форки phoneME, со всеми базовыми классами и под оба профайла cdc и cldc
        +1

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

        +7
        Я не программист, но как пользователь до сих пор очень люблю J2ME. У меня и сейчас ява-телефон в роли mp3-плеера и для звонков (Sony Ericsson W995, бывший флагман), а на случай его смерти лежит новый Sony Ericsson j10i2 Elm. Если бы под яву были современные мессенджеры (вацап, телеграм, клиент VK) — я, пожалуй, до сих пор бы ходил без смартфона.
        Но увы, под J2ME сейчас реально сидеть только в джаббере и, может, в каких-то местечковых редких сетях. Ну и поздние Оперы Мини ещё вроде работают. Всё остальное, связанное с интернетом, умерло (был клиент Jimm для ICQ и несколько очень крутых его клонов с крутыми доп.возможностями, был текстовый клиент скайпа, был Шазам и его аналог от Сони «TrackID», был клиент ютуба и много чего ещё).
        Мне очень нравилось, что при скромных потребностях ява-программы очень чётко работают, при этом полностью контролируется их деятельность (прога не может просто так получить вечный доступ в интернет, к файлам, к камере, к отправке СМС и пр). Проги никогда не лезли в телефонную книгу, не могли сами по себе менять никакие настройки.
        Думаю, даже на железе 2008-2010 года (поздних ява-телефонах) вполне можно было бы реализовать почти все возможности того же вацапа или телеграма, ну разве что понадобился бы сервис, перекодирующий видео в lo-res .3gp и жмущий картинки. Конечно, телефоны 2003-2005 годов были послабее, многие из них не поддерживали mp3 и видео, некоторые были даже монохромные. Среди дешёвых ява-телефонов было немало уродцев, когда телефон вроде поддерживает яву, но никакого интерфейса, чтобы залить файлик .jar, нет (только качать через родной мега-убогий вап-браузер), а встроенной памяти мегабайт-два (не каждая программа даже одна сможет поставиться). Были телефоны с закрытой файловой системой (дешёвые моторолы), когда ява вроде работала, но, скажем, сделать экспорт истории переписки в ICQ в .txt файлик я не мог. На некоторых телефонах просто очень криво и нестабильно работала ява (всякие ранние самсунги).
        Круче всего года до 2006 были сименсы, там всё работало как надо. Потом сименсы умерли, а вперёд вырвались Сони Эриксоны и Нокии. Но поздние Нокии были по сути симбиан-смартфонами (S40 и S60), обычная ява работала, но их родная стихия была .sis-приложения). Они как-то дольше грузились, больше тупили, а вот поздние сони эриксоны переваривали яву на пять с плюсом. Я на W995 и Эльме мог одновременно запустить аську, оперу мини мод с несколькими вкладками, джаббер (клиент «Bombus mod», mp3-плеер или клиент интернет-радио и ещё какую-нибудь игрушку, и на всё это хватало оперативки.
        А сейчас на яве скучно. Почти все сетевые программы сдохли. А главное — операторы поубирали тарифы, где интернет был по трафику (например, 5,50 р за мегабайт с округлением в 50 кб), теперь или покупай конские для такого телефона пакеты по большой цене (зачем мне пакет трафика 6 Гб, если я на просмотр новостей с Оперы Мини трачу 20-50 кб?), или отключай интернет совсем). Остаются только игры.
          +2
          12.1 руб за просмотр этой страницы Хабра? Ну, такое. Я в таком случае платил бы всю зарплату чисто за веб, и то не факт, что хватало бы.

          Операторы поубирали тарифы, где интернет был с округлением в 50 кб, так как количество трафика выросло в разы и такой небольшой шаг может на установку соединения уйти. В действительности, тарифы хоть и кусаются на фоне бесконечного кармана потребителей (СОРМы оплачивать могут, вообще богатеи офигевшие), но вполне себе терпимы.
            +5

            Мобильная опера умела картинки не загружать, да и всякие посторонние скрипты скорее всего тоже не грузила. Curl считает, что размер страницы 239 кб.

              0

              Мобильная опера (опера мини) же пережимала контент на своей стороне, грубо говоря рендерила страницы на стороне сервера.

              0
              В том-то и дело, что с пережатием Оперы Мини, особенно старой версии до 3.x (которую уже отключили), да с отключёнными картинками, типичная страничка (новости, Хабр, Баш, какой-нибудь форум на IPB или PhpBB) весила 15-50 кб, ну максимум 150-200, если это что-то очень тяжёлое. Таким образом, я мог один раз открыть GPRS-сессию и почитать новости, баш, заглянуть на пару форумов и потратить около 200-400 кб трафика, то есть 1-2 рубля! Это удобно и дёшево. Но операторы посчтали, что такой тариф никому не нужен, и влепили минимальный пакет гигабайта 4 или 6, а если выйти в интернет на «звонковом» тарифе, то всё равно списывается рублей 15 и даётся мини-пакет на день на несколько мегабайт. Как-то так. Ну и зачем это? В чём сложность была просто не трогать старые тарифы? Я бы и сейчас тратил себе рубль-два в день и читал свои новости с оперы мини, а теперь вообще не хожу в интернет с той сим-карты, а новости читаю или на смартфоне с корпоративной симкой, или дома через домашний интернет.
            +3
            Если вдруг всё ещё надо, у меня где-то были декомпилированные исходники Gravity Defied, где всё переименовано в осмысленные названия.

            А что до генериков — моё понимание таково, что там никакой поддержки со стороны JVM не требуется. Всякий там ArrayList всё так же возвращает и принимает Object, который всё так же кастуется в нужный тип, просто в случае генериков эти касты генерирует компилятор.
              0
              было бы неплохо поделиться ссылочкой)
                +1
                Тынц
                Generics are implemented by type erasure: generic type information is present only at compile time, after which it is erased by the compiler. The main advantage of this approach is that it provides total interoperability between generic code and legacy code that uses non-parameterized types (which are technically known as raw types). The main disadvantages are that parameter type information is not available at run time, and that automatically generated casts may fail when interoperating with ill-behaved legacy code. There is, however, a way to achieve guaranteed run-time type safety for generic collections even when interoperating with ill-behaved legacy code.
                0

                На самом деле это правда лишь от части.


                Например, когда вы создаете 'new ArrayList<String>()', то тип String в объект не записывается и узнать какого типа пришел List в рантайме нельзя.
                Но если у вас, например, поле класса 'private List<String> strings' или параметр функции 'void func(List<String> strings)', то тип String в класс записывается, и его можно узнать через reflection.


                Тот же hibernate вполне может связать классы Parent и Child:


                public class Parent {
                ...
                @OneToMany(...) 
                private List<Child> childs;
                }
                  0
                  Да, я знаю, и это не противоречит тому, что я написал ;)

                  Лично у меня проблема с этим была только один раз, когда хотел по максимуму переиспользовать код в парсере ответов API — там был везде одинаковый формат списков, но с объектами разных типов внутри. Решил так:

                  public class ListContainer<T> extends ArrayList<T>{
                      public final Class<T> itemClass;
                      // + конструктор
                  }
                  

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

                  А декомпиляторы выводят типы генериков из кастов в местах использования.
                +1
                О, помню, когда мне в школе передали counter strike для мобилы, но она была с защитой по почте и серийнику и никто не знал комбинацию. Как же просто было её взломать и написать простой кейген… Счастливые времена, сейчас все сложнее. Хотя мне тогда для написания игр не хватало чисел с плавующей точкой и тригонометрических операций, а разложений в ряды Маклорена я в школе не знал.
                  0
                  Упоминание MidletPascal вижу, но ни одной IDE для него не упомянули, коих целых три вышло (две десктопных и одна под Android), довольно странно. Ну и MobileBasic тоже пользовался популярностью. В остальном — большое спасибо за статью.

                  P.S. А по поводу запуска J2ME кода под Android (именно кода, не джарника) есть целых два решения — это J2MEPolish и AndroidMidpShell.

                  P.S.S. Я сохраняю проекты MidletPascal в репозиторий, поэтому если у вас что-то осталось, поделитесь пожалуйста)
                    0

                    В те годы тоже ковырялся в MidletPascal и знал только про одну (официальную) IDE. Приятно удивлён тем фактом, что их было несколько :)

                    0

                    Тут рассмотрена относительно простая игра, и это хорошо.
                    Но я помню мой уровень восторга от фирменного тенниса в 3d (Super Real Tennis 3d) на SE k750… где относительно маломощный телефон воспроизводил не самую плохую 3d графику.

                      +1

                      k750, вроде бы, был одним из самых мощных. Во всяком случае, его аналог k810i имел такие фичи, как поддержка многозадачности j2me, около 5МБ оперативной памяти под нужды j2me, возможность запуска jar размером больше 20МБ. Я делал приложение-карту со спутниковыми снимками, в которое зашивал снимки как ресурсы; и всякие словари Мюллера мегабайт на 7. Из 3D я могу припомнить какой-то авиасимулятор (воздушный бой), и гонки Red Out Racer, они были интереснее, чем теннис.

                        0

                        По сравнению с КПК и коммуникаторами тех времён он был всего лишь продвинутым и качественным мопедом. После 750 у меня был 800, а вот следующим был коммуникатор на Windows Mobile.
                        Не знаю, сколько было памяти у 750-го, но думаю, что меньше, так как он старше на пару лет вашей модели. Но количество ОЗУ все равно было в разы меньше, чем у коммуникаторов или КПК тех времён, и уступало смартфонам на Symbian.
                        Да, согласен, эти игры тоже были, но я не был их фанатом.

                          0
                          На Симбе и WinMobile и софт был пожирнее и потормознутее. Так что где была выше скорость отклика, удобство и быстрота работы — ещё большой вопрос. Те смартфоны на Symbian и Windows Mobile, которыми я пользовался, мне запомнились своими тормозами и неотзывчивым интерфейсом. А на яве всё летало. Тяжеленный мидлет больше мегабайта весом стартовал за секунды.
                            0
                            Да, но первый плоттер (построитель графиков) я увидел именно на КПК, и купил себе коммуникатор именно с прицелом использовать его для учёбы. И офис там был шикарный по тем временам.
                        +1

                        Я там ниже отписывался, напишу еще тут немного про Sony Ericsson.


                        Почти все из них, под которые доводилось писать были отличными аппаратами. Лучшими из всего, что было на J2ME. Не помню чтобы под них были баги. А еще в них было много оперативной памяти и они были невероятно быстрыми (относительно других J2ME аппаратов).
                        Они были настолько круты, что под них мы практически никогда не разрабатывали мастер-версий своих игр. Это такие первоначальные версии ПО, которые потом портировались на другие аппараты.
                        Почему так? Потому что если разработать игру под продвинутое железо, то ее потом не портировать на более слабые аппараты. А поскольку зоопарк девайсов был большой и нужен был максимальный охват аудитории, то смысла использовать все возможности крутых девайсов не было.

                        +1

                        А что нужно сегодня, чтобы выпускать актуальные кнопочные Девайсы с j2me? Соглашение с Oracle?
                        Иногда хочется взять звонилку на всякий случай, но современные телефоны знатно деградировали в плане программной платформы. Иногда кажется, что это знание, как делать приличные телефоны, кануло в лету.

                          +1
                          А что нужно сегодня, чтобы выпускать актуальные кнопочные Девайсы с j2me?

                          Чтобы это было экономически выгодным, нужно повернуть вспять экономические процессы. Сейчас j2me и всё с ним связанное (практически) никому не нужно, т.к. есть android, выигравший конкуренцию (т.е. доказавший своё превосходство) некоторое время назад (еще до поглощения Sun Oracle'ом).


                          Если "актуальные кнопочные Девайсы с j2me" вам нужны только для себя, сегодня вы можете соорудить требуемый девайс на базе arduino, raspberrypi, или чего-то подобного, или самостоятельно спроектировать плату из современных компонентов, заказать, напечатать или выпилить напильником корпус… Возможно, вам потребуются единомышленники, но сегодня подобие телефона прошлого можно сделать и в одно лицо.
                          Полагаю, есть даже несколько иной путь — взять готовый телефон, готовое ядро линукса, и запилить (или даже кастомизировать готовую) оболочку, плюс виртуалку с требуемыми вам j2me api.

                            +1

                            Спасибо, постараюсь изучить вопрос.
                            Я писал программы для Arduino, но это отличается от j2me. Raspberry pi ест энергию как не в себя, и устройство будет работать часы вместо дней, на элементах 18650. Есть какие-то варианты, на базе которых можно будет сделать относительно автономными?

                              +2
                              это отличается от j2me

                              J2ME — это реализация java-виртуалки + реализация API. Например, эмулятор j2me-телефона представлял собой просто реализацию API, запускаемую вместе с мидлетом под обычной, десктопной, java-машиной.


                              Есть какие-то варианты, на базе которых можно будет сделать относительно автономными?

                              Быть может, STM32… Но (как программист) я бы взял готовый телефон и попробовал собрать для него свою прошивку, без shitware.

                            0
                            Всё ещё можно найти новые (или восстановленные) сони эриксоны и рабочие аккумуляторы к ним. Я сам сижу на W995 и про запас лежит новый Elm. Как телефоны для звонков и как плееры они очень хороши. Можно и погонять в какой-нибудь Worms 2007 года. Можно лазить в интернете с Оперы Мини, в некоторых даже есть Wi-Fi 802.11b/g! Но почти все сетевые программы для них сдохли, и актуальных (вацап, телеграм) на J2ME просто не существует. А телефон без интернета и программ для работы в интернете не может быть современным.
                              0
                              Как телефоны для звонков и как плееры они очень хороши.

                              Разве? Я сильно плевался на старый интерфейс, когда мой андроид-телефон был в ремонте, и я заменял его старым кнопочным. Хотя в своё время интерфейс Sony Ericsson был хорош. Но сейчас, когда SMS принято группировать в диалоги, старое отображение просто списком чертовски неудобно. Плеер, вроде бы, не успел составлять плейлисты… Аналогично и в некоторых других аспектах.

                            +3

                            Как бывший разработчик игр под J2ME (2005-2008гг) могу рассказать обратную сторону.


                            Итак, compile once run everywhere — это не про J2ME. На практике приходилось делать отдельный билд почти под каждый телефон. И хорошо, если это Nokia. У них там в то время было все просто, Series 40 (128x128), Series 60 (176×208) и может немного более экзотического. Влез в 64кб jar и 215кб heap на S40 — молодец, дальше почти ни одного глюка. Sony Ericsson еще хорош. Берешь 3 почти одинаковых самсунга — у каждого свои глюки. И самсунг еще далеко не самый плохой вариант.


                            Итак, различия:


                            • Экран. Телефоны имеют разные разрешения, под каждое надо отдельные картинки. Хотя бы splash screen (картинка на заставке). А еще некоторые телефоны не могли перейти в полноэкранный режим, для них не надо было рисовать soft-keys, что ломало layout. Да то же расположение кнопки назад (слева или справа) не давало делать универсальные приложения.
                            • Нестандартные возможности. Возможность рисовать отраженные картинки не сразу была в стандартной поставке J2ME. Где-то ее не было вообще, а где-то было нестандартное API (привет Nokia). А возможность нужна, нам же а 64кб надо поместиться. Кто-то gif умеет и ему можно картинки получше пожать, а кому-то только png подавай.
                            • Шрифты. 3 размера теоретически были везде, но по факту некоторые телефоны имели только один. Хуже другое, все шрифты были разные. Всегда где-то что-то не влазит. Иногда по ширине, иногда по высоте, иногда шрифт слишком мелкий. Надо все просмотреть, какие-то надписи укоротить, где-то перерисовать подложки под кнопки, где-то сделать скролл. Методы работы со шрифтами (например, ширину узнать) глючат на некоторых аппаратах. Выровнять шрифт по вертикали, например, на кнопке нельзя. Метод есть, но разные телефоны рисуют шрифт выше или ниже, надо подбирать для каждой серии телефонов отдельно.
                            • Звук. Тут вообще беда. У все производителей все по разному. И форматы файлов и API. А если захотел звук и вибро одновременно, то у QA команды это отдельно дело.
                            • Коды клавиш, насколько я помню, тоже отличались.
                            • Тормоза. Некоторые телефоны сильно медленнее других. Под них надо переделывать физику игры или делать отрисовку экрана один раз на 2 игровых цикла. Почти нигде не стоило создавать объекты после начала уровня. И даже это не спасало от тормозов garbage collector'а на некоторых телефонах. Были заметные притормаживания, которые вообще никак не лечились.
                            • Баги. Они повсюду. Они разные и не совместимые. Nokia (если влез по памяти) и Sony Ericsson отличные аппараты. У остальных все сильно хуже, вплоть до неверного выполнения стандартных методов. Добавляем невозможность дебага (почти на всех аппаратах) и заливку только через GPRS (на многих) и получаем крайне сомнительное удовольствие отладки. Телефон повис до того как ты успел что-то вывести на экран? Не повезло, попробуй сам угадать что поменять.

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

                              0

                              Спасибо за взгляд с "другой стороны". У меня, к сожалению, такого опыта нет, я запускал свои поделки на одном-двух телефонах.

                                0

                                Не могу сказать, что отсутствие такого опыта это "к сожалению" :)
                                Просто разработка маленькой поделки или proof of concept, особенно на хорошем девайсе, разительно отличается от коммерческой разработки под полсотни разных девайсов.
                                Уверен, что современные разработчики под Android тоже могут много чего хорошего рассказать, но я практически уверен, что сегодня проблем гораздо меньше хотя бы потому, что в apk можно закинуть почти все, что нужно.

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

                              Самое читаемое