Новая игра со старой атмосферой на Three.js. Часть 2

    В первой части я рассказал о проблемах, с которыми я столкнулся в процессе создания 3D игры под браузер c использованием Three.js. Теперь я бы хотел подробно остановиться на решении некоторых важных задач при написании игры, типа конструирования уровней, определения столкновений и адаптации изображения под любые пропорции окна браузера.


    Схемы уровней


    Собственно, сами уровни создаются в 3D редакторе, а именно, их геометрия, наложение текстур, запекание теней и т.д. Все это я описал в первой части. Зачем нужны еще какие-то схемы? Дело в том, что Three.js не предлагает какого-то физического движка, и я использую схемы уровней для определения препятствий.


    Three.js для решения задачи столкновений предлагает только рейтрейсинг (raytracing) — простейший способ определения пересечения геометрии объектов. В принципе, его можно использовать, и я даже уже это делал в одном из своих других проектов. Это был виртуальный город прямо на сайте, в браузере. По городу можно передвигаться и не проходить сквозь стены.


    На случай, когда при движении происходит пересечение геометрии игрока и здания, я реализовал отталкивание игрока на некоторое расстояние в противоположную от стены сторону. Но для этого объекты должны быть параллелепипедами. Вокруг некоторых сложных объектов я создавал коллайдеры (будем так называть невидимые объекты, которые играют роль препятствий и не дают игроку пройти сквозь себя), по которым и отрабатывались пересечения. А нижние части некоторых зданий, представляющие из себя просто «коробки», иногда сами использовались как коллайдеры.


    На геометрически сложных объектах рейтрейсинг может не работать или вести себя неадекватно. И, как раз, в качестве варианта решения можно внедрять в объект не один, а несколько маленьких невидимых коллайдеров в форме параллелепипедов со 100% прозрачностью, составленных рядом друг с другом и друг на друга, примерно повторяя форму объекта.

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


    • Во-первых, мне захотелось автоматизировать процесс создания массива коллайдеров.
    • Во-вторых, можно использовать лишь информацию о коллайдерах, то есть, их координаты в пространстве, и не нагружать саму 3D сцену какими-то лишними пустыми объектами.
    • В-третьих, поскольку в игре используется только вид сбоку и одна из координат при движении никогда не меняется, то можно использовать расчет пересечений только по двум координатам.
    • И в-четвертых, после всего, собственно, останется схема уровня. Более того, придумывать новые уровни, как раз, удобно начиная с такой схемы. Можно в любом графическом редакторе просто таскать блоки по экрану, конструируя новые коридоры и препятствия, а затем запустить скрипт и получить информацию о коллайдерах. То есть, частично решается проблема редактора уровней.

    Я написал скрипт, который принимает такие входные параметры как имя файла схемы уровня (png) и цвет, заполнение которым интерпретируется как препятствие. Цвет свободного пространства по умолчанию черный. Для обработки скриптом схема каждого уровня должна быть сохранена в отдельный файл png. Например, для самого нижнего уровня это выглядит так:


    Я условился, что один блок должен быть шириной 80 пикселей и высотой 48 пикселей. Это соответствует 4 x 2.4 метрам в 3D мире. Можно было бы сделать 40 x 24 пикселей, то есть десятикратно, но на картинке это выглядит мелковато.

    Результат работы скрипта по первому уровню (изображение обрезано справа):


    Скрипт выполняется в браузере. Думаю, html разметку приводить нет смысла, она элементарна: поля ввода данных и кнопка запуска. Далее на canvas отображается прочитанная картинка. И в результате работы скрипта, под картинкой выводится массив в масштабе 3D мира, который содержит левые нижние и правые верхние координаты каждого блока, причем со смещением, заданным в скрипте для каждого уровня. Этот массив можно скопировать и вставить в список коллайдеров, чтобы использовать в игре (об этом ниже), он будет храниться в какой-то константе. На самой картинке также появляются координаты, но в системе отсчета 2D изображения. Эти цифры выводятся в центре каждого блока и позволяют проконтролировать, все ли блоки попали в расчет. Сами по себе эти цифры ни для чего не нужны, кроме как для визуального контроля. Некоторые блоки, например, такие как колонны, между которыми проходит игрок, учитываться не должны. О том, какие объекты исключаются из расчета — ниже.

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



    Теперь — о том, каким образом скрипт учитывает блоки:

    • Схема обрабатывается блоками 80x48, в каждом из которых берется область со 2-го по 79-й пиксель по горизонтали и со 2-го по 47-й пиксель по вертикали. Первый и последний пиксели не используются, чтобы вокруг блоков можно было создать черную рамку шириной 1 пиксель, это улучшает визуальное восприятие схемы и облегчает ее создание.
    • Просматриваются все пиксели верхней строки блока. Если среди них есть окрашенные, то в итоговый массив идут координаты блока, от первого и до последнего окрашенного пикселя по горизонтали и на полную высоту блока по вертикали. Это будет глухим блоком на полную или частичную ширину.
    • Просматриваются все пиксели нижней строки блока. Если среди них есть окрашенные, но при этом нет ни одного окрашенного в верхней строке, то в итоговый массив идут координаты блока от первого и до последнего окрашенного пикселя по горизонтали и на 3 пикселя снизу по вертикали. Это будет платформой, по которой можно ходить. Внутри одного блока может быть несколько горизонтальных платформ. Платформы распознаются только в нижней части блока. Координаты платформы «утапливаются» в блок, который находится ниже, чтобы поверхность платформы оказалась на одном уровне с соседними блоками — не платформами.
    • Колонны и прочие украшательства внутри пустого блока не обрабатываются, поскольку рассматривается только верхняя и нижняя строка пикселей. Поэтому внутри блока можно размещать декор, пояснения к схеме, указатели, колонны и т.д., не боясь, что это как-то повлияет на результат работы скрипта.

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

    Код
    ap = {
    
        //смещения уровней по горизонтали и вертикали (позиционирование в пространстве), измеряется в 3D в блоках
        lvd: {
            'lv01.png': {
                invw: false,
                invh: true,
                level_dw: -8.5,
                level_dh: -1.5
            },
            'lv02.png': {
                invw: true,
                invh: true,
                level_dw: -19.5,
                level_dh: -5.5
            }
        },
    
        blockw: 80, //размеры блоков в 2D
        blockh: 48, //размеры блоков в 2D
        sc3d: 0.05, //масштаб, заданный в 3D мире
        ex: 100, //точность в 3D (кол-во знаков после запятой)
    
    
        v: {
            data: []
        },
        i: 0,
        par: {},
        datai: [],
        resi: [],
        ars: [],
        fStopEncode: false,
    
    
        blockColor: function(cl) {
            document.getElementById('input_cl').value = cl;
        },
    
    
        startEncode: function() {
    
            //домножить смещение уровня на размер блоков
            for (var key in ap.lvd) {
                ap.lvd[key].dw = ap.lvd[key].level_dw * ap.blockw;
                ap.lvd[key].dh = ap.lvd[key].level_dh * ap.blockh;
            };
    
            document.getElementById('startbtn').style.display = 'none';
            document.getElementById('startmsg').style.display = 'block';
    
            var cl = document.getElementById('input_cl').value;
            var fld = document.getElementById('input_fld').value;
            var nm = document.getElementById('input_nm').value;
    
            ap.nm = nm;
    
            ap.par = {
                path: [fld + '/', nm],
                key: [nm],
                cl: aplib.hexToRgb(cl.substring(1, 7))
            };
    
            setTimeout(function() {
                ap.datai[ap.par.key] = new Image();
                ap.datai[ap.par.key].onload = function() {
                    ap.parseData();
                };
                ap.datai[ap.par.key].src = ap.par.path[0] + ap.par.path[1];
            }, 500);
        },
    
    
        stopEnode: function(e) {
            if (typeof ap !== "undefined") {
                if (e.keyCode == 27) {
                    console.log('stop');
                    ap.fStopEncode = true;
                };
            };
        },
    
    
        parseData: function() {
            ap.w = ap.datai[ap.par.key[0]].width, ap.h = ap.datai[ap.par.key[0]].height;
            aplib.initCanv(ap.w, ap.h);
            ctx.drawImage(ap.datai[ap.par.key[0]], 0, 0, ap.w, ap.h, 0, 0, ap.w, ap.h);
            ap.ars = [];
            ap.i = 0;
            setTimeout(function() {
                ap.parseData1();
            }, 1000);
    
        },
    
        parseData1: function() {
            if (ap.i < ap.par.key.length) {
                document.getElementById('info').innerHTML = '' + ap.nm;
                ap.blocksw = Math.floor(ap.w / ap.blockw);
                ap.blocksh = Math.floor(ap.h / ap.blockh);
                ap.ar = [];
                ap.arv = {};
                ap.hi = 0;
                ctx.fillStyle = '#CCCCCC';
                ap.parseData2();
            } else {
                document.getElementById('startbtn').style.display = 'block';
                document.getElementById('startmsg').style.display = 'none';
            };
        },
    
        parseData2: function() {
            if (ap.hi < ap.blocksh) {
                ap.ar.push([]);
                ap.wi = 0;
                ap.parseData3();
            } else {
                ap.parseData4();
            };
        },
    
        parseData3: function() {
            var k = '';
            if (ap.wi < ap.blocksw) {
                var fground = true,
                    fvari = false,
                    fempty = true;
    
                var upx1 = 0,
                    upx2 = 0,
                    dnx1 = 0,
                    dnx2 = 0;
                var upxf = false,
                    dnxf = false;
    
                for (var wii = 1; wii < ap.blockw - 2 + 2; wii++) {
                    pixelDatai = ctx.getImageData(ap.wi * ap.blockw + wii, ap.hi * ap.blockh + 1, 1, 1).data; //верхняя строка
                    pixelDatai2 = ctx.getImageData(ap.wi * ap.blockw + wii, (ap.hi + 1) * ap.blockh - 3, 1, 1).data; //нижняя строка
    
                    if ((pixelDatai[0] == ap.par.cl.r) & (pixelDatai[1] == ap.par.cl.g) & (pixelDatai[2] == ap.par.cl.b)) {
                        //есть совпадающие с ground компоненты верхней строки
                        if (upxf == false) {
                            upxf = true;
                            upx1 = wii;
                        };
                    } else {
                        //пустота в верхней строке
                        if (upxf == true) {
                            upx2 = wii + 1;
                            upx1--;
                            //добавить блок земля
                            dy = -1; //для 3D блок за счет плиты поднимается на 1
                            ap.v.data.push([ap.wi * ap.blockw + upx1, ap.hi * ap.blockh + dy, ap.wi * ap.blockw + upx2, ap.hi * (ap.blockh) + ap.blockh - 1]);
                            upxf = false;
                            upx1 = 0;
                            upx2 = 0;
                        };
                    };
    
                    if ((pixelDatai2[0] == ap.par.cl.r) & (pixelDatai2[1] == ap.par.cl.g) & (pixelDatai2[2] == ap.par.cl.b)) {
                        //есть совпадающие с ground компоненты в нижней строке
                        if (upxf == false) {
                            if (dnxf == false) {
                                dnxf = true
                                dnx1 = wii;
                            };
                        };
                    } else {
                        if (upxf == false) {
                            if (dnxf == true) {
                                dnx2 = wii + 1;
                                dnx1--;
                                //добавить блок платформа
                                dy = 2; //для 3D плита опускается на 2
                                ap.v.data.push([ap.wi * ap.blockw + dnx1, (ap.hi + 1) * ap.blockh - 3 + dy, ap.wi * ap.blockw + dnx2, (ap.hi + 1) * ap.blockh - 3 + 2 + dy]);
                                dnxf = false;
                                dnx1 = 0;
                                dnx2 = 0;
                            };
                        };
                    };
    
                };
    
                if (ap.fStopEncode == true) {
                    ap.hi = ap.h, ap.wi = ap.w, i = ap.par.key.length;
                };
    
                setTimeout(function() {
                    ap.wi++;
                    ap.parseData3();
                }, 10);
    
            } else {
                ap.hi++;
                ap.parseData2();
            };
        },
    
    
        parseData4: function() {
            setTimeout(function() {
                var t, tw, tx, ty, ar = [];
                //подписать блоки
                for (var i = 0; i < ap.v.data.length; i++) {
                    ar = ap.v.data[i];
                    t = ar[0] + ';' + (ar[1]+1) + '<br/>' + ar[2] + ';' + (ar[3]+1);
                    tw = ar[2] - ar[0];
                    tx = ar[0];
                    ty = ar[1] + Math.floor((ar[3] - ar[1]) / 2) - 0;
                    aplib.Tex2Canvas(ctx, t, 'normal 10px Arial', 10, '#CCCCCC', tx, ty, tw, 0, 'center', 'top');
                };
    
                ap.parseData5();
    
            }, 10);
        },
    
    
        parseData5: function() {
            var t, tw, tx, ty, ar = [],
                n;
    
            //скорректировать координаты под 3D
            var lv = ap.lvd[ap.nm];
            for (var i = 0; i < ap.v.data.length; i++) {
                ar = ap.v.data[i];
                ar[0] += lv.dw;
                ar[1] += lv.dh;
                ar[2] += lv.dw;
                ar[3] += lv.dh;
                if (lv.invh == true) {
                    n = -ar[1];
                    ar[1] = -ar[3];
                    ar[3] = n;
                };
                if (lv.invw == true) {
                    n = -ar[0]
                    ar[0] = -ar[2];
                    ar[2] = n;
                };
                ar[0] = Math.round(ap.sc3d * ar[0] * ap.ex) / ap.ex;
                ar[1] = Math.round(ap.sc3d * ar[1] * ap.ex) / ap.ex;
                ar[2] = Math.round(ap.sc3d * ar[2] * ap.ex) / ap.ex;
                ar[3] = Math.round(ap.sc3d * ar[3] * ap.ex) / ap.ex;
            };
    
            //отсортировать по горизонтальной оси
            ap.v.data.sort(aplib.sortBy0);
    
            console.log(ap.v.data);
    
            document.getElementById('divresult').innerHTML = JSON.stringify(ap.v.data);
        }
    
    };
    
    
    aplib = {
    
        hexToRgb: function(hex) {
            var arrBuff = new ArrayBuffer(4);
            var vw = new DataView(arrBuff);
            vw.setUint32(0, parseInt(hex, 16), false);
            var arrByte = new Uint8Array(arrBuff);
            return {
                r: arrByte[1],
                g: arrByte[2],
                b: arrByte[3],
                s: arrByte[1] + "," + arrByte[2] + "," + arrByte[3]
            };
        },
    
    
        //отображение текста на canvas
        Tex2Canvas: function(ctx, t, font, lin, fcolor, x, y, w, h, haln, valn) {
            //left, right, center, center-lim-вместить
            ctx.font = font;
            ctx.fillStyle = fcolor;
            var l = 0;
            var tx = x;
            var ftw = false;
            var tw = 1;
            var arr = t.split('<br/>');
            for (var i = 0; i < arr.length; i++) {
                arr[i] = arr[i].split(' ');
            };
            for (var i = 0; i < arr.length; i++) {
                var s = '',
                    slen = 0,
                    s1 = '',
                    j = 0;
                while (j < arr[i].length) {
                    var wordcount = 0;
                    while ((slen < w) & (j < arr[i].length)) {
                        s = s1;
                        s1 = s + arr[i][j] + ' ';
                        slen = ctx.measureText(s1).width;
                        if (slen < w) {
                            j++;
                            wordcount++;
                        } else {
                            if (wordcount > 0) {
                                s1 = s;
                            } else {
                                j++;
                            };
                        };
                    };
                    ftw = false;
                    tw = ctx.measureText(s1).width;
                    if (haln == 'center') {
                        tx = x + Math.round((w - tw) / 2);
                    };
                    if (haln == 'right') {
                        tx = x + Math.round((w - tw));
                    };
                    if (haln == 'center-lim') {
                        if (tw > w) {
                            tw = w;
                        };
                        if (tw < 1) {
                            tw = 1;
                        };
                        tx = x + Math.round((w - tw) / 2);
                        ftw = true;
                    };
                    if (ftw == false) {
                        ctx.fillText(s1, tx, l * lin + y);
                    } else {
                        ctx.fillText(s1, tx, l * lin + y, tw);
                    };
                    if (s1 == '') {
                        j = arr[i].length + 1;
                    };
                    l++;
                    s1 = '';
                    slen = 0;
                };
            };
            return Math.round(tw);
        },
    
    
        //создание canvas
        initCanv: function(w, h) {
    
            function canvErr() {
                document.getElementById('divcanv').innerHTML = '<div style="height:130px"></div><div style="width:440px; border:#FFFFFF 1px solid; margin:10px; padding:4px; background-color:#000000"><p class="txterr">---> Error<br/>HTML5 Canvas is not supported!<br/>Please, update your browser!</p></div>';
            };
    
            if (w == 0) {
                w = 740;
                h = 680;
            };
    
            elcanv = document.getElementById('divcanv');
            elcanv.innerHTML = '<canvas id="canv" style="width:' + w + 'px; height:' + h + 'px; display:block;" width="' + w + '" height="' + h + '"></canvas>';
            canvas1 = document.getElementById('canv');
    
            if (!canvas1) {
                canvErr();
                return 0;
            } else {
                if (canvas1.getContext) {
                    ctx = canvas1.getContext('2d');
                    ctx.clearRect(0, 0, w, h);
                    return 1;
                } else {
                    canvErr();
                };
            };
        },
    
    
        sortBy0: function(i, ii) {
            if (i[0] > ii[0]) return 1;
            else if (i[0] < ii[0]) return -1;
            else return 0;
        }
    
    };


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

    collisionsUpdate: function(x, y, dw, dh) {
        var coll = [];
        var o;
        for (var i = 0; i < ap.v.lv.d.length; i++) {
            o = ap.v.lv.d[i];
            if ((o[0] >= x - ap.v.dw) & (o[2] <= x + ap.v.dw)) {
                if ((o[1] >= y - ap.v.dh) & (o[3] <= y + ap.v.dh)) {
                    coll.push(o);
                };
            };
        };
        ap.v.coll = coll;
    },

    Здесь на входе x,y — текущие координаты игрока, dw,dh — расстояние, на котором нужно искать блоки по горизонтали и по вертикали, например 12 и 8 метров. Иными словами, возьмем все блоки вокруг игрока в квадрате 24x16 метров. Они и будут участвовать в поисках столкновений. ap.v.lv.d[i] — это элемент массива блоков текущего уровня, собственно, он сам — тоже массив из 4 чисел, задающих границы одного блока — [x1, y1, x2, y2], поэтому для проверки вхождения в квадрат по горизонтали берем элементы с индексами 0 и 2, а по вертикали — 1 и 3. Если есть совпадение, то добавляем этот блок в список для коллизий ap.v.coll.

    При движении игрока будем обновлять этот список коллизий, но, в целях экономии производительности, будем это делать не при каждом шаге (а точнее, отрисовке кадра), а при выходе игрока за некий квадрат, чуть меньший, заданный в ap.v.collwStep и ap.v.collhStep, например 8 и 4 метра. То есть, будем пересобирать массив столкновений заново, когда игрок пройдет определенный путь по горизонтали или по вертикали от своей исходной позиции. При этом, запомним его позицию, при которой мы пересобирали массив, чтобы использовать ее для следующей итерации. pers[ax] — здесь под ax понимается ось координат (axe), она может быть x или z, в зависимости от направления коридора, по которому идет игрок.

    //обновить массив коллизий
    if ((Math.abs(pers[ax] - ap.v.collw) > ap.v.collwStep) || (Math.abs(pers.y - ap.v.collh) > ap.v.collhStep)) {
    	ap.v.collw = pers[ax];
    	ap.v.collh = pers.y;
    	ap.collisionsUpdate(pers[ax], pers.y, 12, 8);
    };

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

    Определение столкновений при каждой отрисовке кадра по массиву коллизий, подготовленному выше:

    Код
    collisionsDetect: function(x, y, xOld, yOld, up) {
    
        //up=-1 - вверх
        var res = false,
            o;
    
        var collw = false,
            collh = false,
            collwi = false,
            collhi = false,
            collhsup = false,
            support = [],
            supportf = false,
            fw = false,
            upb = -1;
    
        var bub = -1,
            bubw = 0;
    
        var pw2 = ap.v.player.pw2,
            ph2 = ap.v.player.ph2,
            supportd = ap.v.supportd;
    
        for (var i = 0; i < ap.v.coll.length; i++) {
    
            o = ap.v.coll[i];
            collwi = false;
            collhi = false;
            collhsup = false;
            fw = false;
    
            if ((x + pw2 >= o[0]) & (x - pw2 <= o[2])) {
                if ((y + ph2 > o[1]) & (y - ph2 < o[3])) {
                    collwi = true;
                };
            };
    
            //направление для отталкивания по горизонтали
            if ((xOld + pw2 >= o[0]) & (xOld - pw2 <= o[2])) {
                if ((yOld + ph2 > o[1]) & (yOld - ph2 < o[3])) {
                    bub = i;
                    if (Math.abs(xOld - o[0]) < Math.abs(xOld - o[2])) {
                        bubw = -1;
                    } else {
                        bubw = 1;
                    };
                };
            };
    
            if ((x >= o[0]) & (x <= o[2])) {
                fw = true; //внутри блока i по горизонтали
            };
    
            if ((y + ph2 >= o[1]) & (y - ph2 <= o[3])) {
                if ((x > o[0]) & (x < o[2])) {
                    collhi = true;
                    //над блоком
                    if (y + ph2 > o[3]) {
                        collhsup = true;
                        supportf = true;
                        support = o;
                        upb = 1;
                    };
                    //под блоком
                    if (y - ph2 < o[1]) {
                        upb = -1;
                    };
                };
            };
    
            if ((y - ph2 >= o[3] + supportd - 0.11) & (y - ph2 <= o[3] + supportd + 0.001)) {
                if (fw == true) {
                    collhi = true;
                    collh = true;
                    res = true;
                    collhsup = true;
                    supportf = true;
                    support = o;
                };
            };
    
            if (collwi & collhi) {
                res = true;
            };
    
            if (collwi) {
                collw = true;
            };
    
            if (collhi) {
                collh = true;
            };
    
        };
    
        return {
            f: res,
            w: collw,
            h: collh,
            support: support,
            supportf: supportf,
            upb: upb,
            bub: bub,
            bubw: bubw
        };
    },


    Здесь x, y, xOld, yOld — новые и текущие координаты игрока. Новые рассчитываются при нажатии кнопки, исходя из заданной скорости движения, то есть, это возможные координаты. Они проверяются на предмет того, не попадают ли они внутрь какого-либо блока из списка коллизий. Если попадают, то откатываются к старым, и игрок не проходит сквозь препятствие. А если не попадают, то становятся текущими. pw2 и ph2 — это половинные ширина и высота воображаемого коллайдера игрока (player width / 2, player height / 2). На выход выдается, есть ли столкновение по горизонтали и вертикали (collw, collh), находится ли под игроком опорный блок (supportf) — из этого становится понятно, запускать ли далее анимацию падения или игрок просто перешел на соседний блок, и так далее. Только не спрашивайте, зачем я там прибавлял 0.001 и отнимал 0.11. Это жуткий костыль, который предотвращает проваливание сквозь блоки и эффект дрожания при столкновении с горизонтальным препятствием… Эта функция работает, но ее надо переписать по-нормальному. Оптимизация этой функции пока тоже отсутствует.

    Я думаю, с коллизиями стоит пока на этом закончить.

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

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


    Геймпад


    Вообще, поддержка геймпада на javascript в браузере — это не тривиальная задача. Там нет событий нажатия и отпускания кнопок. Есть только события подключения и отключения устройства и состояние, которое можно получить методом периодического опроса, а затем сравнить его с предыдущим.

    Видео, демонстрирующее работу геймпада в браузере на планшете на Windows 8.1 и PC на Windows 10. Планшет, правда, старенький, 2014-года выпуска, поэтому на нем в игре отключено динамическое освещение.


    Для опроса геймпада используется функция, вызываемая раз в 100 миллисекунд. Она задается посредством функции моей библиотеки m3d.lib.globalTimer.addEvent.

    m3d.lib.globalTimer.addEvent({
        name: 'gamepad',
        ti: 100,
        f: function() {
            var st = m3d.gamepad.state();
            if (st == false) {
                if (contr.gpDownFlag == true) {
                    m3d.gamepad.resetH();
                };
            };
        }
    });

    Здесь globalTimer — это написанная мной система обработки событий по таймеру javascript setInterval. Там просто в некий массив добавляется ряд событий, которые требуется вызывать с разными интервалами. Затем устанавливается один таймер setInterval частотой, соответствующей событию с наибольшей частотой из всех. По таймеру опрашивается функция m3d.lib.globalTimer.update(), которая пробегает по списку все события и запускает функции тех, которые пришло время выполнять. При добавлении или удалении событий может меняться и частота интервала (например, если удалить самое быстрое событие).

    В игре также задаются обработчики для каждой клавиши геймпада: 'a' — это для оси (axe), 'b' — для кнопки (button), причем, 11 — это левое отклонение по горизонтальной оси крестовины (как бы ее кнопка 1), 12 — правое отклонение по горизонтальной оси крестовины (как бы ее кнопка 2), 21 и 22 — для вертикальной оси. Например:

    ['a', 11],
    ['b', 3]

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

        m3d.gamepad.setHandler(
    
            [
                ['a', 11],
                ['b', 3]
            ],
    
            function(v) {
                if (contr.btState.lt == false) {
                    contr.keyDownFlag = true;
                    contr.btState.lt = true;
                    contr.gpDownFlag = true;
                    apcontrolsRenderStart();
                };
            },
    
            function(v) {
                contr.btState.lt = false;
                m3d.contr.controlsCheckBt();
                apcontrolsRenderStart();
            }
    
        );

    Здесь apcontrolsRenderStart() — это функция, которая запускает рендер, если он еще не запущен. Вообще, поддержка геймпада плотно завязана на моей библиотеке m3d, поэтому, если я перейду к описанию всех ее возможностей, то это растянется еще очень надолго…

    Приведу только ее часть — gamepad, в которой я простейшим образом реализовал инициализацию геймпада, установку обработчиков и опрос состояния.

    Код
    gamepad: {
    
        connected: false,
        gamepad: {},
        gamepadKey: '',
        axesCount: 0,
        buttonsCount: 0,
    
        f: [], //функции нажатия
        fup: [], //функции отпускания
        fval: [], //значения с осей и кнопок геймпада
        fupCall: [], //флаги отпускания конпок
        buttons: [], //link to f [0.. ]
        axes: [], //link to f [0.. ]
    
        initCb: function() {},
        resetH: function() {},
    
    
        init: function(gp) {
            var f = false;
            for (var key in gp) {
                if (f == false) {
                    if (gp[key] != null) {
                        if (typeof gp[key].id !== "undefined") {
                            f = true;
                            this.connected = true;
                            this.gamepad = gp[key];
                            this.gamepadKey = key;
                        };
                    };
                };
            };
            if (typeof this.gamepad.axes !== "undefined") {
                this.axesCount = this.gamepad.axes.length;
            };
            if (typeof this.gamepad.buttons !== "undefined") {
                this.buttonsCount = this.gamepad.buttons.length;
            };
    
            this.f = [];
            this.fup = [];
            this.fval = [];
            this.fupCall = [];
    
            this.axes = [];
            for (var i = 0; i < this.axesCount * 2; i++) {
                this.axes.push(-1);
            };
            this.buttons = [];
            for (var i = 0; i < this.buttonsCount; i++) {
                this.buttons.push(-1);
            };
    
            this.initCb();
        },
    
    
        setHandlerReset: function(f) {
            this.resetH = f;
        },
    
    
        setHandler: function(ar, f, fup) {
            //ar['b',3] ['a',11]
            var fi, bt, ax, finext, finexta;
            finexta = false;
            for (var i = 0; i < ar.length; i++) {
                if (ar[i][0] == 'a') {
                    ax = Math.floor(ar[i][1] / 10);
                    bt = ar[i][1] - (ax * 10);
                    bt = ax * 2 + bt - 3;
                    fi = this.axes[bt];
                    if (fi == -1) {
                        //обработчик не задан
                        fi = this.f.length;
                        if (finexta == false) {
                            finexta = true;
                            this.f.push(f);
                            this.fup.push(fup);
                            this.fval.push(0);
                            this.fupCall.push(true);
                            this.axes[bt] = fi;
                        } else {
                            fi--;
                            this.f[fi] = f;
                            this.fup[fi] = fup;
                            this.axes[bt] = fi;
                        };
                    } else {
                        this.f[fi] = f;
                        this.fup[fi] = fup;
                    };
                } else if (ar[i][0] == 'b') {
                    bt = ar[i][1] - 1;
                    fi = this.buttons[bt];
                    if (fi == -1) {
                        //обработчик не задан
                        fi = this.f.length;
                        if (finexta == false) {
                            finexta = true;
                            this.f.push(f);
                            this.fup.push(fup);
                            this.fval.push(0);
                            this.fupCall.push(true);
                            this.buttons[bt] = fi;
                        } else {
                            fi--;
                            this.f[fi] = f;
                            this.fup[fi] = fup;
                            this.buttons[bt] = fi;
                        };
                    } else {
                        this.f[fi] = f;
                        this.fup[fi] = fup;
                    };
                };
            };
        },
    
    
        state: function() {
            var pressed = false;
            var fi, fval, axesval;
    
            for (var i = 0; i < this.fval.length; i++) {
                this.fval[i] = 0;
            };
    
    		//текущее состояние геймпада
            var gp = navigator.getGamepads()[this.gamepadKey];
    		
            for (var i = 0; i < this.axesCount; i++) {
                axesval = Math.round(gp.axes[i]);
                if (axesval < 0) {
                    pressed = true;
                    fi = this.axes[i * 2];
                    if (fi != -1) {
                        this.fval[fi] = gp.axes[i];
                        this.fupCall[fi] = true;
                    };
                } else if (axesval > 0) {
                    pressed = true;
                    fi = this.axes[i * 2 + 1];
                    if (fi != -1) {
                        this.fval[fi] = gp.axes[i];
                        this.fupCall[fi] = true;
                    };
                };
            };
    		
            for (var i = 0; i < this.buttonsCount; i++) {
                if (gp.buttons[i].pressed == true) {
                    pressed = true;
                    fi = this.buttons[i];
                    if (fi != -1) {
                        this.fval[fi] = 1;
                        this.fupCall[fi] = true;
                    };
                };
            };
    
            for (var i = 0; i < this.fval.length; i++) {
                fval = this.fval[i];
                if (fval != 0) {
                    this.f[i](this.fval[i]);
                } else {
                    if (this.fupCall[i] == true) {
                        this.fupCall[i] = false;
                        this.fup[i](this.fval[i]);
                    };
                };
            };
    
            return pressed;
        }
    
    }, //gamepad


    Вообще, поддержка геймпада в игре пока неполная: реализована только поддержка простейшего геймпада, но не такого, который, например, используется в XBox, потому что у меня его нет. Если раздобуду, то запрограммирую и работу с ним. Там можно будет регулировать скорость движения персонажа, то есть, можно будет двигаться с любой скоростью в диапазоне от шага до бега. Это достигается приемом дробных параметров от осей. Мой же геймпад возвращает только целые числа -1 и 1. Более того, мой геймпад имеет отвратительную крестовину, и вместе с нажатием влево или вправо происходит одновременное нажатие вниз либо вверх. Поэтому я не задействовал верх и низ на крестовине и продублировал ее кнопками справа на геймпаде… К релизу игры планирую создать несколько профилей геймпадов. Кроме того, в случае подключения нескольких геймпадов, пока будет использоваться только последний.

    Адаптивный экран


    Игра рассчитана на соотношение сторон 16:9. Но я добавил автоматическую корректировку горизонтали ± 10% для того, чтобы в развернутом окне браузера не было вот таких черных полос по бокам:


    А было бы вот так:


    В полноэкранном же режиме будет реальное 16:9. Можно было бы адаптировать изображение вообще к любым пропорциям окна браузера, но я не стал этого делать, так как низкое широкое окно привело бы к слишком большому углу обзора, а это не хорошо с точки зрения геймплея: будут сразу видны отдаленные тупики, предметы, враги и все прочее, что игроку видеть пока не надо. Поэтому я ограничился подстройкой в пределах ± 10% от 16:9. Однако, для узких мониторов (4:3) я все же реализовал возможность по нажатию клавиши Y перейти из 16:9 в режим адаптации от 4:3 до 16:9. Но не шире — чтобы, опять же, не ломать геймплей. То есть, можно играть в классическом соотношении 16:9, а можно увеличить изображение до высоты окна, обрезав его по горизонтали. Хотя, это тоже не очень хорошо, например, в аркадных ситуациях, когда на игрока что-то летит сбоку. Остается мало времени на реакцию. Но всегда можно быстро вернуться в классический режим.


    Адаптация экрана, а также все используемые в игре горячие клавиши демонстрируются на следующем видео:


    Собственно, соотношение сторон задается в настройках игры.

    aspect1:{w:1280, h:720, p:10}, //16x9 +- 10%
    aspect2:{w:960, h:720, p:34}, //4x3 +- 34%

    А в игре при нажатии Y переключается:

    contr.btCodesDn[89] = function() { //'y'
        if (m3dcache.setup.aspect.swch == 1) {
            m3dcache.setup.aspect = m3dcache.setup.aspect2;
            m3dcache.setup.aspect.swch = 2;
        } else {
            m3dcache.setup.aspect = m3dcache.setup.aspect1;
            m3dcache.setup.aspect.swch = 1;
        };
        m3d.core.onWindowResize(0);
        m3d.contr.renderAll();
    };

    В моей библиотеке есть событие, которое вешается на ресайз окна. Вот его фрагмент:

    Код
    m3dcache.v.vw = window.innerWidth;
    m3dcache.v.vh = window.innerHeight;
    m3dcache.v.vclipw = 0;
    m3dcache.v.vcliph = 0;
    
    if (typeof m3dcache.setup.aspect !== "undefined") {
        if ((m3dcache.setup.aspect.w == 0) || (m3dcache.setup.aspect.h == 0)) {} else {
            var o = m3d.lib.inBlock(0, 0, m3dcache.setup.aspect.w, m3dcache.setup.aspect.h, 0, 0, m3dcache.v.vw, m3dcache.v.vh, 'center', 'center', 'resize');
            if (typeof m3dcache.setup.aspect.p !== "undefined") {
                if (o.clipx > 0) {
                    o.w = o.w * (m3dcache.setup.aspect.p / 100 + 1);
                    if (o.w > m3dcache.v.vw) {
                        o.w = m3dcache.v.vw;
                    };
                    o = m3d.lib.inBlock(0, 0, o.w, o.h, 0, 0, m3dcache.v.vw, m3dcache.v.vh, 'center', 'center', 'resize');
                };
            };
    
            m3dcache.v.vclipw = o.clipx;
            m3dcache.v.vcliph = o.clipy;
    
            var margx = o.clipx + 'px',
                margy = o.clipy + 'px';
            document.getElementById('m3dcontainer').style.marginLeft = margx;
            document.getElementById('m3dcontainer').style.marginTop = margy;
    
            if (document.getElementById('renderer') !== null) {
                document.getElementById('renderer').style.marginLeft = margx;
                document.getElementById('renderer').style.marginTop = margy;
            };
    
            m3dcache.v.vw = o.w;
            m3dcache.v.vh = o.h;
        };
    };


    m3d.lib.inBlock — это тоже функция моей библиотеки, которая вписывает прямоугольник в другой прямоугольник с такими параметрами как центрирование, масштабирование или обрезка, и выдает новые размеры вписанного прямоугольника, а также размеры полей, которые образуются в этом процессе. На основе этой информации позиционируются div контейнер окна. 'renderer' — это блочный элемент контекста 3D сцены. Далее там масштабируются канвасы в соответствии с полученными параметрами.

    UI выводится в контейнере на отдельном элементе canvas. Вообще, дерево документа представляет собой три прозрачных DIV блока с абсолютным позиционированием (можно больше или меньше, в зависимости от потребностей игры): на нижнем находится канвас 3D сцены, выше — канвас для IU и самый верхний используется для анимации элементов интерфейса и прочих визуальных эффектов. То есть, UI отрисовывается не в 3D, а на своем кнавасе, или слое. Задача совмещения слоев в единую картину отдана на откуп браузеру. Для работы с UI у меня есть специальный объект в библиотеке. Кратко — суть в следующем. Загружаются спрайт-листы с элементами UI в формате png с прозрачностью. Оттуда берутся нужные элементы — фоны, кнопки. И рисуются на среднем канвасе при помощи функции js drawImage(img, ix,iy,iw,ih, x,y,w,h). То есть, нужные фрагменты с картинки отображаются в нужных позициях на экране. Кнопки выводятся поверх привязанных к ним фонов — все их позиции и размеры задаются в конфигурации UI. При ресайзе окна пересчитываются позиции элементов на целевом канвасе (на котором они отображаются), в зависимости от того, центрируется ли тот или иной элемент по горизонтали и вертикали или привязывается к какому-либо углу или грани экрана. Таким образом создается адаптивный UI, не зависящий от соотношений сторон экрана. Только следует задавать минимально возможное разрешение по горизонтали и вертикали и не опускаться ниже него, чтобы элементы не налезали друг на друга. О UI я расскажу в другой раз, потому что статья и так получилась объемной, ну и над UI я еще работаю, так как там не хватает еще многих нужных мне функций. Например, на мониторах с высоким разрешением интерфейс будет выглядеть мелко. Можно домножать размеры элементов на некий коэффициент, зависящий от разрешения экрана. С другой стороны, может быть, огромные кнопки на экране и не нужны? Если разрешение экрана огромное, значит и сам экран достаточно большой.


    А можно дать программисту выбор — масштабировать ли IU динамически вместе с размерами окна или распределять элементы по углам. В случае с динамическим размером тоже есть свои вопросы — это, например, «мыло» интерфейса, когда он выводится в слишком крупном масштабе. Если же делать спрайты элементов интерфейса в заведомо огромном разрешении, то они будут занимать много места и еще, наверно, это будет не полезно для маленьких устройств — большие спрайты им все равно не нужны, а память потреблять будут.

    Думаю, на сегодня достаточно. Еще есть над чем подумать, как реализовать то или иное. А пока я ненадолго отвлекаюсь от программирования и занимаюсь продвижением. Планирую поучаствовать в паре инди-шоукейсов и активно занимаюсь раскруткой игры в соц.сетях, так как в ноябре планирую выйти на краудфандинговую площадку: мне понадобятся специалисты в области 3D графики и скелетной анимации для того, чтобы доделать игру.

    В следующих статьях я расскажу о сенсорном управлении в браузерах для мобильных устройств — не все же подключают к планшету геймпад или клавиатуру, об оптимизации 3D графики для маломощных устройств и о многом другом.
    Поддержать автора
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

      0
      Здесь globalTimer — это написанная мной система обработки событий по таймеру javascript setInterval.

      какая практическая проблема вызвала создание этой системы?

        +2
        Да. Можно просто запустить много setInterval-ов с разными периодами. Однако, я заметил, что это плохо сказывается на рендере. Движение становится рваным: видимо, браузер пихает выполнение этих интервалов в любое свободное время. И выполняются иногда они слишком долго и сбивают плавность фреймрейта. Даже requestAnimationFrame не помогает. А с одним интервалом в Хроме — все гладко. Однако, в Firefox все равно наблюдаются рывки. И пропадают только, если отключить setInterval вообще. Думаю, я вообще откажусь от setInterval и все его события перенесу в функцию рендера. И буду там уже управлять моментами вызовов вручную.
          0
          То есть эмуляция работы setInterval на requestAnimationFrame (ну типа как-то вот так) тоже дёргается?
            0
            Попробовал так. Теперь стало и в Хроме дергаться. Я подозреваю, что из-за requestAnimationFrame. Он у меня используется еще и в 3D рендере. Возможно, не надо вызывать его дважды. Все-таки, думаю, стоит перенести все таймеры в функцию рендера под единый requestAnimationFrame.
        0
        Касательно физики, почему не готовый движек?

        Сравнение 2D js движков
        github.com/cmann1/Javascript-Physics-Library-Tests
          +2
          У меня был опыт работы с Cannon.js, совместно с Three.js. Да, готовые движки — это тоже неплохо. Но в них есть свои ограничения, с которыми приходится мириться. А самому можно сделать, как тебе надо. К тому же, я стремлюсь уйти даже от Three.js, написав свою прослойку на GLSL. Поэтому путь в сторону очередного стороннего движка для меня видится неправильным. Мне, скорее, в противоположную сторону.
          0

          А зачем запихивать игру в exeшник и если уж так, то, раз уж игра на js, почему не воспользоваться готовыми кроссплатформенными упаковщиками?

            0
            В идеале, я бы вообще хотел выпустить браузерную версию, которая бы работала просто в браузере на любой ОС. А чем и во что еще можно упаковать javascript, я что-то не совсем понимаю?
              0

              Electron, какой-нибудь, например. Webkit, по идее, тоже реально сбилдить под Mac, и например, в виде deb пакета. Просто, очень много ценителей "того" принца персии сидят сейчас не под виндой.

                0
                Да, собственно, в Webkit есть возможность билдить под MacOS и Linux. Просто я сейчас так занят непосредственно созданием игры, что тестирую ее исключительно под Windows. Но обязательно попробую собрать и под другие ОС.
            +3
            В оригинальной игре, кстати, анимация персонажа была лучше ;)
              0
              В оригинальной игре разработчик снял на пленку самого себя во всех прыжках и прочих движениях и оцифровал все по кадрам. По этому там анимация такая хорошая.
                +5
                Он снимал своего младшего брата.
                +1
                С этим я согласен. Именно поэтому и ищу спеца по 3D анимации. У меня к этому нет таланта. Но если не найду, то придется все делать самостоятельно. Но то, что анимацию надо переделывать — это 100%.
                  0
                  Еще один важный момент по поводу анимации — сейчас персонаж все время находится по центру. Малейшее движение — и фон сразу смещается. Это приведет к быстрому раздражению во время игры. Необходимо дать возможность персонажу перемещаться по текущему экрану в некоторых пределах без скроллинга. Дошел до края — начинается скроллинг. Пошел назад — и пока до противоположного края не дошел — экран стоит на месте.
                    +1
                    А как же гонки и шутеры? Там такое движение не раздражает, а, наоборот, придает динамику. Или вы считаете, что целевая аудитория моей игры — абсолютная противоположность той? А как же пиксельные 2D платформеры, раннеры? Там тоже, в большинстве случаев, постоянно движется фон.
                    Ну и, мне кажется, с вашим вариантом у меня будет ломаться геймлей. Некоторые препятствия в игре будут представлять собой летящие предметы (камни и т.п.) с катапульт, к которым игрок будет приближаться. Если давать ему доходить до края, то останется слишком мало времени на реакцию. Хотя, конечно, можно эти орудия расставить в таких точках, при которых экран, как раз, подвинется и игрок окажется в центре…
                    В общем, я попробую сделать так, но не уверен, что эта возможность войдет в финальный билд.
                      0
                      Я не про отсутствие скроллинга совсем, а про небольшой люфт при мелких движениях. В пределах от 2-5 см, и до краев экрана, как лучше будет до геймлпея. Но минимальный все равно нужен. Иначе сильно рябит в глазах при мелких движениях. Создается впечатление, что персонаж на месте стоит все время, а движется только фон.
                        0
                        Вот идея не двигать экран при мелких движениях мне нравится. А когда игрок перемещается чуть дальше, тогда — чтобы экран как бы догонял его и с продолжением движения персонажа уже бы двигался, как сейчас. Надо будет попробовать. Можно даже сделать это как опцию.
                        Проблема еще может быть в мониторе. У меня он почти не оставляет шлейфов, а вот играл у друга — у него при скроллинге все размазывается.
                      +1
                      Вообще, в платфомерах скроллинг был основной фишкой. На этом Марио и взлетел, в целом. Хотя в Принце персии, конечно, скроллинга не было…
                        +1
                        Кстати, там, видимо, отсутствие скроллинга отчасти было обусловлено техническими возможностями старого железа: насколько я знаю, игра изначально была выпущена для Apple II.
                          0
                          Да, скорее всего, так и было.
                  +3
                  Выглядит очень круто. Отличная работа!
                  Не подскажите, как много ресурсов ушло на разработку?
                    0
                    Спасибо. А в чем бы вы предложили измерить эти ресурсы? В дошираках? Я занимаюсь этой игрой где-то с начала года. Но параллельно еще и работаю над своей библиотекой. Кроме того, немало времени уходило на то, чтобы найти решения всевозможных задач. Но, думаю, следующую игру буду делать уже намного быстрее.
                      0
                      В человеко-часах и деньгах, конечно)
                      Вы работаете в одиночку? Может отдавали что-то на аутсорс, покупали звуки или текстуры? Сколько еще придется потратить времени и денег по прогнозам?
                      Как, кстати, собираетесь монетизировать браузерную игру?
                      В общем, интересна финансовая сторона вопроса.
                        +2
                        Все делал один. Ничего не покупал. Текстуры сделал сам, звуки — какие-то бесплатные, какие-то сгенерированы самостоятельно. Как монетизировать подобную браузерную игру — пока не знаю. Но планирую выйти на краудфандинг со сборкой игры под Windows, она сейчас есть в Стиме. Далее, если что-то соберу, то уже на эти средства найму 3D моделера, чтобы улучшить графику и сделать нормальную анимацию персонажей. Релиз пока поставил на 3 августа 2020. Ну а вложенных средства там пока только $100 за размещение на Стиме. Сколько ушло моих человеко-часов — не считал.
                          +2
                          Круто! Удачи с проектом!
                    0
                    Может где упустил, но где можно потрогать в живую?
                    0
                    del

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

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