...которая будет работать на первых Android-смартфонах в мире, ретро-компьютерах из 90-х и даже Mac'ах! Часть 1.

Иногда у меня лежит душа просто взять и написать какую-нибудь небольшую игрушку с нуля, без использования готовых движков. В процессе разработки я ставлю перед собой интересные задачки: игра должна весить как можно меньше, работать на как можно большем числе платформ и использовать нетипичный для меня архитектурный паттерн. Недавно я начал писать трёхмерные «танчики», которые должны весить не более 600 килобайт с учетом всех ресурсов и в рамках подробной статьи готов рассказать о всех деталях разработки трёхмерной игры с нуля в 2025 году. Если вам интересно узнать, как работают небольшие 3D-демки «под капотом» от написания фреймворка до разработки геймплея — жду вас под катом!

❯ Предисловие

Разработка небольших 3D-игр с нуля — одно из моих любимых хобби. Я люблю прорабатывать архитектуру проекта во всех аспектах, начиная с рендера и заканчивая игровой логикой и редакторами, пусть в большинстве случаев и простыми. В рамках прошлых статей мы уже успели с вами написать несколько забавных игрушек-демок под экзотические платформы: например 3D-леталку с использованием Direct3D6 под видеокарты из 90-х годов

Или даже игру про гонки на девяносто-девятке для КПК Dell Axim X51v и его GPU — PowerVR MBX Lite:

Нередко в комментариях меня спрашивали: «А зачем это всё делать с нуля, если есть десятки готовых игровых движков под самые разные задачи?» — и я всегда отвечал, что условный Unity может быть бесконечно мощным, гибким и крутым в рамках актуальных платформ и технологий, но для гиковских задач он практически не подходит. Сможет ли Unity собрать 3D-игру на PS2 или PSP? Вот то-то же!

Шутер про зомби написанный в рамках одной из статей 2023 года
Шутер про зомби написанный в рамках одной из статей 2023 года

Именно поэтому разработка самопальных 3D-игрушек — это что-то близкое к написанию игр для NES, SEGA или ZX Spectrum: возможно не так много кому нужно, но весело для самого создателя! И в качестве своей новой игры, я решил написать трёхмерную вариацию на тему «Танчиков» с Денди, а поскольку я люблю себе ставить определенные цели, я заранее сделал небольшой список «хотелок»:

  • Игра должна быть написана на Java и работать как минимум на четырех платформах: Windows, Linux, MacOS и Android. При этом мне очень хотелось запустить свою игру на самых первых Android-смартфонах в мире с ОС версии 1.5-1.6: LG GT540, Motorola Droid, Samsung I7500 Galaxy. А поскольку в Android 1.5 всё ещё используется JDK5 — в качестве побочной фичи игра будет запускаться и на ретро-компьютерах!

  • Конечный вес игры не должен превышать 600Кб в сборке для Android. Для ПК я сделал исключение — сам jar-файл весит немного, но вот lwjgl (враппер над OpenGL) занимает почти 600Кб даже после оптимизации ProGuard'ом.

  • Геймплей в своей основе должен копировать классическую игру с NES. У нас есть n-уровней, где необходимо отстрелять такое-то число танчиков, чтобы пройти на следующий и не дать уничтожить нашу базу. Просто и понятно!

И запустив IDEA вместо привычного мне NetBeans, я принялся творить...

Содержание:

❯ Основа «движка» — с чего всё начинается?

Статья разделена на несколько подразделов, каждый из которых описывает ту или иную часть нашей игры. Сами по себе игры достаточно комплексные программы, а для самопалов, написанных «с нуля», добавляется ещё и бойлерплейт код по типу графа сцены. Но в целом общая архитектура будет понятна даже новичку!

Разработка 3D-игры с нуля начинается с проектирования архитектуры и разработки перефреймворка-недодвижка, который должен упростить написание игровых систем. В моём случае это таймер, планировщик задач на главном потоке, рендер 3D-графики, менеджер звуков, ресурсов, примитивный граф сцены и что-то типа математической библиотеки (векторы с cross/dot/length, а также 4x4 матрицы с самыми типичными представлениями).

Ради оптимальной производительности я сразу же решил для себя использовать «свой» кодстайл и определенные практики вместо общепринятых в Java:

  1. Максимальная экономия на аллокациях и использовании динамической памяти. Дело в том, что абсолютно все объекты в Java создаются в куче и в отличии от нативных языков или .NET, как класс отсутствует value-типы структур, которые можно было бы создать на стеке. Таким образом, у многих игр даже сложение двух векторов провоцирует аллокацию... а представьте, если этих аллокаций сотни на каждый кадр? Сборщик мусора вам точно не скажет спасибо. Если на ретродесктопе возможно обойтись небольшим дропом кадров, то на первых Android-смартфонах можно добиться чуть ли не фризов! Не верите? Оригинальный Minecraft на Pentium 4 вам в пример!

  2. Использование глобальных полей вместо геттеров/сеттеров. Геттеры/сеттеры — хороший паттерн, позволяющий не выстрелить себе в колено, но любой вызов метода в Java — это совсем небольшой, но всё же оверхед. Поэтому какой смысл дёргать геттер, если можно напрямую использовать поле объекта, когда того позволяет ситуация?

  3. Для небольших операций допускается использование анонимных классов. Промисы, аниматоры по типу FadeIn/FadeOut, загрузка уровней — почему бы не сделать их «частью» соответствующих методов?

Основным объектом фреймворка является класс Runtime, который содержит в себе ссылки на остальные подсистемы. Сам рантайм ничего не знает о платформе, на которой он работает и общается с системными функциями с помощью специального интерфейса Platform, который реализует минимально-необходимый функционал: логирование, доступ к файлам и ссылки на системные модули — в том числе и Graphics.

public interface Platform {
        String getName();

        Graphics getGraphics();
        Input getInput();
        SoundManager getSoundManager();

        void log(String fmt, Object... args);
        void logException(Throwable exception);
        InputStream openFile(String fileName) throws IOException;
        void requestExit();
    }

Все платформозависимые подсистемы и сам Runtime создаёт так называемый порт: в случае PC это класс Context, а в случае Android — MainActivity. Помимо создания ключевых объектов, порт занимается организацией главного цикла обработки сообщений и пробросом событий в фреймворк с помощью соответствующих коллбэков. На практике это выглядит так:

  public void run() {
        log("Starting main loop");

        Runtime.init();
        while(!Display.isCloseRequested()) {
            Display.processMessages();

            Runtime.Graphics.setViewport(Display.getWidth(), Display.getHeight());

            Runtime.update();
            Runtime.draw();

            try {
                Display.swapBuffers();
            } catch (LWJGLException e) {
                log("SwapBuffers failed");
            }
        }

        Runtime.releaseResources();
        log("Window is closed");
    }

Так достигается высокая гибкость фреймворка. При необходимости можно сделать мультирендер с поддержкой разных версий OpenGL, добавить прозрачную поддержку бандлов с ресурсами и даже встроить рендер в чужое окно (например редактор уровней)!

Runtime зависит от класса Game, который занимается менеджментом состояния игры: от обработки менюшек, до загрузки уровней и вызова апдейтов/отрисовки для объекта World:

  public Game(Runtime runtime) {
        Runtime = runtime;

        world = new World(runtime);
    }

    public void init() {
        Runtime.Platform.log("Initializing game");
        loadingResult = WorldLoader.Instance.load(Runtime, world, "test");
    }

    public void update() {
        if(loadingResult.isDone()) {
            if(!loadingResult.isSuccessful())
                throw new RuntimeException("Loading task cancelled due to exception");
            else
              world.update();
        }
    }

    public void draw() {
        if(loadingResult.isDone() && loadingResult.isSuccessful())
          world.draw();
    }

    public void beforeClose() {

    }

Загрузка ресурсов и уровней в игровых движках — тема для отдельной статьи. В своём велосипеде я реализовал асинхронную загрузку за счет стандартного тредпула в Java: загрузчик оборачивает Future в класс-враппер, воркер в процессе загрузки рапортует врапперу об изменениях, а основной поток параллельно показывает окошко с прогрессом. Если воркер кидает исключение, обработчик в стандартном классе FutureTask его перехватывает и выбрасывает ExecutionException в основном потоке, позволяя показать сообщение об ошибке.

  public static AsyncResult start(final Runtime runtime, final LoadingWorker worker, String name) {
        if(name == null)
            throw new NullPointerException("Attempt to start unnamed loading thread");

        if(worker == null)
            throw new NullPointerException("Worker can't be null for thread " + name);

        final AsyncResult res = new AsyncResult(runtime, name);
        worker.onBeforeLoad(res);
        
        res.future = execService.submit(new Runnable() {
            @Override
            public void run() {
                runtime.Platform.log("Started loading thread %s", res.getThreadName());

                worker.onLoad(res);
                runtime.Platform.log("Loading thread %s successfully completed job", res.getThreadName());
            }
        });

        return res;
    }

Многие движки не умеют в потокобезопасность, когда речь заходит о выгрузке геометрии или текстур на GPU. В современных графических API проблем с этим нет, но вот в OpenGL и старых версиях D3D это та ещё боль, поэтому фактическая загрузка ресурсов (минуя I/O часть и обработку входного файла) происходит в основном потоке. Для этого я реализовал отдельный планировщик задач, эдакий минимальный аналог Handler в Android. Когда загрузчик текстуры подготовил массив пикселей, он ставит в очередь задачку с выгрузкой данных на GPU и не ожидая завершения возвращает управление потоку загрузки.

    runtime.Scheduler.runOnMainThreadIfNeeded(new Runnable() {
                @Override
                public void run() {
                    for(int i = 0; i < mipCount; i++)
                        ret.upload(mipLevels[i].Buffer, mipLevels[i].Width, mipLevels[i].Height, format == FORMAT_PALETTE ? FORMAT_RGB : format);
                }
            });

Ну и какой же игровой фреймворк обходится без менеджера ресурсов на слабых ссылках, который позволяет загрузить текстуру или модель только один раз и использовать её во множестве игровых объектов. Например, игра спавнит 20 танчиков с одинаковой 3D-моделью, но фактическая загрузка геометрии и текстуры произойдет только один раз: при первом вызове getMesh и getTexture. Когда приходит время работы сборщика мусора, он ищет неиспользованные ресурсы и вызывает у них финализатор:

  private Object getNamedObject(String name, Class expectedClass) {
        if(loadedObjects.containsKey(name)) {
            WeakReference weakRef = loadedObjects.get(name);
            Object obj = weakRef.get();

            if(obj == null) {
                runtime.Platform.log("[Resources] Object '%s' was freed previously. Reloading..."); // TODO: Implement weak references removal over time
                return null;
            }

            if(obj.getClass() != expectedClass)
                throw new ClassCastException("Object of name " + name + " is instance of " + obj.getClass().getSimpleName() + ", but getNamedObject expected " + expectedClass.getSimpleName());

            return obj;
        }

        return null;
    }

    private void addObjectToPool(String name, Object obj) {
        loadedObjects.put(name, new WeakReference<Object>(obj));
    }

    public Texture2D getTexture(String name) {
        Texture2D tex = (Texture2D) getNamedObject(name, Texture2D.class);

        if(tex == null) {
            tex = TextureLoader.load(runtime, name);
            addObjectToPool(name, tex);
        }

        return tex;
    }

В общих чертах архитектура фреймворка понятная — по сути это «классика» проектирования широкоспециализированных игровых движков. Но ведь читатель, привыкший к познавательным статьям с научпоп-уклоном уж точно пришёл сюда не за разговорами об архитектуре, поэтому предлагаю перейти к первой практической части — рендерере!

❯ Рендеринг 3D-графики

Графический движок — один из самых первых модулей, которые реализуют в самопальных играх. В нашем случае он будет достаточно примитивным и использовать Fixed-Function Pipeline. Поскольку Android вплоть до версии 2.2 не поддерживал OpenGLES 2.0, мы остаёмся без поддержки шейдеров и наслаждаемся самым простым функционалом: трансформация геометрии без аппаратного морфинга/скиннинга, вершинное освещение с ограничением в 8 источников на один вызов отрисовки и практически полная невозможность реализации нормальных теней. Зато ретро-лук и ностальгия читателей, которые писали игрушки с glBegin/glEnd в нулевых, обеспечены!

Примерный уровень графики с FFP (можно улучшить с помощью лайтмап)
Примерный уровень графики с FFP (можно улучшить с помощью лайтмап)

Любой рендер начинается с инициализации контекста и установки базовых рендерстейтов. Это сейчас на каждую группу стейтов есть свои объекты и для каждого материала можно назначить свои параметры отрисовки, а в те годы необходимо было плотно следить за контекстом и если где-то что-то забыл вернуть в изначальное состояние — картинка начинала артефачить, не говоря уже о внезапных крашах, если включить какой-нибудь NORMAL_ARRAY и не передать на него указатель! А уж как хорошо OpenGL поддерживали «встройки» от Intel...

context.log("Context version: %s", glGetString(GL_VERSION));
context.log("Graphics card: %s", glGetString(GL_RENDERER));

context.log("Checking extension support");
String extensions = glGetString(GL_EXTENSIONS);

requireExtension(extensions, "GL_SGIS_generate_mipmap");

orthoMatrix = new Matrix();

// Initialize basic state
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

glEnable(GL_LIGHTING);
glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);

matrixBuffer = ByteBuffer.allocateDirect(4 * 16);
matrixBuffer.order(ByteOrder.nativeOrder());
matrixBuf = matrixBuffer.asFloatBuffer();

Canvas = new Canvas(this);

Чтобы что-то нарисовать на экране, нужно это что-то сначала подготовить. Поскольку игра у нас маленькая во всех аспектах, я написал утилиту для конвертации моделей и текстур в собственные форматы. Формат моделей простой: буквально сами меши и их индексы, даже компрессии с вершинами в байтах как в Quake нет:

// Export SubMesh struct
for(Map.Entry<String, ArrayList<Vertex>> subMesh : meshCollection.entrySet()) {
    output.writeUTF(subMesh.getKey());

    System.out.println("Building indices");
    buildIndices(subMesh.getValue(), verts, indices);

    output.writeInt(verts.size());
    output.writeInt(indices.size());

    for(Vertex vert : verts) {
        output.writeFloat(vert.X);
        output.writeFloat(vert.Y);
        output.writeFloat(vert.Z);

        output.writeFloat(vert.NX);
        output.writeFloat(vert.NY);
        output.writeFloat(vert.NZ);

        output.writeFloat(vert.U);
        output.writeFloat(vert.V);
    }

    for(Short i : indices)
        output.writeShort(i);

    verts.clear();
    indices.clear();

}
Самая детализированная модель танчика занимает всего 63Кб — и на данный момент это совсем немного!
Самая детализированная модель танчика занимает всего 63Кб — и на данный момент это совсем немного!

А вот с текстурами пришлось подумать. Дело в том, что типичная 16-и битная текстура 256x256 занимает целый 131 килобайт памяти, что для наших целей слишком много. Умные дяди из S3 Graphics ещё в конце 90-х придумали формат S3TC (сегодня известный как DXT) во времена 4Мб видеокарт, который позволял сжать 16 пикселей в 8 байт, но мобильные GPU кроме Tegra его не поддерживают, а распаковывать его «на лету» не так то просто.

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

Оригинальная текстура, палитровая с 256 цветами и с 16 цветами. Выглядит... по сеговски?
Оригинальная текстура, палитровая с 256 цветами и с 16 цветами. Выглядит... по сеговски?

Однако Photoshop умеет преобразовывать изображения в палитровые с минимальной потерей качества! Текстура тех же размеров, визуально ничем не отличающаяся от RGB565, будет весить всего 32 килобайта, а если далее её пожать Deflate'ом — 24 килобайта. Текстура 128x128 так вообще ПЯТЬ килобайт — это точно вин!

Сможете ли вы сказать, что в данном кадре используется не 565 текстуры, а 4-битные?
Сможете ли вы сказать, что в данном кадре используется не 565 текстуры, а 4-битные?

Единственный момент — ни один мобильный GPU не поддерживает палитровые текстуры, так что «под капотом» они всё равно преобразуются в RGBA с колоркеем.

if(palette.length / 3 == 16) {
    // 4-bit palette unpacking
    for (int j = 0; j < (width * height) / 2; j++) {
        int pixel1 = (buf[j] & 0xF) * 3;
        int pixel2 = ((buf[j] >> 4) & 0xF) * 3;

        mipLevels[i].Buffer.put(palette[pixel1 + 2]);
        mipLevels[i].Buffer.put(palette[pixel1 + 1]);
        mipLevels[i].Buffer.put(palette[pixel1]);
        mipLevels[i].Buffer.put((byte) 255);

        mipLevels[i].Buffer.put(palette[pixel2 + 2]);
        mipLevels[i].Buffer.put(palette[pixel2 + 1]);
        mipLevels[i].Buffer.put(palette[pixel2]);
        mipLevels[i].Buffer.put((byte) 255);
    }
} else {
    for (int j = 0; j < width * height; j++) {
        int paletteSample = (buf[j] & 0xFF) * 3;

        mipLevels[i].Buffer.put(palette[paletteSample + 2]);
        mipLevels[i].Buffer.put(palette[paletteSample + 1]);
        mipLevels[i].Buffer.put(palette[paletteSample]);
        mipLevels[i].Buffer.put((byte) 255);
    }
  }

Следующий кирпичик в рендерере — система материалов, которая задаёт как будет выглядеть тот или иной объект на экране. В больших движках она достаточно комплексная и привязана к константам в шейдерах — юниформах, а также многопроходному рендерингу. В FFP же материал содержит в себе настройки рендерстейтов «как есть», а также ссылки на текстуры:

public String Name;

public Texture2D Diffuse;
public Texture2D Detail;
public float R;
public float G;
public float B;
public float A;

public float AlphaTestValue;

public boolean DepthWrite;
public boolean DepthTest;
public boolean AlphaBlend;
public boolean AlphaTest;

public boolean Unlit;

По итогу, вся система материалов выглядит так. И поверьте, без кэширования стейтов в профайлер лучше не заглядывать:

setState(GL_DEPTH_TEST, material.DepthTest);
glDepthMask(material.DepthWrite);
setState(GL_ALPHA_TEST, material.AlphaTest);
setState(GL_BLEND, material.AlphaBlend);
setState(GL_TEXTURE_2D, material.Diffuse != null);
setState(GL_LIGHTING, true);

if(material.AlphaTest)
    glAlphaFunc(GL_LESS, material.AlphaTestValue);

if(material.Diffuse != null) {
    glClientActiveTexture(GL_TEXTURE0);
    material.Diffuse.bind();
} else {
    glBindTexture(GL_TEXTURE_2D, 0);
}

if(material.Detail != null) {
    glClientActiveTexture(GL_TEXTURE1);
    material.Detail.bind();
}

for(int i = 0; i < LIGHT_COUNT; i++) {
    int light = GL_LIGHT0 + i;
    if(lightSources[i] == null) {
        setState(light, false);
    } else {
        setState(light, true);

        vectorBuf.put(material.R);
        vectorBuf.put(material.G);
        vectorBuf.put(material.B);
        vectorBuf.put(material.A);
        vectorBuf.rewind();
        glMaterial(GL_FRONT_AND_BACK, GL_DIFFUSE, vectorBuf);

        vectorBuf.put(lightSources[i].Position.X);
        vectorBuf.put(lightSources[i].Position.Y);
        vectorBuf.put(lightSources[i].Position.Z);
        vectorBuf.put(lightSources[i].IsDirectional ? 0 : 1);
        vectorBuf.rewind();
        glLight(light, GL_POSITION, vectorBuf);
    }
}

А вот и результат её работы! Уровень графики примитивный, но в целом очень сильно напоминает shareware-игры из нулевых... Ах, ностальгия!

❯ «Граф» сцены, система компонентов и загрузка уровней

Для того чтобы игру было легко модифицировать и поддерживать, необходимо сразу продумать грамотную архитектуру игровых объектов. Кто-то ограничивается классической концепцией Entity (как в Quake, Half-Life и многих других играх), кто-то добавляет к Entity систему компонентов (как в Unity), а некоторые пихают модный ECS куда ни попадя. Я решил остановиться на паттерне Entity-Component, однако в отличии от той же «юньки», где напрямую унаследоваться от GameObject нельзя, в моей реализации основную логику задают именно сами GameObject'ы, оставляя на компонентах рендеринг и данные по типу коллизий:

public abstract class GameObject {
    Vector<Component> components;
  
    public void onCreate() {

    }

    public void onUpdate() {
        for(Component c : components)
            c.onUpdate();
    }

    public void onDraw(Graphics graphics, Camera camera, int renderPassFlags) {
        for(Component c : components) {
            c.onDraw(graphics, camera, renderPassFlags);
        }
    }

    public void onDestroy() {

    }

    public void loadResources() {

    }

    public void onLateUpdate() {

    }
}

Структура уровня выстраивается из таких игровых объектов как из кирпичиков. Например, StaticMesh представляет из себя статичную модель на сцене, а унаследованный от него StaticObject добавляет к нему коллизию и может запросить «запекание» однообразной геометрии в один батч. При этом уровни не ограничены каким-то широко специализированным форматом с сериализацией всех полей: необходимо писать кастомные загрузчики, специфичные для той или иной игры.

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

# Level format:
# Tags:
#   Sky - Skysphere texture name
#   Weather - One of the supported weathers (Sunny, Rainy, Thunderstorm)
#   TaskScript - Script-class with map tasks
# Objects:
#   <Class> <X, Y, Z> <RX, RY, RZ> <Has collision: 0 - No collision, 1 - Has collision> <Variadic> (depends from class)

Tags:
    Sky sunny
    Weather sunny
    TaskScript com.monobogdan.game.tasks.GenericTask
    TargetTankCount 15
    DifficultyMultiplier 1.0

Objects:
StaticObject -3.02 1.00 -27.86 0 0 0 1 crate.mdl brick.tex
StaticObject -5.02 1.00 -27.86 0 0 0 1 crate.mdl brick.tex

❯ Игровая логика

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

chooseDirection(x, y);
// Calculate forward vector for desired rotation dir
forward.calculateForward(rotationDir);

collisionHolder.Min.set(forward.X - mesh.BoundingMax.X, forward.Y - mesh.BoundingMax.Y, forward.Z - mesh.BoundingMax.Z);
collisionHolder.Max.set(forward.X + mesh.BoundingMax.X, forward.Y + mesh.BoundingMax.Y, forward.Z + mesh.BoundingMax.Z);

boolean canMove = Rotation.compare(rotationDir, 5.0f);
tmpVector.set(Position);

if((x == -1.0f || x == 1.0f) && canMove) {
    Position.X += x * ACCELERATION_FACTOR;
    canMove = false; // Single axis at time
}

if((y == -1.0f || y == 1.0f) && canMove)
    Position.Z += y * ACCELERATION_FACTOR;

// Check collision with walls
if(collisionHolder.isIntersectingWithAnyone(CollisionHolder.TAG_STATIC) != null) {
    Position.set(tmpVector);
    desiredPosition.set(tmpVector);
        }

Однако классическому "контроллеру" танчика с NES не хватает плавности, да и сами карты у нас заметно больше NES'овских. Для решения этой задачки можно использовать Easing-функции, где самая простая - линейная интерполяция. Используя её в качестве экспоненциального затухания, мы можем сделать достаточно плавную камеру:

final float EASE_SPEED = 0.04f;

forward.Z = -10;
tmpVector.set(Position);
tmpVector.add(forward);
tmpVector.Y = 20;

targetRotation.X = 75 + (-velocity.Z * 5);
targetRotation.Y = velocity.X * 15;

World.Camera.Position.lerp(World.Camera.Position, tmpVector, EASE_SPEED);
World.Camera.Rotation.lerp(World.Camera.Rotation, targetRotation, EASE_SPEED);

И по итогу, на данный момент времени мы имеем следующий результат:

Mali 300
Mali 300

❯ Заключение

Вот такая статья о разработке 3D-игры с нуля у нас с вами получилась. Прошлые статьи в этой рубрике я писал в стиле туториала, но в этой я решил рассмотреть конкретные кейсы и архитектурные решения. И может она не настолько простая и понятная, как статья про разработку «самолетиков», думаю своего читателя она точно нашла! Если вам интересно, с кодом можно ознакомиться на моём Github (пока ещё очень сырая демка, там ещё есть что порефакторить и переписать).

А если вам интересна тематика ремонта, моддинга и программирования для гаджетов прошлых лет — подписывайтесь на мой Telegram-канал ‭«Клуб фанатов балдежа‭», куда я выкладываю бэкстейджи статей, ссылки на новые статьи и видео, а также иногда выкладываю полезные посты и щитпостю. А ролики (не всегда дублирующие статьи) можно найти на моём YouTube канале.

Очень важно! Разыскиваются девайсы для будущих статей!

Друзья! Для подготовки статей с разработкой самопальных игрушек под необычные устройства, объявляется розыск телефонов и консолей! В 2000-х годах, китайцы часто делали дешевые телефоны с игровым уклоном — обычно у них было подобие геймпада (джойстика) или хотя бы две кнопки с верхней части устройства, выполняющие функцию A/B, а также предустановлены эмуляторы NES/Sega. Фишка в том, что на таких телефонах можно выполнять нативный код и портировать на них новые эмуляторы, чем я и хочу заняться и написать об этом подробную статью и записать видео! Если у вас есть телефон подобного формата и вы готовы его задонатить или продать, пожалуйста напишите мне в Telegram (@monobogdan) или в комментарии. Также интересуют смартфоны-консоли на Android (на рынке РФ точно была Func Much-01), там будет контент чуточку другого формата :)

А также я ищу старые (2010-2014) подделки на брендовые смартфоны Samsung, Apple и т. п. Они зачастую работают на весьма интересных чипсетах и поддаются хорошему моддингу, парочку статей уже вышло, но у меня ещё есть идеи по их моддингу! Также может у кого-то остались самые первые смартфоны Xiaomi (серии Mi), Meizu (ещё на Exynos) или телефоны Motorola на Linux (например, EM30, RAZR V8, ROKR Z6, ROKR E2, ROKR E5, ZINE ZN5 и т. п., о них я хотел бы подготовить специальную статью и видео т. к. на самом деле они работали на очень мощных для своих лет процессорах, поддавались серьезному моддингу и были способны запустить даже Quake!). Всем большое спасибо за донаты!

А ещё я держу все свои мобилы в одной корзине при себе (в смысле, все проекты у одного облачного провайдера) — Timeweb. Потому нагло рекомендую то, чем пользуюсь сам — вэлкам:

Опробовать ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале 

Комментарии (11)


  1. bodyawm Автор
    21.06.2025 14:10

    Если честно, я очень хотел успеть доделать играбельную демку к сегодняшнему дню... но то там дела, то сям и по итогу не успел :) Так то те же самые танчики хоть за 24 часа написать можно, если делать всё по принципу KISS ;)

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


    1. bodyawm Автор
      21.06.2025 14:10

      Ну и объявлю розыск. Дело в том, что я недавно купил интересный MIPS-ноутбук на процессоре Ingenic JZ4750, точь в точь как был у @dlinyj . У моего экземпляра слетела прошивка: ядро загружается, а вот раздел с Qtopia поврежден. Если вдруг у кого-то есть такой ноутбук и вы готовы посодействовать снятию дампа - буду ждать вашего сообщения в личку!

      Скрытый текст


      1. bodyawm Автор
        21.06.2025 14:10

        А ещё я выкупил из Китая один очень редкий, предсерийный прототип Fujitsuo Intretop CX300. Кто-то знает, что это за девайсы такие? :)

        Скрытый текст


        1. MaFrance351
          21.06.2025 14:10

          Этакий топовый эквивалент того нетбука, что выше?


  1. zrxzx
    21.06.2025 14:10

    Когда-то играл в Dungeon Warrior 3D: 128 кБ псевдотрёхмерная игра работала даже на телефонах вроде Nokia 2610 (S40v2, экран 128x128, не поддерживал загрузку файлов > 300 кБ и запуск j2me > 250 кБ, мало heap из-за чего часто случался OutOfMemoryError). Помню псевдо3D гонки с весом менее 100 кБ, но название вылетело из головы :(

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


    1. bodyawm Автор
      21.06.2025 14:10

      Кстати, они еще и крайне необычные под капотом. Дело в том что многие такие игры не использовали API M3G или Mascot Capsule, а писали софтрендер с нуля... На Java! Для кнопочных телефонов!!!


    1. gaal_dev
      21.06.2025 14:10

      raycasting и спрайты - напоминает Wolfenstein 3D


      1. bodyawm Автор
        21.06.2025 14:10

        Некоторые были с честными треугольниками


  1. gaal_dev
    21.06.2025 14:10

    десятки готовых игровых движков под самые разные задачи

    не десятки

    все игры на Unreal engine и Unity выглядят примерно одинаково что выхолащивает жанр


    1. bodyawm Автор
      21.06.2025 14:10

      Да самопалов много разных. Из успешных выделяется юнити, уе, годот, урхо


  1. NutsUnderline
    21.06.2025 14:10

    во 2 г. не хватает варианта по типу "во мы свое время писали на асме и..." а впрочем сайзкодинг всяких демок и сейчас популярен, сколько там keriger занимает :)