Привет, хабр! В этой статье я хочу поделиться собственным опытом разработки 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.
Цель разработки игры — рассказать о возможностях браузеров, прокачать свои навыки, поделиться опытом и получить огромное удовольствие от процесса.
Теперь подробнее об особенностях реализации.
Движок игры и некоторые детали
В качестве пространства имен используется глобальная переменная 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' // запуск проверки
'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, описание игры, игра
Используемые инструменты и библиотеки:
- jQuery
- three.js
- Detector
- CurveExtras
- OBJLoader
- Stats
- threex (набор компонентов)
- windowresize
- rendererstats
- fullscreen
- headtrackr.js
- yepnope.js
- socket.io
- fireworks (система частиц)
- Web Audio BufferLoader