Мы долго с вами возились с 2D играми, примеряясь и нацеливаясь на полноценное 3D. И вот настал час сделать прыжок... погодите вы с Unity. Обойдемся пока и без него!
Для нашей браузерной 3D игры нам потребуется:
- Браузер, поддерживающий WebGL. Напомню - это Chrome, Firefox, IE11 и старше
- Three.js, которую можно скачать на официальном сайте. Это легкая и кроссбраузерная библиотека, написанная на JavaScript. Мы будем ее использовать для нашей 3D графики.
- Библиотека Keyboard.js, созданная Артуром Шрайбером. Она пригодится нам для управления нашими объектами с помошью клавиатуры
Настройка
Для начала создадим HTML-файл и назовем его index.html
. Состоять он будет всего из нескольких тегов, поэтому я не буду выносить CSS стили в отдельный файл, а пропишу их сразу в теге head.
<!doctype html>
<html>
<head>
<title>Пинг-понг</title>
<style>
body {
background: #000000;
}
#gameCanvas {
background: #000000;
width: 640px;
height: 360px;
margin: auto;
align: center;
}
#scoreboard {
text-align: center;
font-family: Helvetica, sans-serif;
color: white;
}
#scores {
font-size:600%;
padding:0;
margin:0;
color: #ffffff;
}
#title {
background: #ffffff;
color: #000000;
}
</style>
</head>
<body onload='setup();'>
<div id='gameCanvas'></div>
<script src='./scripts/three.min.js'></script>
<script src='./scripts/keyboard.js'></script>
<script src='./scripts/game.js'></script>
<div id='scoreboard'>
<h1 id='scores'>0-0</h1>
<h1 id='title'>3D ПИНГ-ПОНГ</h1>
<h2 id='winnerBoard'>Набравший 7 очков победит!</h2>
<h3>A - перемещение влево
<br>D - перемещение вправо</h3>
</div>
</body>
</html>
Начинаем рисовать
Мы создадим две функции setup()
и draw()
. Функция setup()
предназначена для самой первой сцены нашей игры. Функция draw()
будет запускаться на каждом кадре и будет управлять рендерингом и логикой игры.
function setup() {
draw();
}
function draw() {
requestAnimationFrame(draw);
}
Для того, чтобы зациклить функцию draw()
, мы вызовем функцию requestAnimationFrame()
и передадим ей параметр draw. К сожалению, на момент написания урока по Three.js
и WebGL не все браузеры поддерживают вызов этой функции, поэтому для совместимости вам придется использовать полифилл от Пола Айриша. Учтите, requestAnimationFrame()
не гарантирует постоянную частоту кадров, поэтому вам придется использовать временные дельты чтобы ваша игра выглядела более реалистично. К счастью, для такой простой игрушки как пинг-понг это не требуется, поэтому не будем заострять на этом внимание. Но, если вам интересно, - черкните мне пару строк и я наишу статью по этой теме в моем блоге.
Устанавливаем мир и камеру Three.js
Three.js
включает в себя несколько важных элементов:
- Сцена
- Рендер (отвечает за отрисовку объектов)
- Камера
- Меш (объект, состоящий из треугольников, с наложенной на них текстурой)
- Свет
- Материал
Камера, меш и свет добавляются в сцену с помощью функции scene.add()
.
Прикрепляем WebGL Three.js рендер к блоку div
Рендер может прикрепляться к любому DOM-элементу HTML, в котором вы хотите создать сцену. После чего, в каждом кадре вызывается функция render()
для отрисовки сцены.
// определяем размер сцены
var WIDTH = 640,
HEIGHT = 360;
// создаем WebGL рендер
var renderer = new THREE.WebGLRenderer();
// запуск рендера
renderer.setSize(WIDTH, HEIGHT);
// прикрепляем блок с id=gameCanvas к рендеру
var c = document.getElementById("gameCanvas");
c.appendChild(renderer.domElement);
...
function draw()
{
// отрисовываем THREE.JS sсцену
renderer.render(scene, camera);
// зацикливаем функцию draw()
requestAnimationFrame(draw);
// обработка игровой логики
...
}
Добавляем камеру на сцену
С помощью Three.js
можно создавать два вида камер: перспективную и ортогональную. Для большинства задач предпочтительной является перспективная камера. Как и любой объект сцены, мы можем менять положение и поворот камеры.
camera = new THREE.PerspectiveCamera(
VIEW_ANGLE,
ASPECT,
NEAR,
FAR);
scene = new THREE.Scene();
// добавляем камеру на сцену
scene.add(camera);
// устанавливаем начальную позицию камеры
// если этого не сделать, то может
// испортится рендеринг теней
camera.position.z = 320;
Рисуем сферу и подсвечиваем ее
Меши нужно использовать вместе с материалами, чтобы придать им нужный внешний вид. В зависимости от примитивов,из которых они состоят (куб, сфера, плоскость или тор), меши выглядят по-разному. Материалы мешей могут иметь различные характеристики в зависимости от их типа. Чаще всего это Lambert, Phong и Basic. Рассмотрим их по-подробнее:
- Basic
- рендерит неосвещенный меш без теней и затемнений. При таком материале сфера выглядит обычным кругом.
- Lambert
- материал с простым диффузным освещением, которое затемняет области объекта, удаленные от источника света, что придает объекту с матовой (не блестящей и не отражающей) поверхностью 3D-вид.
- Phong
- это отражающий материал, который отражает свет и и предметы
С помощью приведенного ниже кода, создаем сферу c материалом Lambert:
// устанавливаем переменные для
// сферы: radius, segments, rings
// низкие значения 'segment' и 'ring'
// улучшают производительность
var radius = 5,
segments = 6,
rings = 6;
// создаем материал сферы
var sphereMaterial =
new THREE.MeshLambertMaterial(
{
color: 0xD43001
});
// создаем шар с геометрией как у сферы
var ball = new THREE.Mesh(
new THREE.SphereGeometry(radius,
segments,
rings),
sphereMaterial);
// добавляем сферу на сцену
scene.add(ball);
Теперь вы видите свою сферу освещенной. Это самый обычный, ненаправленный свет. Убедитесь, что вы настроили интенсивность света и расстояние, чтобы наша сфера хорошо освещалась.
// создаем источник света
pointLight = new THREE.PointLight(0xF8D898);
// позиционируем
pointLight.position.x = -1000;
pointLight.position.y = 0;
pointLight.position.z = 1000;
pointLight.intensity = 2.9;
pointLight.distance = 10000;
// добавляем на сцену
scene.add(pointLight);
Добавляем объекты игры и рисуем плоскость для игры
Для игровой плоскости создадим меш с типом Plane (Плоскость). Размер плоскости будет соответствовать размеру игровой области с небольшими отступами. Там мы разместим две дощечки, которые будут ловить наш шарик.
// создаем материал плоскости
var planeMaterial =
new THREE.MeshLambertMaterial(
{
color: 0x4BD121
});
// создаем игровое поле
var plane = new THREE.Mesh(
new THREE.PlaneGeometry(
// 95% ширины стола, т.к. нужно показать
// где шар будет выходить за пределы поля
planeWidth * 0.95,
planeHeight,
planeQuality,
planeQuality),
planeMaterial);
scene.add(plane);
Рисуем дощечки
Дощечки будут мешами с типом Cube (Куб) и расположены напротив друг друга.
// устанавливаем переменные дощечек
paddleWidth = 10;
paddleHeight = 30;
paddleDepth = 10;
paddleQuality = 1;
// установка дощечки № 1
paddle1 = new THREE.Mesh(
new THREE.CubeGeometry(
paddleWidth,
paddleHeight,
paddleDepth,
paddleQuality,
paddleQuality,
paddleQuality),
paddle1Material);
// добавляем дощечку на сцену
scene.add(paddle1);
// установка дощечки № 2
paddle2 = new THREE.Mesh(
new THREE.CubeGeometry(
paddleWidth,
paddleHeight,
paddleDepth,
paddleQuality,
paddleQuality,
paddleQuality),
paddle2Material);
// добавляем дощечку на сцену
scene.add(paddle2);
// располагаем дощечки на краю поля напротив друг от друга
paddle1.position.x = -fieldWidth/2 + paddleWidth;
paddle2.position.x = fieldWidth/2 - paddleWidth;
// Поднимаем их над игровой поверхностью
paddle1.position.z = paddleDepth;
paddle2.position.z = paddleDepth;
Изменяя значения позиции камеры можно добиться различных эффектов перспективы, как показано на скриншоте.
Базовая логика. Движение шара.
Шарик имеет два направления движения - по оси X и по оси Y.
// переменные, обозначающие
// направления по осям X и Y и скорость шара
var ballDirX = 1, ballDirY = 1, ballSpeed = 2;
По плоскости X в каждом кадре шар будет двигаться с постоянной скоростью поэтому, зададим переменную ballSpeed, которая будет выступать в роли множителя для значений направлений.
// обновляем положение шара во время игры
ball.position.x += ballDirX * ballSpeed;
ball.position.y += ballDirY * ballSpeed;
Чтобы игра была менее предсказуемой и интересной, добавим характеристик к нашему шарику (например, он сильно ударяется о дощечку или стену), поэтому мы разрешим ему двигаться по оси Y со скоростю ballSpeed * 2
. Поиграйтесь с этим значением, пока не получите приемливый для вас вариант. С помощью этого параметра вы также можете менять сложность игры.
// ограничиваем скорость шарика
// чтобы он не летал как сумасшедший
if (ballDirY > ballSpeed * 2)
{
ballDirY = ballSpeed * 2;
}
else if (ballDirY < -ballSpeed * 2)
{
ballDirY = -ballSpeed * 2;
}
Логика отскока шара от стен
Во время игры должна происходить проверка: коснулся ли шарик какой-нибудь из боковых стенок. Используя несколько комбинаций if-else
мы проверяем позицию шарика относительно позиций стенок. При столкновении мы меняем направление шарика по оси Y и тем самым создаем эффект отскакивания шара.
// Если шар движется сверху
if (ball.position.y <= -fieldHeight/2)
{
ballDirY = -ballDirY;
}
// Если шар движется снизу
if (ball.position.y >= fieldHeight/2)
{
ballDirY = -ballDirY;
}
Позже мы вернемся к этому коду, чтобы реализовать увеличение счета при прохождении шара мимо дощечки.
Управление дощечками при помощи клавиатуры
С помощью библиотеки keyboard.js
, которую мы подключили к нашему главному файлу index.html, нам потребуется вызвать только одну функцию – Key.isDown()
. Получая параметр, библиотека проверяет нажата ли одноименная клавиша и возвращает логическое значение (1 или 0).
// движение влево
if (Key.isDown(Key.A))
{
// код, двигающий дощечку влево
}
Для движения дощечки влево и вправо, мы будем использовать клавиши A
и D
соответственно. Если вы хотите использовать другие клавиши, откройте и отредактируйте файл keyboard.js.
...
var Key = {
_pressed: {},
A: 65,
W: 87,
D: 68,
S: 83,
// добавьте код (ASCII) вашей клавиши
// вместе с ее значением, например:
SPACE: 32,
...
};
Мы должны предусмотреть чтобы наша дощечка не ушла с игровой площадки. Это можно сделать с помощью нескольких комбинаций if-else
.
// движение влево
if (Key.isDown(Key.A))
{
// двигаем дощечку пока она не коснется стенки
if (paddle1.position.y < fieldHeight * 0.45)
{
paddle1DirY = paddleSpeed * 0.5;
}
// в противном случае мы прекращаем движение и растягиваем
// дощечку чтобы показать, что дальше двигаться нельзя
else
{
paddle1DirY = 0;
paddle1.scale.z += (10 - paddle1.scale.z) * 0.2;
}
}
Обратите внимание, что мы используем переменную, в которой хранится направление движение дощечки, вместо того, чтобы просто изменять значение позиции. Это пригодится нам, когда мы будем программировать движение шарика при попадании под углом в движущуюся дощечку.
Программирование AI
Когда вы программируете игру такого масштаба, крайне важно создать яркую, сочную среду со множеством харизматичных персонажей. В нашей игре мы создадим искусственный интеллект (AI), который просто будет следовать за шаром.
// применяем функцию Lerp к шару на плоскости Y
paddle2DirY = (ball.position.y - paddle2.position.y) * difficulty;
Мы можем изменить сложность оппонента используя переменную, вместо написания "магических чисел". Эта переменная влияет на "скорость реакции" противника за счет увеличения времени линейной интерполяции (Lerp).
При использовании этой функции мы должны сделать чтобы компьютер играет честно, ограничив его максимальную скорости перемещения. Сделаем это, как обычно, с помощью нескольких комбинаций if-else
.
// если функция Lerp вернет значение, которое
// больше скорости движения дощечки, мы ограничим его
if (Math.abs(paddle2DirY) <= paddleSpeed)
{
paddle2.position.y += paddle2DirY;
}
// если значение функции Lerp слишком большое,
// мы ограничиваем скорость paddleSpeed
else
{
// если дощечка движется в положительном направлении
if (paddle2DirY > paddleSpeed)
{
paddle2.position.y += paddleSpeed;
}
// если дощечка движется в отрицательном направлении
else if (paddle2DirY < -paddleSpeed)
{
paddle2.position.y -= paddleSpeed;
}
}
Если вы хотите увеличить степень погружения в игру, вы можете использовать свойство paddle.scale()
, чтобы растянуть дощечку, когда она не может двигаться. Для того, чтобы сделать это, мы должны быть уверены, что дощечка вернется к своему нормальному размеру.
// Мы возвращаем значение функции Lerp обратно в 1
// это нужно, потому что мы растягиваем дощечку в нескольких случаях:
// когда дощечка прикасается к стенкам стола или ударяется о шарик.
// Так мы гарантируем, что она всегда вернется к своему исходному размеру
paddle2.scale.y += (1 - paddle2.scale.y) * 0.2;
Добавляем игровой процесс. Ставим шар обратно на место после падения его со стола
Чтобы обеспечить главный игровой процесс – ведение счета, нам необходимо убрать эффект отскакивания от двух стенок, где расположены дощечки. Для этого мы просто уберем часть существующего кода.
// Если шар двигается сверху
if (ball.position.y <= -fieldHeight/2)
{
ballDirY = -ballDirY;
}
// Если шар двигается снизу
if (ball.position.y >= fieldHeight/2)
{
ballDirY = -ballDirY;
}
// --------------------------------- //
// ИЗМЕНЕННЫЙ КОД //
// --------------------------------- //
// если шар двигается слева (со стороны игрока)
if (ball.position.x <= -fieldWidth/2)
{
// Компьютер получает очко
// обновляем таблицу с результатами
// ставим новый шарик
}
// если шар двигается справа (со стороны компьютера)
if (ball.position.x >= fieldWidth/2)
{
// Игрок получает очко
// обновляем таблицу с результатами
// ставим новый шарик
}
Мы можем реализовать таблицу с результатами разными способами, но для текущей игры, мы просто увеличим соответствующую переменную.
// если шар двигается слева (со стороны игрока)
if (ball.position.x <= -fieldWidth/2)
{
// Компьютер получает очко
score2++;
// обновляем таблицу с результатами
document.getElementById("scores").innerHTML = score1 + "-" + score2;
// устанавливаем новый шар в центр стола
resetBall(2);
// проверяем, закончился ли матч (набрано требуемое количество очков)
matchScoreCheck();
}
Мы обновляем содержимое блока со счетом с помощью его свойства innerHTML
. После того, как шарик упал, мы должны поставить его снова в центре стола. Напишем простую функцию, которая принимает в качестве параметра дощечку, которая потеряла шарик.
// располагаем шарик по центру стола
// также устанавливает скорость и направление шара
// в сторону последней победивше точки
function resetBall(loser)
{
// располагаем шар по центру стола
ball.position.x = 0;
ball.position.y = 0;
// если игрок проиграл, отправляем шар компьютеру
if (loser == 1)
{
ballDirX = -1;
}
// если компьютер проиграл, отправляем шар игроку
else
{
ballDirX = 1;
}
// шар двигается в положительном направлении по оси Y (налево от камеры)
ballDirY = 1;
}

Отскакивания шара от дощечки
Настало время научить дощечки ударять по шарику. В простой игре в понг вся физика шара является всего лишь парой конструкций if-else. Мы проверяем позицию шара по осям X и Y и сравниваем их с границами дощечки. Если они пересекаются, то создаем эффект отскакивания.
// если шар имеет одинаковые координаты с дощечкой № 1
// на плоскости Х запоминаем позицию ЦЕНТРА объекта
// мы делаем проверку только между передней и средней
// частями дощечки (столкновение одностороннее)
if (ball.position.x <= paddle1.position.x + paddleWidth
&& ball.position.x >= paddle1.position.x)
{
// если у шара одинаковые координаты с дощечкой № 1 на плоскости Y
if (ball.position.y <= paddle1.position.y + paddleHeight/2
&& ball.position.y >= paddle1.position.y - paddleHeight/2)
{
// шар пересекается с передней частью дощечки
}
}
Также важно проверить направление движение шара, так как мы хотим проверить столкновения в одном направлении (по направлению к оппоненту).
// если шар движется к игроку (отрицательное направление)
if (ballDirX < 0)
{
// растягиваем дощечку, чтобы показать столкновение
paddle1.scale.y = 15;
// меняем направление движения чтобы создать эффект отскакивания шара
ballDirX = -ballDirX;
// Меняем угол шара при ударе.
// Немного усложним игру, позволив скользить шарику
ballDirY -= paddle1DirY * 0.7;
}
Мы также можем влиять на боковое движение шара, основываясь на относительной скорости дощечки в момент удара по ней. Это особенно полезно при введении внешних факторов: скольжения. Скольжение шара часто является единственным способом запутать и перехитрить противника, поэтому оно очень важно в этой игре.
Не забудьте продублировать код, обновив значения в соответствии с дощечкой противника. Вы можете использовать эту возможность, чтобы ослабить какую-нибудь способность вашего соперника.
Окончательный вариант функции, описывающей столкновения дощечка-шар:
// Содержит логику столкновений с дощечкой
function paddlePhysics()
{
// ЛОГИКА ДОЩЕЧКИ ИГРОКА
// если шар имеет одинаковые координаты с дощечкой № 1 на плоскости Х
// запоминаем позицию ЦЕНТРА объекта, при этом мы проверяем
// только переднюю и среднюю части дощечки (одностороннее столкновение)
if (ball.position.x <= paddle1.position.x + paddleWidth
&& ball.position.x >= paddle1.position.x)
{
// и если шар имеет одинаковые координаты с дощечкой № 1 на плоскости Y
if (ball.position.y <= paddle1.position.y + paddleHeight/2
&& ball.position.y >= paddle1.position.y - paddleHeight/2)
{
// и если шар направляется к игроку (отрицательное направление)
if (ballDirX < 0)
{
// растягиваем дощечку при ударе
paddle1.scale.y = 15;
// меняем направление движения шара (эффект отскакивания)
ballDirX = -ballDirX;
// меняем угол шара при ударе
ballDirY -= paddle1DirY * 0.7;
}
}
}
// ЛОГИКА ДОЩЕЧКИ СОПЕРНИКА
// если шар имеет одинаковые координаты с дощечкой # 2 на плоскости Х
// запоминаем позицию ЦЕНТРА объекта, при этом мы проверяем
// только переднюю и среднюю части дощечки (одностороннее столкновение)
if (ball.position.x <= paddle2.position.x + paddleWidth
&& ball.position.x >= paddle2.position.x)
{
// и если шар имеет одинаковые координаты с дощечкой № 2 на плоскости Y
if (ball.position.y <= paddle2.position.y + paddleHeight/2
&& ball.position.y >= paddle2.position.y - paddleHeight/2)
{
// и если шар направляется к сопернику (положительное направление)
if (ballDirX > 0)
{
// растягиваем дощечку при ударе
paddle2.scale.y = 15;
// меняем направление движения шара (эффект отскакивания)
ballDirX = -ballDirX;
// меняем угол шара при ударе
ballDirY -= paddle2DirY * 0.7;
}
}
}
}
Очки
В пинг-понге для победы нужно набрать максимальное количество очков. Создадим для этого переменную maxScore
.
// переменные с очками каждого игрока
var score1 = 0, score2 = 0;
// игра завершится, когда кто-то наберет 7 очков
var maxScore = 7;
Создадим функцию matchScoreCheck()
, которая будет вызываться когда кто-то не поймал шарик и проверять очки каждого игрока.
// проверяем, закончился ли матч (набрано требуемое количество очков)
function matchScoreCheck()
{
// если выиграл игрок
if (score1 >= maxScore)
{
// останавливаем шар
ballSpeed = 0;
// выводим текст
document.getElementById("scores").innerHTML = "Победа игрока!";
document.getElementById("winnerBoard").innerHTML = "Обновите страницу чтобы сыграть снова";
}
// если выиграл компьютер
else if (score2 >= maxScore)
{
// останавливаем шар
ballSpeed = 0;
// выводим текст
document.getElementById("scores").innerHTML = "Компьютер выиграл!";
document.getElementById("winnerBoard").innerHTML = "Обновите страницу чтобы сыграть снова";
}
}
После того, как игра закончена, нужно поставить шар в центр стола и прекратить любое движение.
Наводим лоск
Чтобы игрок был в курсе того, что происходит, очень важно дать ему обратную связь. Давайте будем показывать счет на экране. Чтобы не отрисовывать интерфейс в том же окне, что и игра, воспользуемся специально созданными для этого тегами.
Покажем сколько очков нужно набрать для победы – за это у нас отвечает блок с id=winnerBoard
, который мы будем обновлять при запуске игры.
// Обновляем блок, содержащий сообщение о необходимых для победы очках
document.getElementById("winnerBoard").innerHTML = "Набравший " + maxScore + " очков победит!";
Тени
Оставшуюся часть времени займемся украшательствами. Библиотека Three.js
обладает впечатляющими возможности для создания теней для примитивов – кубов, плоскостей, сфер и т.д. Тени не могут быть созданы только силами точечного света, поэтому нужно добавить источник направленного света и прожектор. Прожектор светит большим круглым лучом света на поверхность, в то время как направленный источник просто излучает свет в определенном направлении без учета позиции относительно объекта.
Мы будем использовать прожектор, потому что он явно указывает откуда падает свет и в какую сторону.
// создаем точечный свет
pointLight = new THREE.PointLight(0xF8D898);
// позиционируем
pointLight.position.x = -1000;
pointLight.position.y = 0;
pointLight.position.z = 1000;
pointLight.intensity = 2.9;
pointLight.distance = 10000;
scene.add(pointLight);
// добавляем прожектор для создания теней
spotLight = new THREE.SpotLight(0xF8D898);
spotLight.position.set(0, 0, 460);
spotLight.intensity = 1.5;
spotLight.castShadow = true;
scene.add(spotLight);
// Включаем рендеринг теней
renderer.shadowMapEnabled = true;
Чтобы придать динамичности нашей игре, сделаем чтобы луч прожектора двигался за катящимся шаром.
// позиционируем тени
// также как и шарик
spotLight.position.x = ball.position.x;
spotLight.position.y = ball.position.y;
Чтобы объекты отбрасывали тени, или тени появлялись на объектах, установим значение true для переменных .receiveShadow
и .castShadow
соответствующих объектов. Например:
paddle1 = new THREE.Mesh(
new THREE.CubeGeometry(paddleWidth, paddleHeight,
paddleDepth, paddleQuality,
paddleQuality, paddleQuality),
paddle1Material);
// добавляем сферы на сцену
scene.add(paddle1);
paddle1.receiveShadow = true;
paddle1.castShadow = true;
Заключение
Наш урок по библиотеке Three.js
подошел к концу. Мы научились перемещать объекты, создавать тени и создали игру Пинг-понг. Вы можете поиграть в игру Пинг-понг на WebGL или скачать исходный код игры.
Вы также можете сделать целый ряд вещей, чтобы отполировать свою игру до блеска, например,
- Обновлять HUD (индикатор для отображения важной информации во время компьютерной игры), чтобы игра выглядела красивее
- Переместить элементы HUD на сцену, чтобы позволить игрокам играть полноэкранном режиме
- Повозиться со сложными шейдерами, чтобы создать отражения и другие интересные эффекты