...которая будет работать на первых Android-смартфонах в мире, ретро-компьютерах из 90-х и даже Mac'ах! Часть 1.
Иногда у меня лежит душа просто взять и написать какую-нибудь небольшую игрушку с нуля, без использования готовых движков. В процессе разработки я ставлю перед собой интересные задачки: игра должна весить как можно меньше, работать на как можно большем числе платформ и использовать нетипичный для меня архитектурный паттерн. Недавно я начал писать трёхмерные «танчики», которые должны весить не более 600 килобайт с учетом всех ресурсов и в рамках подробной статьи готов рассказать о всех деталях разработки трёхмерной игры с нуля в 2025 году. Если вам интересно узнать, как работают небольшие 3D-демки «под капотом» от написания фреймворка до разработки геймплея — жду вас под катом!
❯ Предисловие
Разработка небольших 3D-игр с нуля — одно из моих любимых хобби. Я люблю прорабатывать архитектуру проекта во всех аспектах, начиная с рендера и заканчивая игровой логикой и редакторами, пусть в большинстве случаев и простыми. В рамках прошлых статей мы уже успели с вами написать несколько забавных игрушек-демок под экзотические платформы: например 3D-леталку с использованием Direct3D6 под видеокарты из 90-х годов
Или даже игру про гонки на девяносто-девятке для КПК Dell Axim X51v и его GPU — PowerVR MBX Lite:

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

Именно поэтому разработка самопальных 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:
-
Максимальная экономия на аллокациях и использовании динамической памяти. Дело в том, что абсолютно все объекты в Java создаются в куче и в отличии от нативных языков или .NET, как класс отсутствует value-типы структур, которые можно было бы создать на стеке. Таким образом, у многих игр даже сложение двух векторов провоцирует аллокацию... а представьте, если этих аллокаций сотни на каждый кадр? Сборщик мусора вам точно не скажет спасибо. Если на ретродесктопе возможно обойтись небольшим дропом кадров, то на первых Android-смартфонах можно добиться чуть ли не фризов! Не верите? Оригинальный Minecraft на Pentium 4 вам в пример!
-
Использование глобальных полей вместо геттеров/сеттеров. Геттеры/сеттеры — хороший паттерн, позволяющий не выстрелить себе в колено, но любой вызов метода в Java — это совсем небольшой, но всё же оверхед. Поэтому какой смысл дёргать геттер, если можно напрямую использовать поле объекта, когда того позволяет ситуация?
-
Для небольших операций допускается использование анонимных классов. Промисы, аниматоры по типу 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 в нулевых, обеспечены!

Любой рендер начинается с инициализации контекста и установки базовых рендерстейтов. Это сейчас на каждую группу стейтов есть свои объекты и для каждого материала можно назначить свои параметры отрисовки, а в те годы необходимо было плотно следить за контекстом и если где-то что-то забыл вернуть в изначальное состояние — картинка начинала артефачить, не говоря уже о внезапных крашах, если включить какой-нибудь 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();
}

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

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

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

Единственный момент — ни один мобильный 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);
И по итогу, на данный момент времени мы имеем следующий результат:


❯ Заключение
Вот такая статья о разработке 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!). Всем большое спасибо за донаты!


Автор: bodyawm