Pull to refresh

Особенности разработки WebGL игры Digital Trip

Reading time 17 min
Views 9.8K
image

Привет, хабр! В этой статье я хочу поделиться собственным опытом разработки WebGL игры Digital Trip. Помимо WebGL, в игре использованы такие технологии, как WebAudio API, WebSockets, getUserMedia, Vibration API, DeviceOrientation, а также библиотеки three.js, hedtrackr.js, socket.io и пр. В статье будут описаны наиболее интересные детали реализации. Я расскажу о движке игры, управлении при помощи мобильного, управлении веб-камерой, скажу пару слов о back-end’e на node.js, работающем в связке с dogecoin демоном.
В конце статьи приведены ссылки на использованные библиотеки, исходный код на GitHub, описание игры и саму игру.
Всех, кому интересно, прошу под кат.


Геймплей очень простой: летим по заданной траектории, собираем монетки и бонусы, уворачиваемся от камней. Положения игрока ограничены 3 вариантами. Бонусы бывают трех типов: щит (HTML5), замедление (котик) или восстановление жизней (губы). В конце игры можно вывести полученные монетки на свой кошелек dogecoin.

image

Цель разработки игры — рассказать о возможностях браузеров, прокачать свои навыки, поделиться опытом и получить огромное удовольствие от процесса.
Теперь подробнее об особенностях реализации.

Движок игры и некоторые детали


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

Прелоадер

К станице подключены три скрипта:
<script src="js/vendor/jquery.min.js"></script>
<script src="js/vendor/yepnope.1.5.4-min.js"></script>
<script src="js/myYepnope.min.js"></script>

Для загрузки остальных скриптов используется загрузчик ресурсов yepnope.
При выполнении myYepnope.js происходит проверка поддержки WebGL браузером:
var isWebGLSupported,
    canvas = document.getElementById('checkwebgl');
if (!window.WebGLRenderingContext) {
    // Browser has no idea what WebGL is
    isWebGLSupported = false;
} else if (canvas.getContext("webgl") ||
    canvas.getContext("webGlCanvas") ||
    canvas.getContext("moz-webgl") ||
    canvas.getContext("webkit-3d") ||
    canvas.getContext("experimental-webgl")) {
    // Can get context
    isWebGLSupported = true;
} else {
    // Can't get context
    isWebGLSupported = false;
}

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


Эффект размытия достигается за счет использования css-свойства -webkit-filter: blur(). Свойство прекрасно анимируется. Для Firefox используется svg filter, радиус которого динамически изменяется и применяется в виде css-свойства filter: 'url()', при этом data url генерируется скриптом и обновляется каждые 20% загрузки.
Код
if (isWebGLSupported) {
    var $body = $('body'),
        $cc = $('.choose_control'),
        maxBlur = 100,
        steps = 4,
     isWebkitBlurSupported;

    if ($body[0].style.webkitFilter === undefined) {
        isWebkitBlurSupported = false;
        $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + maxBlur + "\"/></filter></svg>#blur-overlay')"});
    } else {
        isWebkitBlurSupported = true;
        $body[0].style.webkitFilter = 'blur(' + maxBlur + 'px)';
    }

    $('#loader').css({display: 'table'});
    $cc.css({display: 'table'});

    yepnope.loadCounter = 0;
    yepnope.percent = 0;
    yepnope.showLoading = function (n) {
        yepnope.percent += maxBlur/steps;
        yepnope.loadCounter += 1;

      $(".loader").animate({minWidth: Math.round(yepnope.percent)+"px"}, {
            duration: 1000,
            progress: function () {
                var current = parseInt($(".loader").css("minWidth"), 10) * 100/maxBlur;
                $("title").html(Math.floor(current) + "% " + "digital trip");
                if (isWebkitBlurSupported) {
                    $body[0].style.webkitFilter = 'blur('+ (maxBlur - current)+ 'px)';
                }
                if (!isWebkitBlurSupported && current % 20 === 0) {
                    $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + (maxBlur - maxBlur/(steps+1)*n) + "\"/></filter></svg>#blur-overlay')"});
                }
                if (current === 100) {
                    $("title").html("digital trip");
                    if (!isWebkitBlurSupported && current % 20 === 0) $cc.css({filter: "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"blur-overlay\"><feGaussianBlur stdDeviation=\"" + 0 + "\"/></filter></svg>#blur-overlay')"});
                }
            },
            complete: function () {
              if (n === steps) {
                    DT.runApp();
                }
            }
        });
    };
    yepnope([{
        load: [
            "js/vendor/three.min.js",
            "js/DT.min.js",
          "../socket.io/socket.io.js"
        ],
        callback: {}
    }]);
} else {
    $('#nogame').css({display: 'table'});
}


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

События

Взаимодействия между объектами внутри игры основаны на стандартных и кастомных событиях
Список событий
'blur' // потеря фокуса
'focus' // появление фокуса

'socketInitialized' // инициализация socket.io
'externalObjectLoaded' // завершение загрузки внешней модели

'startGame' // запуск игры
'pauseGame' // пауза
'resumeGame' // возобновление игры
'gameOver' // конец игры
'resetGame' // сброс параметров игры

'updatePath' // обновление положения в игровом пространсте (трубе)
'update' // обновление объектов игры

'changeSpeed' // внешнее изменение скорости
'showHelth' // отображение изменения здоровья
'showInvulner' // отображение изменения неуязвимости (щит)
'showScore' // отображение изменения очков
'showFun' // отображение изменения режима замедления (котика)
'changeHelth' // изменение здоровья
'bump' // столкновение с объектом
'blink' // мигание сферы
'hit' // столкновение с камнем
'changeScore' // изменение очком
'catchBonus' // поимка бонуса
'makeInvulner' // изменение режима неуязвимости (щита)
'makeFun' // включение режима замедления (котика)
'showBonuses' // оботражение пойманных бонусов
'stopFun' // выключение режима котика

'paymentCheck' // состояние проверки клиента для оплаты
'paymentMessage' // получение сообщения о платеже
'transactionMessage' // получение сообщения о транзакции
'checkup' // запуск проверки

События возникают в элементе document, вызывая соответствующие обработчики, например:
DT.$document.trigger('gameOver', {cause: 'death'});
DT.$document.on('gameOver', function (e, data) {
    if (data.cause === 'death') {
        DT.audio.sounds.gameover.play();
    }
});

События 'blur' и 'focus' вызываются в window и служат для отключения звука и включения паузы при потере фокуса окна с игрой.
DT.$window.on('blur', function() {
    if (DT.game.wasStarted && !DT.game.wasPaused && !DT.game.wasOver) {
        DT.$document.trigger('pauseGame', {});
    }
    DT.setVolume(0);
});

Инициализация игрового мира

Здесь все стандартно для проектов на three.js: создается сцена, камера, игровое пространство, источники света, фон.

Сцена
DT.scene = new THREE.Scene();

Камера
DT.splineCamera = new THREE.PerspectiveCamera( 84, window.innerWidth / window.innerHeight, 0.01, 1000 );

Игровое пространство — труба, вдоль кривой TorusKnot из набора THREE.Curves
var extrudePath = new THREE.Curves.TorusKnot();
DT.tube = new THREE.TubeGeometry(extrudePath, 100, 3, 8, true, true);

Источники света
DT.lights = {
    light: new THREE.PointLight(0xffffff, 0.75, 100),
    directionalLight: new THREE.DirectionalLight(0xffffff, 0.5)
};

Фон в виде сферы вокруг игрового пространства с натянутой по внутренней поверхности картинкой с границами одного цвета для бесшовного соединения.
Фон
var geomBG = new THREE.SphereGeometry(500, 32, 32),
    matBG = new THREE.MeshBasicMaterial({
            map: THREE.ImageUtils.loadTexture('img/background5.jpg'),
        }),
    worldBG = new THREE.Mesh(geomBG, matBG);
worldBG.material.side = THREE.BackSide;


Классы

В игре есть несколько основных классов: Игра (DT.Game), Игрок (DT.Player) и Игровой объект (DT.GameObject). Они имеют свои методы (обновления, сброса и пр.), вызываемые соответствующими обработчиками в ответ на срабатывание того или иного события. Объект игры содержит различные параметры (скорость, ускорение), константы (минимальное расстояние между камнями и информацию о своем состоянии (wasStarted, wasPaused). Объект игрока содержит информацию о текущем состоянии игрока (счет, жизни, состояние неуязвимости), а также состояние модели игрока (сфера, кольца (контуры, являющиеся индикаторами здоровья) вокруг сферы). Все остальные объекты являются подклассами Игрового объекта (частицы, щит на игроке, бонусы).

Внутренние и внешние модели

В игре есть модели двух типов: внутренние (простые) модели (сфера, индикатор здоровья (кольца/контуры), камни и монеты), которые создаются средствами three.js и внешние (сложные) модели (бонусы и HTML5 щит вокруг сферы) подгружаются в .obj формате соответствующим загрузчиком.
Сфера является частью объекта игрока и представляет собой 2 объекта: физическая сфера для расчетов столкновений с другими объектам (не добавлена на сцену)
Сфера
this.sphere = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshPhongMaterial({}));

и система частиц на основе движка для системы частиц Fireworks.



Система частиц
this.emitter = Fireworks.createEmitter({nParticles : 100})
    .effectsStackBuilder()
        .spawnerSteadyRate(30)
        .position(Fireworks.createShapePoint(0, 0, 0))
        .velocity(Fireworks.createShapePoint(0, 0, 0))
        .lifeTime(0.2, 0.7)
        .renderToThreejsParticleSystem({ ... })
        .back()
    .start();

Модели бонусов подгружаются в виде 2 объектов каждая с одинаковым количеством вершин (также для трансформации).

Список моделей
DT.listOfModels = [{
        name: 'bonusH1',
        scale: 0.1,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0xff0000,
    }, {
        name: 'bonusI',
        scale: 0.02,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0x606060,
        '5': 0xffffff,
        'html': 0xffffff,
        'orange': 0xD0671F,
        'shield': 0xC35020,
    }, {
        name: 'bonusE1',
        scale: 0.75,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0x606060,
    }, {
        name: 'bonusH2',
        scale: 0.1,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0xff0000,
    }, {
        name: 'shield',
        scale: 0.16,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0x606060,
    }, {
        name: 'bonusE2',
        scale: 0.75,
        rotaion: new THREE.Vector3(0, 0, 0),
        color: 0x606060,
    }
];


Загрузчик
var manager = new THREE.LoadingManager(),
    loader = new THREE.OBJLoader(manager);

manager.onProgress = function (item, loaded, total) {
    console.info('loaded item', loaded, 'of', total, '('+item+')');
};

DT.listOfModels.forEach(function (el, i, a) {
    loader.load('objects/' + el.name + '.obj', function ( object ) {
        object.traverse( function ( child ) {
            var color = el[child.name] || el.color;
            child.material = new THREE.MeshPhongMaterial({
                color: color,
                shading: THREE.SmoothShading,
                emissive: new THREE.Color(color).multiplyScalar(0.5),
                shininess: 100,
            });
        });
        if (i === 1) {
            a[i].object = object
        } else {
            a[i].object = object.children[0];
        }
        DT.$document.trigger('externalObjectLoaded', {index: i});
    });
});


После загрузки внешние модели становятся доступными по ссылке DT.listOfModels[index].object и используются в конструкторе бонуса.

Превращения (трансформации) и постпроцессинг

В игре есть несколько трансформаций: для индикаторов здоровья, для бонусов и glitch-эффект (или эффект сломанного телевизора) в конце игры.

Трансформации индикатора здоровья и бонусов основаны на morphTargets.



При создании объекта стандартное состояние сохраняется в геометрии этого объекта. Остальные состояния сохранятся в специальном свойстве геометрии morphTargets. Текущее состояние объекта определяется уровнем morphTargetInfluences объекта.

Индикатор здоровья (кольца/контуры) вокруг сферы является 2 объектами, геометрия каждого из которых состоит их 180 вершин (по 60 с внутренней и внешней стороны).

Кольца/контуры могут представлять собой окружности, пяти-, четырех- и треугольники, при этом количество вершин всегда остается 180.
Важно, чтобы число вершин в каждом состоянии было одинаковым, а их векторы координат менялись «правильно» (в соответствии с желаемой трансформацией), иначе трансформация будет работать некорректно или не будет работать вовсе.

Для этого была написана специальная функция для создания геометрии индикатора здоровья (колец/контуров).

геометрия индикатора здоровья
DT.createGeometry = function (circumradius) {
    var geometry = new THREE.Geometry(),
        x,
        innerradius = circumradius * 0.97,
        n = 60;

    function setMainVert (rad, numb) {
        var vert = [];
        for (var i = 0; i < numb; i++) {
            var vec3 = new THREE.Vector3(
                rad * Math.sin((Math.PI / numb) + (i * ((2 * Math.PI)/ numb))),
                rad * Math.cos((Math.PI / numb) + (i * ((2 * Math.PI)/ numb))),
                0
            );
            vert.push(vec3);
        }
        return vert;
    }

    function fillVert (vert) {
        var nFilled, nUnfilled, result = [];

        nFilled = vert.length;
        nUnfilled = n/nFilled;
        vert.forEach(function (el, i, arr) {
            var nextInd = i === arr.length - 1 ? 0 : i + 1;
            var vec = el.clone().sub(arr[nextInd]);
            for (var j = 0; j < nUnfilled; j++) {
                result.push(vec.clone().multiplyScalar(1/nUnfilled).add(el));
            }
        });
        return result;
    }

    // set morph targets
    [60, 5, 4, 3, 2].forEach(function (el, i, arr) {
        var vert,
            vertOuter,
            vertInner;

        vertOuter = fillVert(setMainVert(circumradius, el).slice(0)).slice(0);
        vertInner = fillVert(setMainVert(innerradius, el).slice(0)).slice(0);

        vert = vertOuter.concat(vertInner);

        geometry.morphTargets.push({name: 'vert'+i, vertices: vert});

        if (i === 0) {
            geometry.vertices = vert.slice(0);
        }
    });

    // Generate the faces of the n-gon.
    for (x = 0; x < n; x++) {
        var next = x === n - 1 ? 0 : x + 1;
        geometry.faces.push(new THREE.Face3(x, next, x + n));
        geometry.faces.push(new THREE.Face3(x + n, next, next + n));
    }
    return geometry;
};


По этой же причине модели бонусов импортируются в виде двух .obj объектов, заранее измененных определенным образом в редакторе (как это необходимо для ожидаемой анимации превращения (трансформации). Мы использовали для этого 3ds Max и blender.



С моделью губ есть один интересный момент. В обычном состоянии губы анимируются (приоткрываются и закрываются). При этом происходит просто изменение силы влияния вершин из двух наборов вершин (открытых и закрытых губ). Согласно документации three.js, значение morphTargetInfluence каждого набора вершин должно находиться в диапазоне [0, 1]. При этом при использовании силы больше 1 происходит эффект некоторого «гипервлияния». Так, например, если применить morphTargetInfluence со значением 5 к набору вершин для модели кота, модель как будто «вывернется наизнанку». У модели губ это выглядит как «открытие рта».
На этом поведении основан эффект поглощения бонуса «губы», что позволило избежать импорта дополнительной внешней модели.
Glitch-эффект (или эффект сломанного телевизора), используемый для анимации конца игры представляет собой пример постпроцессинга с использованием шейдеров.



Создаем эффект
Код
DT.effectComposer = new THREE.EffectComposer( DT.renderer );
DT.effectComposer.addPass( new THREE.RenderPass( DT.scene, DT.splineCamera ) );
DT.effectComposer.on = false;

var badTVParams = {
    mute:true,
    show: true,
    distortion: 3.0,
    distortion2: 1.0,
    speed: 0.3,
    rollSpeed: 0.1
}

var badTVPass = new THREE.ShaderPass( THREE.BadTVShader );
badTVPass.on = false;
badTVPass.renderToScreen = true;
DT.effectComposer.addPass(badTVPass);


И рендерим его каждый кадр
Код
DT.$document.on('update', function (e, data) {
    if (DT.effectComposer.on) {
        badTVPass.uniforms[ "distortion" ].value = badTVParams.distortion;
        badTVPass.uniforms[ "distortion2" ].value = badTVParams.distortion2;
        badTVPass.uniforms[ "speed" ].value = badTVParams.speed;
        badTVPass.uniforms[ "rollSpeed" ].value = badTVParams.rollSpeed;
        DT.effectComposer.render();
            badTVParams.distortion+=0.15;
            badTVParams.distortion2+=0.05;
            badTVParams.speed+=0.015;
            badTVParams.rollSpeed+=0.005;
    };
});


Эффект включается после возникновения события ‘gameOver’
Код
DT.$document.on('gameOver', function (e, data) {
    DT.effectComposer.on = true;
});


И сбрасывается при соответствующем событии
Код
DT.$document.on('resetGame', function (e, data) {
    DT.effectComposer.on = false;
    badTVParams = {
        distortion: 3.0,
        distortion2: 1.0,
        speed: 0.3,
        rollSpeed: 0.1
    }
});


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

Визуализация музыки

Музыка визуализируется пульсацией частиц (пыли), находящихся в игровом пространстве.

Для этого была определена желаемая частота визуализации. Уровень присутствия звука нужной частоты (DT.audio.valueAudio) в текущий момент в буфере для визуализации определяется так
Код
var getFrequencyValue = function(frequency, bufferIndex) {
        if (!DT.isAudioCtxSupp) return;
        var nyquist = DT.audio.context.sampleRate/2,
            index = Math.round(frequency/nyquist * freqDomain[bufferIndex].length);
        return freqDomain[bufferIndex][index];
    };

var visualize = function(index) {
        if (!DT.isAudioCtxSupp) return;
        freqDomain[index] = new Uint8Array(analysers[index].frequencyBinCount);
        analysers[index].getByteFrequencyData(freqDomain[index]);
        DT.audio.valueAudio = getFrequencyValue(DT.audio.frequency[index], index);
    };

Значение DT.audio.valueAudio используется для обновления состояния прозрачности частиц:
Код
DT.$document.on('update', function (e, data) {
    DT.dust.updateMaterial({
        isFun: DT.player.isFun,
        valueAudio: DT.audio.valueAudio,
        color: DT.player.sphere.material.color
    });
});

Сам метод updateMaterial:
Код
DT.Dust.prototype.updateMaterial = function (options) {
    if (!this.material.visible) {
        this.material.visible = true;
    }
    this.material.color = options.isFun ? options.color : new THREE.Color().setRGB(1,0,0);
    this.material.opacity = 0.5 + options.valueAudio/255;
    return this;
};

Подробнее о WebAudio API можно почитать здесь.

Анимация favicon

Favicon в digital trip по умолчанию представляет собой черно-белое изображение кота.
В режиме замедления (режим котика) иконка начинает менять цвет.
Если в Firefox можно поставить
<link rel="icon" type="image/gif" href="fav.gif">

то в Chrome такой способ не пройдет. Для Chrome была использована динамическая подмена png-изображения favicon.
Общая реализация выглядит так:
Код
var favicon = document.getElementsByTagName('link')[1],
    giffav = document.createElement('link'),
    head = document.getElementsByTagName('head')[0],
    isChrome = navigator.userAgent.indexOf('Chrome') !== -1;

giffav.setAttribute('rel', 'icon');
giffav.setAttribute('type', 'image/gif');
giffav.setAttribute('href', 'img/fav.gif');

DT.$document.on('update', function (e, data) {
    if (isChrome && DT.player.isFun && DT.animate.id % 10 === 0) favicon.setAttribute('href', 'img/' + (DT.animate.id % 18 + 1) + '.png');
});
DT.$document.on('showFun', function (e, data) {
    if (!data.isFun) {
     if (isChrome) {
            favicon.setAttribute('href', 'img/0.png');
        } else {
            $(giffav).remove();
            head.appendChild(favicon);
        }
    } else {
        if (!isChrome) {
            $(favicon).remove();
            head.appendChild(giffav);
        }
    }
});

‘update’ – событие обновления состояния объектов, ‘showFun’ – событие о начале режима котика (замедления), DT.player.isFun — сотояние режима котика, DT.animate.id – номер текущего фрейма (кадра). Всего возможных вариантов favicon — 19. К сожалению, в Safari анимации favicon нет.

Мобильный контроллер


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

Управление осуществляется при помощи гироскопа и события ‘deviceOrientation’. В случае отсутствия гироскопа или доступа к нему используется управление нажатием на кнопки управления.

Fallback и обработчик:
Код
// Technique from Juriy Zaytsev
// http://thinkweb2.com/projects/prototype/detecting-event-support-without-browser-sniffing/
var eventSupported = function( eventName ) {
    var el = document.createElement("div");
    eventName = "on" + eventName;
    var isSupported = (eventName in el);
    if ( !isSupported ) {
        el.setAttribute(eventName, "return;");
        isSupported = typeof el[eventName] === "function";
    }
    el = null;
    return isSupported;
};
// device orientation
function orientationTest (event) {
    if (!turned && event.gamma) turned = true;
    window.removeEventListener('deviceorientation', orientationTest, false);
    window.removeEventListener('MozOrientation', orientationTest, false);
}
window.addEventListener('deviceorientation', orientationTest, false);
window.addEventListener('MozOrientation', orientationTest, false);
setTimeout(function () {
    if (!turned) {
        $("#btnLeft").on('touchstart',function () {
            socket.emit("click", {"click":"toTheLeft"});
        });
        $("#btnRight").on('touchstart',function () {
            socket.emit("click", {"click":"toTheRight"});
        });
        $status.html("push buttons to control");
    } else {
        $status.html("tilt your device to control");
    }
    if (!eventSupported('touchstart')) {
        $status.html("sorry your device not supported");
    }
}, 1000);

Проверка поддержки 'deviceOrientation' реализована через setTimeout, а не аналогично eventSupported, так как существуют устройства (например, HTC One V), которые поддерживают 'deviceOrientation' номинально, но само событие не возникает. Фактически мы в течение какого-то интервала времени ждем возникновения события (которое точно должно возникнуть), и если оно не возникает, делаем вывод, что событие не поддерживается. Такая проверка фактически является хаком.

Для некоторых телефонов (например HTC c Windows Phone) стандартный браузер (mobile IE) не поддерживает событие 'touchstart', но поддерживает более высокоуровневое событие ‘click’. Мы отказались от поддержки таких девайсов, так как время отклика при использовании события ‘click’ (300 мс) намного больше, чем у 'touchstart' и обеспечить необходимый уровень отклика для контроля с помощью таких устройств не удается.

Кстати, пользователи некоторых моделей Macbook Pro c HDD могут использовать свой ноутбук в таком режиме, так как в нем есть гироскоп.

Для пользователей устройств с ОС Android 4.0 и выше есть небольшой бонус — обратный отклик контроллера в виде вибрации, если столкнуться с камнем (вибрация 100 мс) или подобрать монетку (вибрация 10 мс). Для этого используется Vibration API (необходим обновленный стандартный браузер, мобильный Chrome или Firefox). Подробнее о Vibration API можно почитать здесь.

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

<audio id="audioloop" src="../sounds/loop.mp3" onended="this.play();" autobuffer></audio>

$('#btnSphere').on('touchstart',function () {
    socket.emit('click', {'click':'pause'});
    $('#audioloop').trigger('play');
});

При этом на устройствах с ОС Android аудиопетля может быть 1-секундной, а на устройствах с iOS требуется более длинный трек. В iOS браузер Safari не проигрывает трек бесконечно, число циклов — около 100, поэтому была выбрана длина трека в 10 секунд.

Управление веб-камерой


Управление веб-камерой основано на методе getUserMedia().
Мы рассмотрели несколько примеров управления при помощи веб-камеры. Один из вариантов — нажатие виртуальных клавиш, как в этом примере.



От него мы отказались, так как он оказался недостаточно точным.

Другой вариант — использовать угол наклона головы и библиотеку headtrackr.js. Он оказался более интересным и помогал размять шею и снять напряжение, однако угол определялся не всегда правильно. В итоге для управления при помощи веб-камеры используется положение головы и ее движение относительно середины экрана (также при помощи headtrackr.js).

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

Back-end


Сервер игры работает на node.js. Используются модули express, socket.io, mongoose, node-dogecoin и hookshot.

Тут все достаточно тривиально: socket.io осуществляет транспорт, express oтвечает за маршруты и статику, а mongoose сохраняет клиентов в базу данных. Hookshot использован для быстрого разворачивания изменений на VPS.

app.use('/webhook', hookshot('refs/heads/master', 'git pull'));

Наиболее интересным в back-end’е является взаимодействие с dogecoin демоном, развернутым на этом же сервере. Это полноценный dogecoin кошелек, взаимодействие с которым осуществляется при помощи модуля node-dogecoin примерно следующим образом:

dogecoin.exec('getbalance', function(err, balance) {
	console.log(err, balance);
});

Кроме того, сервер осуществляет проверку клиента на предмет мошенничества. Здесь проверяется набранное клиентом число монет и сравнивается с максимальным числом монет, которое можно набрать за время данной сессии.
Код
var checkCoins = function (timeStart, timeEnd, coinsCollect) {
	var time = (timeEnd - timeStart)/1000,
    	maxCoins = calcMaxCoins(time);
	// if client recieve more coins than it may
	return coinsCollect <= maxCoins;
};
 
var calcMaxCoins = function (time) {
	var speedStart = 1/60,
        acceleration = 1/2500,
    	maxPath = 0,
    	maxCoins = 0,
    	t = 0.25, // coins position in the tube
    	dt = 0.004, // coins position offset
    	n = 10; // number of coins in a row
 
	maxPath = (speedStart + acceleration * Math.sqrt(time * 60)) * time;
 
	maxCoins = Math.floor(maxPath / (t + dt * (n - 1)) * n)/10;
    console.log('time:' + time, 'maxCoins:' + maxCoins, 'maxPath:' + maxPath);
	return maxCoins;
};

Также реализована проверка числа платежей с одного IP, c одним UID (cookie) и время между двумя ближайшими играми с одного IP.
Код
var checkClient = function (clients, currentClient) {
    console.log("Handle clients from Array[" + clients.length + "]")
    var IPpaymentsCounter = 0,
        UIDpaymentsCounter = 0,
        IPtimeCounter = 60 * 1000,
        checkup = null;

    clients.forEach(function(client, i) {
        if (client.clientIp === currentClient.clientIp && client.paymentRequest) {
            IPpaymentsCounter += client.paymentRequest; 
            if (currentClient.timeEnd && currentClient.cientId !== client.cientId) {
                Math.min(IPtimeCounter, currentClient.timeEnd - client.timeEnd);
            }
        }
        if (client.cookieUID === currentClient.cookieUID && client.paymentRequest) {
            UIDpaymentsCounter += client.paymentRequest;
        }
        // console.log("handle client #" + i);
    });
    console.log('IPtimeCounter', IPtimeCounter);
    if (currentClient.checkup === false ||
        currentClient.maxCoinsCheck === false ||
        IPpaymentsCounter > 1000 ||
        UIDpaymentsCounter > 100 ||
        IPtimeCounter < 20 * 1000) {
        checkup = false;
    } else {
        checkup = true;
    }
    return checkup;
};

Это простая защита, основанная на принципе целесообразности.

Заключение


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

Вся разработка велась на GitHub, код можно посмотреть здесь.

Ссылки: проект на github, описание игры, игра

Используемые инструменты и библиотеки:
Tags:
Hubs:
+15
Comments 6
Comments Comments 6

Articles

Information

Website
hotdot.pro
Registered
Founded
Employees
2–10 employees
Location
Россия