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

в 15:18, , рубрики: canvas, javascript, nw.js, three.js, WebGL, игры, разработка игр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Схема обрабатывается блоками 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 также хранит массив объектов, которые участвуют в системе столкновений. Просто там столкновения определяются методом испускания луча и его пересечения с плоскостями сторон объектов, а у меня — определением, находятся ли координаты одного объекта внутри другого по каждой из двух осей.

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

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

Геймпад

Вообще, поддержка геймпада на 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% для того, чтобы в развернутом окне браузера не было вот таких черных полос по бокам:

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

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

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

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

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

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

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

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 я еще работаю, так как там не хватает еще многих нужных мне функций. Например, на мониторах с высоким разрешением интерфейс будет выглядеть мелко. Можно домножать размеры элементов на некий коэффициент, зависящий от разрешения экрана. С другой стороны, может быть, огромные кнопки на экране и не нужны? Если разрешение экрана огромное, значит и сам экран достаточно большой.

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

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

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

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

Автор: Kempston

Источник


* - обязательные к заполнению поля