image

Многие программисты так или иначе имеют тягу и интерес к разработке игр. Немалое количество спецов было замечено за написанием маленьких и миленьких игрушек, которые были разработаны за короткое время «just for fun». Большинству разработчиков за счастье взять готовый игровой движок по типу Unity/UE и попытаться создать что-то своё с их помощью, особенно упорные изучают и пытаются что-то сделать в экзотических движках типа Godot/Urho, а совсем прожжённые ребята любят писать игрушки… с нуля. Таковым любителем писать все сам оказался и я. И в один день мне просто захотелось написать что-нибудь прикольное, мобильное и обязательно — двадэшное! В этой статье вы узнаете про: написание производительного 2D-рендерера с нуля на базе OpenGL ES, обработку «сырого» ввода в мобильных играх, организацию архитектуры и игровой логики и адаптация игры под любые устройства. Интересно? Тогда жду вас в статье!

Как это работает?


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

image

Один из прошлых проектов — 3D шутэмап под… коммуникаторы с Windows Mobile без видеоускорителей! Игра отлично работала и на HTC Gene, и на QTek S110!

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

image

Подобные инструменты включают в себя как довольно функциональные конструкторы игр, которые обычно не требуют серьёзных навыков программирования и позволяют собирать игру из логических блоков, так и полноценных игровых движков на манер Unity или Unreal Engine, которые позволяют разработчикам писать игры и продумывать их архитектуру самим. Можно сказать что именно «благодаря» доступности подобных инструментов мы можем видеть текущую ситуацию на рынке мобильных игр, где балом правят очень простые и маленькие донатные игрушки, называемые гиперкежуалом.

Но у подобных инструментов есть несколько минусов, которые банально не позволяют их использовать в реализации некоторых проектов:

  • Большой вес приложения: При сборке, Unity и UE создают достаточно объёмные пакеты из-за большого количества зависимостей. Таким образом, даже пустой проект может спокойно весить 50-100 мегабайт.
  • Неоптимальная производительность: И у Unity, и у UE очень комплексные и сложные рендереры «под капотом». Если сейчас купить дешевый смартфон за 3-4 тысячи рублей и попытаться на него накатить какой-нибудь 3 в ряд, то нас ждут либо вылеты, либо дикие тормоза.

Лично я для себя приметил ещё один минус — невозможность деплоить игры на устройства с старыми версиями Android, но это, опять же, моя личная хотелка.

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

Определяемся с задачами


Перед тем, как садится и пилить игрушку, нужно сразу же определится с целями и поставить перед собой задачи — какой стек технологий мы будет использовать, как будем организовать игровую логику, на каких устройствах игра должна работать и.т.п. Я прикинул и решил реализовать что-то совсем несложное, но при этом достаточно динамичное и забавное… 2D-шутер с видом сверху!

image

Игра будет написана полностью на Java — родном языке для Android-приложений. Пустые пакеты без зависимостей весят всего около 20 килобайт — что только нам на руку! Ни AppCompat, ни какие либо ещё библиотеки мы использовать не будем — нам нужен минимальный размер из возможных!

Итак, что должно быть в нашей игре:

  • Основная суть: Вид сверху, человечком по центру экрана можно управлять и стрелять во вражин. Цель заключается в том, чтобы набрать как можно больше очков перед тем, как игрока загрызут. За каждого поверженного врага начисляются баксы, за которые можно купить новые пушки!
  • Оружие: Несколько видов вооружения, в том числе пистолеты, дробовики, автоматы и даже пулеметы! Всё оружие можно купить в внутриигровом магазине за валюту, которую игрок заработал во время игры
  • Враги: Два типа врагов — обычный зомби и «шустрик». Враги спавнятся в заранее предусмотренных точках и начинают идти (или бежать) в сторону игрока с целью побить его.
  • Уровни: Можно сказать, простые декорации — на момент написания статьи без какого либо интерактива.

Поскольку игра пишется с нуля, необходимо сразу продумать необходимые для реализации модули:

  • Графика: Аппаратно-ускоренный рендерер полупрозрачных 2D-спрайтов с возможность аффинных трансформаций (поворот/масштаб/искривление и.т.п). На мобильных устройствах нужно поддерживать число DIP'ов (вызовов отрисовки) как можно ниже — для этого используется техника батчинга. Сам рендерер работает на базе OpenGLES 1.1 — т.е чистый FFP.
  • Ввод: Обработка тачскрина и геймпадов. Оба способа ввода очень легко реализовать на Android — для тачскрина нам достаточно повесить onTouchListener на окно нашей игры, а для обработки кнопок — ловить события onKeyListener и сопоставлять коды кнопок с кнопками нашего виртуального геймпада.
  • Звук: Воспроизведение как «маленьких» звуков, которые можно загрузить целиком в память (выстрелы, звуки шагов и… т.п), так и музыки/эмбиента, которые нужно стримить из физического носителя. Тут практически всю работу делает за нас сам Android, для звуков есть класс — SoundPool (который, тем не менее, не умеет сообщать о статусе проигрывания звука), для музыки — MediaPlayer. Есть возможность проигрывать PCM-сэмплы напрямую, чем я и воспользовался изначально, но с ним есть проблемы.
  • «Физика»: Я не зря взял этот пункт в кавычки. :) По сути, вся физика у нас — это один метод для определения AABB (пересечения прямоугольник с прямоугольником). Всё, ни о какой настоящей физике и речи не идет. :)

Поэтому, с учетом требований описанных выше, наша игра будет работать практически на любых смартфонах/планшетах/тв-приставках кроме китайских смартфонов на базе чипсета MT6516 без GPU из 2010-2011 годов. На всех остальных устройствах, включая самый первый Android-смартфон, игра должна работать без проблем. А вот и парк устройств, на которых мы будем тестировать нашу игру:

image


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

Рендерер


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

private void attachMainLoop() {
        GLView.setRenderer(new GLSurfaceView.Renderer() {
            @Override
            public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
                Engine.log("GL context successfully created");
                Engine.log("Vendor: %s", GLES10.glGetString(GLES10.GL_VENDOR));
                Engine.log("Renderer: %s", GLES10.glGetString(GLES10.GL_RENDERER));

                Text = new TextRenderer();

                setupRenderState();
                Engine.Current.loadResources();
            }

            @Override
            public void onSurfaceChanged(GL10 gl10, int w, int h) {
                DeviceWidth = w;
                DeviceHeight = h;

                GLES10.glMatrixMode(GLES10.GL_PROJECTION);
                GLES10.glLoadIdentity();
                GLES10.glOrthof(0, w, h, 0, 0, 255);

                Camera.autoAdjustDistance(w, h);

                Engine.log("New render target resolution: %dx%d", w, h);
            }

            @Override
            public void onDrawFrame(GL10 gl10) {
                Engine.Current.drawFrame();
            }
        });
        GLView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);

        Engine.Current.MainActivity.setContentView(GLView);
    }

По сути, в современном мире, 2D — это частный случай 3D, когда рисуются всё те же примитивы в виде треугольников, но вместо перспективной матрицы, используется ортографическая матрица определенных размеров. Во времена актуальности DirectDraw (середина-конец 90х) и Java-телефонов, графику обычно не делали адаптивной, из-за чего при смене разрешения, игровое поле могло растягиваться на всю площадь дисплея. Сейчас же, когда разброс разрешений стал колоссальным, чаще всего можно встретить два подхода к организацию проекции:

  • Установка ортографической матрицы в фиксированные размеры: Если координатная система уже была завязана на пиксели, или по какой-то причине хочется использовать именно её, то можно просто завязать игру на определенном разрешении (например, 480x320, или 480x800). Растеризатор формально не оперирует с пикселями — у него есть нормализованные координаты -1..1 (где -1 — начало экрана, 0 — середина, 1 — конец, это называется clip-space), а матрица проекции как раз и переводит координаты геометрии в camera-space координатах в clip-space — т.е в нашем случае, автоматически подгоняет размеры спрайтов из желаемого нами размера в физический. Обратите внимание, физические движки обычно рассчитаны на работу в метрических координатных системах. Попытки задавать ускорения в пикселях вызывают рывки и баги.
  • Перевод координатной системы с пиксельной на метрическую/абстрактную:
    Сейчас этот способ используется чаще всего, поскольку именно его используют самые популярные движки и фреймворки. Если говорить совсем просто — то мы задаем координаты объектов и их размеры не относительно пикселей, а относительно размеров этих объектов в метрах, или ещё какой-либо абстрактной системы координат. Этот подход близок к обычной 3D-графике и имеет свои плюшки: например, можно выпустить HD-пак для вашей игры и заменить все спрайты на варианты с более высоким разрешением, не переделывая половину игры.

Для совсем простых игр я выбираю обычно первый подход. Самое время реализовать главный метод всего рендерера — рисование спрайтов. В моём случае, спрайты не были упакованы в атласы (одна текстура, содержащая в себе целую анимацию или ещё что-то в этом духе), поэтому и возможность выборки тайла из текстуры я реализовывать не стал. В остальном, всё стандартно:

public void drawSprite(Sprite spr, float x, float y, float width, float height, float z, float rotation, Color col) {
        if(spr != null) {
            if(col == null)
                col = Color.White;

            if(width == 0)
                width = spr.Width;

            if(height == 0)
                height = spr.Height;

            // Convert position from world space to screen space
            x = x - Camera.X;
            y = y - Camera.Y;

            if(x > ViewWidth || y > ViewHeight || x + width < 0 || y + height < 0) {
                Statistics.OccludedDraws++;

                return;
            }

            GLES10.glEnable(GLES10.GL_TEXTURE_2D);
            GLES10.glBindTexture(GLES10.GL_TEXTURE_2D, spr.TextureId);

            GLES10.glMatrixMode(GLES10.GL_MODELVIEW);
            GLES10.glLoadIdentity();
            GLES10.glTranslatef(x + (width / 2), y + (height / 2), 0);
            GLES10.glRotatef(rotation, 0, 0, 1);
            GLES10.glTranslatef(-(width / 2), -(height / 2), 0);
            GLES10.glScalef(width, height, 1.0f);

            vertex(0, 0, 0, 0, col);
            vertex(1, 0, 1, 0, col);
            vertex(1, 1, 1, 1, col);
            vertex(0, 0, 0, 0, col);
            vertex(0, 1, 0, 1, col);
            vertex(1, 1, 1, 1, col);
            vPosBuf.rewind();
            vColBuf.rewind();
            vUVBuf.rewind();

            GLES10.glVertexPointer(2, GLES10.GL_FLOAT, 0, vPosBuf);
            GLES10.glColorPointer(4, GLES10.GL_FLOAT, 0, vColBuf);
            GLES10.glTexCoordPointer(2, GLES10.GL_FLOAT, 0, vUVBuf);

            GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 6);

            Statistics.DrawCalls++;

            }
    }

private void vertex(float x, float y, float u, float v, Color col) {
        vPosBuf.putFloat(x);
        vPosBuf.putFloat(y);
        vColBuf.putFloat(col.R);
        vColBuf.putFloat(col.G);
        vColBuf.putFloat(col.B);
        vColBuf.putFloat(col.A);
        vUVBuf.putFloat(u);
        vUVBuf.putFloat(v);
    }

Всё более чем понятно — преобразуем координаты спрайта из world-space в camera-space, отсекаем спрайт, если он находится за пределами экрана, задаем стейты для GAPI (на данный момент, их всего два), заполняем вершинный буфер геометрией и рисуем на экран. Никакого смысла использовать VBO здесь нет, а на nio-буфферы можно получить прямой указатель без лишних копирований, так что никаких проблем с производительностью не будет. Обратите внимание — вершинный буфер выделяется заранее — аллокации каждый дравколл нам не нужны и вредны.

        // Vertex format:
        //   vec2 pos; -- 8 bytes
        //   vec4 color; -- 16 bytes
        //   vec2 uv; -- 8 bytes
        //   32 bytes total
        int numVerts = 6;
        vPosBuf = ByteBuffer.allocateDirect((4 * 8) * numVerts);
        vColBuf = ByteBuffer.allocateDirect((4 * 16) * numVerts);
        vUVBuf = ByteBuffer.allocateDirect((4 * 8) * numVerts);
        vPosBuf.order(ByteOrder.LITTLE_ENDIAN);
        vColBuf.order(ByteOrder.LITTLE_ENDIAN);
        vUVBuf.order(ByteOrder.LITTLE_ENDIAN);

Обратите внимание на вызовы ByteBuffer.order — это важно, по умолчанию, Java создаёт все буферы в BIG_ENDIAN, в то время как большинство Android-устройств — LITTLE_ENDIAN, из-за этого можно запросто накосячить и долго думать «а почему у меня буферы заполнены правильно, но геометрии на экране нет!?».

image

В процессе разработки игры, при отрисовке относительно небольшой карты с большим количеством тайлов, количество вызовов отрисовки возросло аж до 600, из-за чего FPS в игре очень сильно просел. Связано это с тем, что на старых мобильных GPU каждый вызов отрисовки означал пересылку состояния сцены видеочипу, из-за чего мы получали лаги. Фиксится это довольно просто: реализацией батчинга — специальной техники, которая «сшивает» большое количество спрайтов с одной текстурой в один и позволяет отрисовать хоть 1000, хоть 100000 спрайтов в один проход! Есть два вида батчинга, статический — когда объекты «сшиваются» при загрузке карты/в процессе компиляции игры (привет Unity) и динамический — когда объекты сшиваются прямо на лету (тоже привет Unity). На более современных мобильных GPU с поддержкой GLES 3.0 есть также инстансинг — схожая технология, но реализуемая прямо на GPU. Суть её в том, что мы передаём в шейдер параметры объектов, которые мы хотим отрисовать (матрицу, настройки материала и.т.п) и просим видеочип отрисовать одну и ту же геометрию, допустим, 15 раз. Каждая итерация отрисовки геометрии будет увеличивать счетчик gl_InstanceID на один, благодаря чему мы сможем расставить все модельки на свои места! Но тут уж справедливости ради стоит сказать, что в D3D10+ можно вообще стейты передавать на видеокарту «пачками», что здорово снижает оверхед одного вызова отрисовки.

image

Для загрузки спрайтов используется встроенный в Android декодер изображений. Он умеет работать в нескольких режимах (ARGB/RGB565 и.т.п), декодировать кучу форматов — в том числе и jpeg, что положительно скажется на финальном размере игры.

public void upload(ByteBuffer data, int width, int height, int format) {
        if(data != null) {
            int len = data.capacity();

            GLES10.glEnable(GLES10.GL_TEXTURE_2D);
            GLES10.glBindTexture(GLES10.GL_TEXTURE_2D, TextureId);
            GLES10.glTexImage2D(GLES10.GL_TEXTURE_2D, 0, GLES10.GL_RGBA, width, height, 0, GLES10.GL_RGBA, GLES10.GL_UNSIGNED_BYTE, data);
            GLES11.glTexParameteri(GLES10.GL_TEXTURE_2D, GLES10.GL_TEXTURE_MIN_FILTER, GLES10.GL_NEAREST);
            GLES11.glTexParameteri(GLES10.GL_TEXTURE_2D, GLES10.GL_TEXTURE_MAG_FILTER, GLES10.GL_NEAREST);

            Width = width;
            Height = height;
        }
    }

    public static Sprite load(String fileName) {
        InputStream is = null;
        try {
            is = Engine.Current.MainActivity.getAssets().open("sprites/" + fileName);

            BitmapFactory.Options opts = new BitmapFactory.Options();
            opts.inPreferredConfig = Bitmap.Config.ARGB_8888;

            Bitmap bmp = BitmapFactory.decodeStream(is, null, opts);
            ByteBuffer buf = ByteBuffer.allocateDirect(bmp.getRowBytes() * bmp.getHeight());
            bmp.copyPixelsToBuffer(buf);
            buf.rewind();

            Sprite ret = new Sprite();
            ret.upload(buf, bmp.getWidth(), bmp.getHeight(), FORMAT_RGBA);

            return ret;
        } catch (IOException e) {
            Engine.log("Failed to load sprite %s", fileName);

            throw new RuntimeException(e);
        }
    }

На этом реализация рендерера закончена. Да, все вот так просто :)
Переходим к двум остальным модулям — звук и ввод.

Звук и ввод


Как я уже говорил, звук я решитл реализовать на базе уже существующей звуковой подсистемы Android. Ничего сложного в её реализацир нет, можно сказать, нам остаётся лишь написать обёртку, необходимую для работы. Изначально я написал собственный загрузчик wav-файлов и хотел использовать AudioTrack — класс для воспрозизведения PCM-звука напрямую, но мне не понравилось, что в нём нет разделения на источники звука и буферы, из-за чего каждый источник вынужден заниматься копированием PCM-потока в новый и новый буфер…

image

Полная реализация звукового потока выглядит так. И да, с SoundPool нет возможности получить позицию проигрывания звука или узнать, когда проигрывание закончилось. Увы.

public static class Instance {
        private AudioStream parent;
        private int id;

        Instance(AudioStream parent) {
            this.parent = parent;
        }

        public void play() {
            id = sharedPool.play(parent.streamId, Audio.MasterAudioLevel, Audio.MasterAudioLevel, 0, 0, 1.0f);
        }

        public void stop() {
            sharedPool.stop(id);
        }
    }

    private static SoundPool sharedPool;
    private int streamId;

    static {
        Engine.log("Allocating SoundPool");
        sharedPool = new SoundPool(255, AudioManager.STREAM_MUSIC, 0);
    }

    public AudioStream(int streamId) {
        this.streamId = streamId;
    }

    @Override
    protected void finalize() throws Throwable {
        sharedPool.unload(streamId);
        super.finalize();
    }

    public static AudioStream load(String fileName) {
        AssetManager assets = Engine.Current.MainActivity.getAssets();

        try {
            AssetFileDescriptor afd = assets.openFd("sounds/" + fileName);
            int streamId = sharedPool.load(afd, 0);

            return new AudioStream(streamId);
        } catch (IOException e) {
            Engine.log("Failed to load audio stream %s", fileName);

            return null;
        }
    }

Не забываем и про музыку:

private MediaPlayer mediaPlayer;
    private boolean ready;

    public MusicStream(MediaPlayer player) {
        mediaPlayer = player;
    }

    public void forceRelease() {
        if(mediaPlayer.isPlaying())
            mediaPlayer.stop();

        mediaPlayer.release();
    }

    public void play() {
        if(!mediaPlayer.isPlaying())
            mediaPlayer.start();
    }

    public void pause() {
        if(mediaPlayer.isPlaying())
            mediaPlayer.pause();
    }

    public void stop() {
        if(!mediaPlayer.isPlaying())
            mediaPlayer.stop();
    }

    public boolean isPlaying() {
        return mediaPlayer.isPlaying();
    }

    public void setLoop(boolean isLooping) {
        mediaPlayer.setLooping(isLooping);
    }

    public static MusicStream load(String fileName) {
        AssetManager assets = Engine.Current.MainActivity.getAssets();

        try {
            AssetFileDescriptor afd = assets.openFd("music/" + fileName);
            MediaPlayer player = new MediaPlayer();
            player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
            player.setVolume(0.3f, 0.3f); // TODO: Move volume settings to Audio
            player.prepare();

            return new MusicStream(player);
        } catch (IOException e) {
            Engine.log("Failed to load audio stream %s", fileName);

            return null;
        }
    }

Да будет звук! Ну и про ввод не забываем:

public static final int TOUCH_IDLE = 0;
    public static final int TOUCH_PRESSED = 1;
    public static final int TOUCH_RELEASED = 2;

    public interface TextCallback {
        void onEnteredText(String str);
    }

    public static class TouchState {
        public boolean State;
        public int Id;
        public float X, Y;
    }

    public static int GAMEPAD_A = 0;
    public static int GAMEPAD_B = 1;
    public static int GAMEPAD_Y = 2;
    public static int GAMEPAD_X = 3;
    public static int GAMEPAD_LT = 4;
    public static int GAMEPAD_RT = 5;
    public static int GAMEPAD_DPAD_LEFT = 6;
    public static int GAMEPAD_DPAD_RIGHT = 7;
    public static int GAMEPAD_DPAD_UP = 8;
    public static int GAMEPAD_DPAD_DOWN = 9;
    public static int GAMEPAD_BUTTON_COUNT = 10;

    public static class GamepadState {
        public float AnalogX, AnalogY;
        public boolean[] Buttons;

        GamepadState() {
            Buttons = new boolean[GAMEPAD_BUTTON_COUNT];
        }
    }

    class TouchListener implements View.OnTouchListener {

        @Override
        public boolean onTouch(View view, MotionEvent motionEvent) {
            for(int i = 0; i < motionEvent.getPointerCount(); i++) {
                Touches[i].Id = motionEvent.getPointerId(i);

                // Convert from device-space to view-space.
                float xVal = motionEvent.getX() / Engine.Current.Graphics.DeviceWidth;
                float yVal = motionEvent.getY() / Engine.Current.Graphics.DeviceHeight;
                Touches[i].X = xVal * Engine.Current.Graphics.ViewWidth;
                Touches[i].Y = yVal * Engine.Current.Graphics.ViewHeight;

                if(motionEvent.getAction() == MotionEvent.ACTION_DOWN)
                    Touches[i].State = true;

                if(motionEvent.getAction() == MotionEvent.ACTION_UP)
                    Touches[i].State = false;
            }

            return true;
        }
    }

    public TouchState[] Touches;
    public GamepadState Gamepad;
    // Format - first int is KEYCODE mapped on Android, second is gamepad button
    private final int[] gamePadMapping =
            {
                    KeyEvent.KEYCODE_DPAD_CENTER, GAMEPAD_A,
                    KeyEvent.KEYCODE_BACK, GAMEPAD_B,
                    KeyEvent.KEYCODE_BUTTON_Y, GAMEPAD_Y,
                    KeyEvent.KEYCODE_BUTTON_X, GAMEPAD_X,
                    KeyEvent.KEYCODE_DPAD_UP, GAMEPAD_DPAD_UP,
                    KeyEvent.KEYCODE_DPAD_RIGHT, GAMEPAD_DPAD_RIGHT,
                    KeyEvent.KEYCODE_DPAD_LEFT, GAMEPAD_DPAD_LEFT,
                    KeyEvent.KEYCODE_DPAD_UP, GAMEPAD_DPAD_UP,
                    KeyEvent.KEYCODE_DPAD_DOWN, GAMEPAD_DPAD_DOWN
            };

    public Input() {
        Touches = new TouchState[5];

        for(int i = 0; i < Touches.length; i++)
            Touches[i] = new TouchState();

        Gamepad = new GamepadState();

        Engine.log("Initializing input subsystem...");
        Engine.Current.Graphics.GLView.setOnTouchListener(new TouchListener());
    }

    public int isTouchingZone(float x, float y, float w, float h) {
        boolean touching = false;

        for(int i = 0; i < Touches.length; i++) {
            touching = Touches[i].X > x && Touches[i].Y > y && Touches[i].X < x + w && Touches[i].Y < y + h;

            if(touching && Touches[i].State)
                return i;
        }

        return -1;
    }

    public boolean isAnyFingerInZone(float x, float y, float w, float h) {
        boolean touching = false;

        for(int i = 0; i < Touches.length; i++) {
            touching = Touches[i].X > x && Touches[i].Y > y && Touches[i].X < x + w && Touches[i].Y < y + h;

            if(touching && Touches[i].State)
                return true;
        }

        return false;
    }

    public void requestTextInput(String title, String target, TextCallback callback) {
        AlertDialog.Builder dlgBuilder = new AlertDialog.Builder(Engine.Current.MainActivity);

        TextView text = new TextView(Engine.Current.MainActivity);
        EditText editor = new EditText(Engine.Current.MainActivity);

        text.setText(target + ":");
        editor.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        dlgBuilder.setTitle(title);

        LinearLayout layout = new LinearLayout(Engine.Current.MainActivity);
        layout.addView(text);
        layout.addView(editor);
        layout.setOrientation(LinearLayout.VERTICAL);
        layout.setPadding(5, 5, 5, 5);
        dlgBuilder.setView(layout);
        dlgBuilder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialogInterface, int i) {
                callback.onEnteredText(editor.getText().toString());
            }
        });

        Engine.Current.MainActivity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                dlgBuilder.show();
            }
        });
    }

Сама реализация джойстика крайне простая — запоминаем координаты, куда пользователь поставил палец и затем считаем дистанцию положения пальца относительно центральной точки, параллельно нормализовывая их относительно максимальной дистанции:

public class Joystick {
    private Sprite joySprite;

    public float VelocityX;
    public float VelocityY;

    public float OriginX, OriginY;
    private float fingerX, fingerY;
    private int joyFinger;

    public Joystick() {
        joySprite = Sprite.load("ui_button.png");

        OriginX = -999;
        OriginY = -999;
    }

    private float clamp(float a, float min, float max) {
        return a < min ? min : (a > max ? max : a);
    }

    public void update() {
        int finger = 0;

        if((finger = Engine.Current.Input.isTouchingZone(0, 0, Engine.Current.Graphics.ViewWidth, Engine.Current.Graphics.ViewHeight)) != -1) {
            if(OriginX == -999) {
                OriginX = Engine.Current.Input.Touches[finger].X;
                OriginY = Engine.Current.Input.Touches[finger].Y;
            }

            float xdiff = (Engine.Current.Input.Touches[finger].X - OriginX) / Engine.Current.Graphics.ViewWidth;
            float ydiff = (Engine.Current.Input.Touches[finger].Y - OriginY) / Engine.Current.Graphics.ViewHeight;

            VelocityX = clamp(xdiff / 0.2f, -1, 1);
            VelocityY = clamp(ydiff / 0.2f, -1, 1);
        } else {
            OriginX = -999;
            OriginY = -999;
        }
    }

    public void draw() {
        VelocityX = 0;
        VelocityY = 0;
    }
}

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

Основа для игры есть, теперь переходим к её реализации!

Пишем игру


image

Писать игру я начал с создания первого уровня и реализации загрузчика уровней. В качестве редактора, я выбрал популярный и широко-известный TilEd — удобный редактор с возможностью экспорта карт в несколько разных форматов. Я лично выбрал Json, поскольку в Android уже есть удобный пакет для работы с этим форматом данных.

private void parseJson(String json) {
        try {
            JSONObject obj = new JSONObject(json);

            width = obj.getInt("width");
            height = obj.getInt("height");

            JSONArray jtileSet = obj.getJSONArray("tilesets").getJSONObject(0).getJSONArray("tiles");
            for(int i = 0; i < jtileSet.length(); i++) {
                JSONObject tile = jtileSet.getJSONObject(i);

                String name = tile.getString("image");
                name = name.substring(name.lastIndexOf("/") + 1);
                tileSet[tile.getInt("id")] = Sprite.load(name);
            }

            JSONArray layers = obj.getJSONArray("layers");

            this.tiles = new byte[width * height];
            Engine.log("Level size %d %d", width, height);

            for(int i = 0; i < layers.length(); i++) {
                JSONObject layer = layers.getJSONObject(i);
                boolean isTileData = layer.has("data");

                if(isTileData) {
                    JSONArray tiles = layer.getJSONArray("data");

                    Engine.log("Loading tile data");
                    for(int j = 0; j < tiles.length(); j++)
                        this.tiles[j] = (byte)(tiles.getInt(j) - 1);
                } else {
                    JSONArray objects = layer.getJSONArray("objects");

                    for(int j = 0; j < objects.length(); j++) {
                        JSONObject jobj = objects.getJSONObject(j);

                        Prop prop = new Prop();
                        prop.Sprite = tileSet[jobj.getInt("gid") - 1];
                        prop.Name = jobj.getString("name");
                        prop.X = (float)jobj.getDouble("x");
                        prop.Y = (float)jobj.getDouble("y");
                        prop.Visible = true;

                        String type = jobj.getString("type");
                        if(type.equals("invisible"))
                            prop.Visible = false;

                        props.add(prop);
                    }
                }
            }
        } catch (JSONException e) {
            e.printStackTrace(); // Level loading is unrecoverable error
            throw new RuntimeException(e);
        }
    }

Запекание батчей:

private void buildBatch() {
        batches = new HashMap<Sprite, Graphics2D.StaticBatch>();

        for(int i = 0; i < width; i++) {
            for(int j = 0; j < height; j++) {
                Sprite tile = tileSet[tiles[j * width + i]];

                if(!batches.containsKey(tile))
                    batches.put(tile, new Graphics2D.StaticBatch(tile, width * height));

                batches.get(tile).addInstance(i * 32, j * 32, Graphics2D.Color.White);
            }
        }

        for(Sprite spr : batches.keySet()) {
            batches.get(spr).prepare();
        }

        Engine.log("Generated %d batches", batches.size());
    }

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

public abstract class Entity {

    public float X, Y;
    public float ForwardX, ForwardY; // Forward vector
    public float RightX, RightY;
    public float Rotation;
    public boolean IsVisible;

    public int DrawingOrder;

    public float distanceTo(float x, float y) {
        x = X - x;
        y = Y - y;

        return (float)Math.sqrt((x * x) + (y * y));
    }

    public boolean AABBTest(Entity ent, float myWidth, float myHeight, float width, float height) {
        return X < ent.X + width && Y < ent.Y + height && ent.X < X + myWidth && ent.Y < Y + myHeight;
    }

    public void recalculateForward() {
        ForwardX = (float)Math.sin(Math.toRadians(Rotation));
        ForwardY = -(float)Math.cos(Math.toRadians(Rotation));
    }

    public void update() {
        recalculateForward();
    }

    public void draw() {

    }
}

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

@Override
    public void update() {
        super.update();

        joyInput.update();

        float inpX = joyInput.VelocityX;
        float inpY = joyInput.VelocityY;

        if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_LEFT]) {
            inpX = -1;
            Rotation = 270;
        }

        if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_RIGHT]) {
            inpX = 1;
            Rotation = 90;
        }

        if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_DOWN]) {
            inpY = 1;
            Rotation = 180;
        }

        if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_UP]) {
            inpY = -1;
            Rotation = 0;
        }

        X += inpX * (WALK_SPEED * Engine.Current.DeltaTime);
        Y += inpY * (WALK_SPEED * Engine.Current.DeltaTime);

        Engine.Current.Graphics.Camera.X = X - (Engine.Current.Graphics.ViewWidth / 2);
        Engine.Current.Graphics.Camera.Y = Y - (Engine.Current.Graphics.ViewHeight / 2);

        int finger = 0;
        if((finger = Engine.Current.Input.isTouchingZone(0, 0, Engine.Current.Graphics.ViewWidth, Engine.Current.Graphics.ViewHeight)) != -1) {
            Input.TouchState state = Engine.Current.Input.Touches[finger];

            aimX = state.X;
            aimY = state.Y;

            // Convert player position from world-space, to screen-space
            float ptfX = (X - Engine.Current.Graphics.Camera.X) - state.X;
            float ptfY = (Y - Engine.Current.Graphics.Camera.Y) - state.Y;

            Rotation = (float)Math.toDegrees(Math.atan2(-ptfX, ptfY));
            recalculateForward();

            if(nextAttack < 0) {
                GunItem currGun = Guns.get(EquippedGun);
                currGun.Gun.FireEffect.createInstance().play();
                nextAttack = currGun.Gun.Speed;

                Bullet bullet = new Bullet();
                bullet.Speed = 15;
                bullet.LifeTime = 3.0f;
                bullet.Rotation = Rotation;
                bullet.Damage = currGun.Gun.Damage;

                float bullX = sprites[currGun.Gun.Sprite].Width / 2;
                float bullY = sprites[currGun.Gun.Sprite].Height / 2;
                float fwXFactor = ForwardX * 19;
                float fwYFactor = ForwardY * 19;

                bullet.X = X + bullX - (Bullet.Drawable.Width / 2) + fwXFactor;
                bullet.Y = Y + bullY - (Bullet.Drawable.Height / 2) + fwYFactor;

                Game.current.World.spawn(bullet);
            }
        }
        nextAttack -= Engine.Current.DeltaTime;
    }

image

Список доступных стволов хранится в статическом массиве GunDescription:

public static Gun[] GunDescription = {
            new Gun("Glock-18", Player.SPRITE_HANDGUN, 20.0f, 0.4f, 20, 90, "glock18.wav", "pistol.png", 1500),
            new Gun("UZI", Player.SPRITE_HANDGUN, 20.0f, 0.15f, 20, 90, "uzi.wav", "pistol.png", 1500),
            new Gun("Deagle", Player.SPRITE_HANDGUN, 100.0f, 0.7f, 20, 90, "deagle.wav", "pistol.png", 1500),
            new Gun("TOZ-34", Player.SPRITE_HANDGUN, 100.0f, 1.1f, 20, 90, "shotgun.wav", "pistol.png", 1500),
            new Gun("XM1014", Player.SPRITE_HANDGUN, 90.0f, 0.6f, 20, 90, "shotgun.wav", "pistol.png", 1500),
            new Gun("AK47", Player.SPRITE_HANDGUN, 40.0f, 1.1f, 20, 90, "ak47.wav", "pistol.png", 1500),
            new Gun("M4-A1", Player.SPRITE_HANDGUN, 90.0f, 0.6f, 20, 90, "m4.wav", "pistol.png", 1500),
            new Gun("MiniFGun", Player.SPRITE_HANDGUN, 30.0f, 0.15f, 20, 90, "minigun.wav", "pistol.png", 1500)
    };

Ну и не забываем про реализацию зомби. Она тоже очень простая: есть базовый класс Zombie, от которого наследуются все монстры и который реализует несколько необходимых методов — повернуться в сторону игрока, идти вперед и конечно же атака!

image


@Override
    public void update() {
        super.update();

        Player player = Game.current.World.Player;
        rotateTowardsEntity(player);

        if(distanceTo(player.X, player.Y) > 35)
            moveForward(WALK_SPEED * Engine.Current.DeltaTime);
    }

Что у нас есть на данный момент?


Честно сказать, статья итак уже получилась слишком длинной. Я очень хотел написать игру, о разработке которой можно было бы рассказать в рамках одной не особо большой статьи, но с моим стилем написания текстов так сделать не выйдет. Придется разбивать на части!
Однако, некоторый прогресс уже есть и мы можем даже поиграть в игру на текущем ее этапе!

Как мы видим, игра (а пока что — proof of concept) работает довольно неплохо на всех устройствах, которые были выбраны для тестирования. Однако это ещё не всё — предстоит добавить конечную цель игры (набор очков), магазин стволов и разные типы мобов. Благо, это всё реализовать уже совсем несложно. :)

Заключение


Написать небольшую игрушку с нуля в одиночку вполне реально. Разработка достаточно больших проектов конечно же требует довольно больших человекочасов, однако реализовать что-то своё, маленькое может и самому!

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



Возможно, захочется почитать и это:


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


  1. bodyawm Автор
    12.09.2023 08:04
    +17

    Друзья! Интересен ли вам такой формат публикаций окологеймдевной направленности?

    Я могу довольно много рассказать и показать про программирование игр для видеокарт из 90х. А ведь там тоже есть много чего интересного - например, у S3 Virge, видеокарты, известной как 3D Decelerator, было собственное 3D графическое API! Малину портил низкий филлрейт, но в целом, сама реализация была очень даже ничего!

    А у ATI помимо D3D был CIF - ATI C Interface, нативное графическое API под самые первые графические ускорители Rage


    1. StjarnornasFred
      12.09.2023 08:04
      +4

      Очень интересен! Бро, да ты топ Хабра последних лет!


      1. bodyawm Автор
        12.09.2023 08:04

        Спасибо! Расчехляю Virge из списанных офисов! Не зря ведь мне железа времен P4 надарили :)


        1. tonyrouge
          12.09.2023 08:04

          ViRGE? Из офисов на Р4? :)

          Четвёртый пень — это уже поздние AGP и ранние PCIe видяхи (на досках с 775, кто ниасилил кор2дуо). Каноничная сборка — около 3 ГГц пенёк, 512 рамы ддр1 и фуфыкс 5600 вместо видеокарты.

          ViRGE у меня стояла в очень бородатые годы на Am 5x86 :)


          1. bodyawm Автор
            12.09.2023 08:04

            VIRGE с более старых ПК - P III, P I. Эт просто у меня рабочих материностарше P IV нет :)


            1. tonyrouge
              12.09.2023 08:04
              +1

              У меня валяется Lucky Star с парой дутых кондёров, пень ММХ на 166 МГц и графоний Rage II :)
              Увы, в АТХ-питание она не умеет. Вменяемый АТ-питальник ещё попробуй найди...


              1. bodyawm Автор
                12.09.2023 08:04

                Так AT же - обычные штыри. Теоретически никто не мешает сварганить свой переходик из ATX.


                1. MaFrance351
                  12.09.2023 08:04
                  +1

                  А два АТшных хвоста можно откусить от двух сгоревших АТХов, где они использовались как AUX_PWR для некоторых старых плат.


                  1. bodyawm Автор
                    12.09.2023 08:04

                    И точно ведь!


  1. dlinyj
    12.09.2023 08:04
    +5

    Вот это хабратортно! Снимаю шляпу, очень круто и доступно показано. А APK попробовать есть?


    1. bodyawm Автор
      12.09.2023 08:04
      +5

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


      1. dlinyj
        12.09.2023 08:04

        Будем ждать :)


  1. vesper-bot
    12.09.2023 08:04
    +8

    Ужасно напоминает одну флэшевую игрушку, где по джунглям бегал такой чел с шотганом и валил голых дикарей, которые его имели если ловили. Тоже вид сверху, почти такие же спрайты, та же тактика сражений — пятиться пока много не соберется, потом бабахнуть и снести всю толпу.


    1. bodyawm Автор
      12.09.2023 08:04
      +3

      У этой игры есть дальний "батя" - Crimsonland, только там упор в жестокость :)


    1. Vsevo10d
      12.09.2023 08:04
      +3

      Escopeta.

      Жаль, всякие однообразные анимешные раздевалки и факеры как-то портируют с умершего флэша, чтобы в браузере играть, а эту легенду - нет.


      1. iliazeus
        12.09.2023 08:04

        Попробуйте Ruffle. Учитывая возраст игры, думаю, он ее спокойно переварит. SWF-файл гуглится в интернете.


        1. vesper-bot
          12.09.2023 08:04

          Я пользуюсь Supernova, она вроде больше переваривает. Вроде только GC:L в ней не пошел у меня.


  1. yar
    12.09.2023 08:04
    -1

    А чем не устраивает, например, LibGDX?

    Я его выбираю по тем же соображениям, что чистая Java, и приложение весит, ну не скажу что менее 1Мб, но менее 3Мб.

    Как по мне разница между 1Мб и 3Мб не такая большая, как писать работу с OpenGL самому и использовать достаточно мощный инструмент.


    1. bodyawm Автор
      12.09.2023 08:04
      +3

      Так в этом и прикол - что все пилишь сам :)

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


      1. yar
        12.09.2023 08:04
        -1

        Ну в engine же важно не то, что он просто есть, а то, какое комьюнити, сколько багов исправлено в движке, есть ли поддержка, есть ли перспективы.

        Например LibGDX помимо Android может делать сборки для desktop, для webGL, а не так давно мог и для iOS.

        Я тоже много что программирую довольно давно, и СУБД свою делал и ОР-маппер, и поисковик для интернет, но это никому не нужно, решение развивается только если есть большое комьюнити. Иначе получается типа "я на asm написал крутую прогу резидентную для DOS зацените пацаны, CRLT-ALT-DEL перехватываю", но это же чисто фан без практической пользы для бизнеса или для комьюнити.


        1. bodyawm Автор
          12.09.2023 08:04
          +3

          Дак я в статье и не говорю, что собираюсь выложить игру в гугл плей и заработать мульены)

          Это just for f


          1. yar
            12.09.2023 08:04
            -9

            Ну дак а все же ждут рецепт как же заработать мульёны. Кто как пилит свою фигню в гараже, которая не приносит денег, в целом, никому не интересно.


            1. bodyawm Автор
              12.09.2023 08:04
              +5

              в целом, никому не интересно.

              Вот зачем пытаться выдать своё собственное мнение за мнение окружающих? :)

              Зайдите на мои профили на DTF, Habr, Pikabu, обратите внимание на охват статей и убедитесь, что это не просто интересно - но ещё и достаточно захватывающе!

              Про libGDX, очевидно, знаю. Но что это меняет? :)


              1. yar
                12.09.2023 08:04
                -9

                В Вашем профиле Хабра я не нашел прямых ссылок на Pikabu и DTF, поэтому сочту Вашу манеру изложения как просто "понты", ну типа "я есть там, я есть сям, поэтому думайте что я крутой", про LibGDX Вы тоже не ответили по существу, почему Вам 1Мб доставляет, а уже аж целых 3Мб противны.


                1. Deciptikon
                  12.09.2023 08:04
                  +8

                  Прочтите название статьи "Сам написал, сам поиграл: как я написал 2D-игру для Android полностью с нуля, весом менее 1мб?"
                  С нуля - значит без сторонних библиотек, без заготовок, без прочего разного.
                  Понты здесь ни при чем, ну или почти ни при чем... Человек захотел написать игру полностью сам, написал, ему понравилось на столько что он захотел поделиться своим опытом и он поделился.
                  Вы же будто не читали статью, спрашиваете про какие-то "мульёны", когда в самом начале оговорено про то, что сделанное, сделано для себя. Называете понтами желание поделиться радостью от проделанной работы и, в комментарии ниже, косвенно обвиняете в незнании существования LibGDX, хотя человек, опять же, в самом начале оговорился о том, что будет писать "с нуля".
                  У меня лишь один вопрос: "Что с вами?". И я сразу на него отвечу, на мой взгляд вы либо завидуете, либо не понимаете, либо хотите внимания (это наиболее вероятно).


                  1. yar
                    12.09.2023 08:04
                    -7

                    Типа я могу назвать статью "как ходить в туалет" и это оправдает мои подробные описания как я туда сходил? Тут Хабр! как никак, все хотят стать крутыми и богатыми, а не узнать как вы там сходили в то что в теме указали.


                    1. dlinyj
                      12.09.2023 08:04
                      +4

                      Очень интересно читать от автора с кучей заминусованных статей топовому автору на Хабре советы как ему писать статьи. Даже нечего сказать...


                      Автор статьи, ты крут, не корми тролля.


                      1. yar
                        12.09.2023 08:04
                        -4

                        По Вашему мнению топовость определят правость? (и кстати, у меня не все статьи и коменты в минус, а коменты в минус так и вообще ничего не значат, их в основном оценивали под эмоциями, как Вы сейчас).


                      1. bodyawm Автор
                        12.09.2023 08:04
                        +3

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

                        Считаете, что имеет место быть накрутка, а статье не место на Хабре - напишите@Boomburum, это один из самых главных дядек на Хабре Ж)


                      1. yar
                        12.09.2023 08:04
                        -2

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


                      1. bodyawm Автор
                        12.09.2023 08:04
                        +4

                        Потому что вы упорно не хотите читать то, что я вам пишу.

                        Так в этом и прикол - что все пилишь сам :)


                      1. yar
                        12.09.2023 08:04

                        Ну я тоже многое пилю сам, но типа бут лоадер, ОС, СУБД, IDE , и тп сам не пытаюсь писать, хотя ранее и это пытался. Вот и не пойму в чем у Вас прикол писать именно это, ведь есть тоже удобные надстройки.


                      1. Deciptikon
                        12.09.2023 08:04
                        +3

                        Я не автор статьи, но попробую донести да вас мысль как я её понял. Автор поставил себе цель, написать самому с нуля. Дело не в 1 или 3-х Мб, если бы получилось в итоге 2 Мб, думаю автор не расстроился бы.

                        Представьте, вы пишите вопросы автору статьи и ваша цель, узнать "почему 1 а не 3?", а я бы предъявил вам претензию, мол почему пишите без вступления и куда подевали кульминацию, всего-то надо написать не 1 абзац а 3, зато как интересно было бы... Ваша цель - не литературная мини-статья, а краткий вопрос в комментариях, поэтому вам и не нужны эти 2 абзаца лишнего текста.

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

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

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


                      1. bodyawm Автор
                        12.09.2023 08:04
                        +1

                        Человека уже забанили и закинули в read-only. Он вчера флудил комментами, называл меня троллем и накрутчиком плюсов))


                      1. iamkisly
                        12.09.2023 08:04
                        +1

                        Человек весь диалог демонстрирует не понимание что такое "just for fun" и "потому что я это могу". Он же вот пишет, что

                        которая не приносит денег, в целом, никому не интересно

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


                      1. NutsUnderline
                        12.09.2023 08:04

                        автора с кучей заминусованных статей топовому автору

                        Справедливости ради, а там разве прям куча минусов (ненашел)? просто "ударился во все тяжкие" похоже


                      1. bodyawm Автор
                        12.09.2023 08:04
                        +1

                        Его недавние статьи удалили с переводом в ридонли


                      1. dlinyj
                        12.09.2023 08:04
                        +1

                        В момент беседы у автора половина статей была заминусована, а сейчас то ли он сам скрыл их, то ли администрация удалила.


    1. CrashLogger
      12.09.2023 08:04
      +6

      Писать велосипеды прикольно! Программисты это любят. И если уж на работе нам не дают этим заниматься, то в свободное время никто не запретит )


  1. Alexeyslav
    12.09.2023 08:04
    +1

    Хотел тоже поразрабатывать что-то под андроид, даже студию поставил, а ОНА НЕ РАБОТАЕТ. Господа, 2023 год на дворе, современный инструмент.... а ЕМУ НЕ НРАВЯТСЯ кирилица в путях, а поскольку она находится в имени профиля пользователя, исправить это малой кровью не представляется возможным. Не пересоздавать же профиль на компьютере и перенастраивать остальной софт только ради студии? Так что, увы, знакомство с андроидом - отложено пока не починят эту проблему. Вероятно, это будет чуть раньше чем никогда.


    1. NutsUnderline
      12.09.2023 08:04
      +3

      сам факт - занятный, но вывод, пардон, не программистский. входной фильтр не пройден ;)


      1. Alexeyslav
        12.09.2023 08:04
        +1

        О, я понял - это специально сделано, чтобы отсеять тех кто чем-то отличается от массы. Как минимум тем что заводит профиль на системе в кирилице.


    1. Firsto
      12.09.2023 08:04
      +1

      пересоздавать же профиль на компьютере и перенастраивать остальной софт

      Достаточно либо создать символическую ссылку (командой mklink /J newName originalName), либо просто переименовать папку и изменить путь в ProfileImagePath по адресу HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\{___USERID___}


      1. Alexeyslav
        12.09.2023 08:04

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

        В случае переименования папки, как это сделать из-под активного профиля? никак... а потом ещё и бороться с запомненными ранее путями с упоминанием старого имени профиля. Это вообще не вариант...


  1. MajorMotokoKusanagi
    12.09.2023 08:04
    +2

    @bodyawm, не особо понял, как у Вас прорисовка кадров происходит. Сцена перерисовывается полностью, вместе со статическим (ли?) фоном? Есть же замечательная штука - FrameLayout. Первый в списке дочерних View будет самым "нижним", последний - верхним (слои наоборот). Статический фон - он статический, и прорисовка анимации (спрайты персонажей, оружия, лучей выстрела) должна быть проще.


    1. MajorMotokoKusanagi
      12.09.2023 08:04

      Объясню (дополню мысль). Фон - загружается раз, в нижнем View, вся анимация боя - прорисовка в верхнем View, или наоборот - в зависимости от взаимодействия с ландшафтом. Графика тут проста, без заморочек, как понимаю.


    1. bodyawm Автор
      12.09.2023 08:04
      +1

      Привет, нет, так делать нельзя. В Android 2.x отрисовка 2D полностью софтварная и лежит на процессоре - для простых игр типа три в ряд подойдет, для динамичных - нет. Кроме того, наложение полупрозрачного View поверх или "под низ" рендертаргета GLES вызовет дикие тормоза.


      1. bodyawm Автор
        12.09.2023 08:04

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


        1. iamkisly
          12.09.2023 08:04

          Не понятно, чем вам поможет создание текстурных атласов учитывая что вы пишете, что отривовка полностью софтварная