Pull to refresh

JavaScript Gaming: Часть 1. Box2d и основы Физики

Reading time 28 min
Views 23K


Вместо предисловия.


Я всегда любил и буду любить компьютерные игры. Есть в них какая-то своя внутренняя магия, которая всегда привлекает и завораживает одновременно. За всю свою жизнь я переиграл в десятки игр, начиная с ветеранов Wolfenstein и Dune 2 и заканчивая современными блокбастерами. И теперь, добивая очередной хит и наблюдая за финальным роликом и титрами, в голове все чаще и чаще мелькает мысть «А что, если?..»

А ведь действительно, что если взять и написать собственную игру? Конечно же понятно, что ААА-тайтл сделать в одиночку не получится, и это годы работы и прочая и прочая, но ведь осилит дорогу идущий? Так уж получилось, что в Desktop-программироании я откровенно слаб, и вариантов для практикующего веб-разработчика не так уж много. Но за последние годы все кардинально изменилось, и теперь уже у браузера много общего с кофеваркой, а javascript может спокойно удовлетворять даже нужды военных ведомств, не то что мои собственные.

Вот как раз во время очередных раздумий и достаточно серьезной простуды мне попалась на глаза статья о Box2d в игрологе Ant.Karlov'а. Зачитавшись и замечтавшись я очень быстро нашел JS-порт этой библиотеки, и старая шальная идея сделать что-то маленькое и, главное — свое, начала донимать меня с новыми силами.

В общем, меньше патетики, больше дела. Надеюсь, вам будет интересно. Да простят меня суровые боги за использование Angry Birds в КПДВ ^_^

Вводная и Disclaimer


Конечная цель всего цикла заметок — сделать полноценный клон Angry Birds, как минимум с 3-мя видами метательного оружия и 5-ю разными уровнями. Да, их и так уже достаточно, и в разных компоновках, и даже самой Angry Birds как минимум 3 версии, но нужно же на чем-то практиковаться? В процессе я постараюсь затронуть все аспекты — сам физический движок, работу с растровой графикой, редактор уровней (а он обязательно понадобится), обвязку в виде меню/credits'ов/настроек, звук и музыку и еще тысячу важных мелочей. Всего этого у меня, на самом деле, самого еще нет, но в процессе написания я буду рассказывать здесь, что и как у меня получилось сделать и к чему это привело.

Некоторые особо длинные куски кода я буду показывать троеточиями. Для особо нетерпеливых в конце заметки есть ссылка на работающий пример.

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

Поехали!


Итак, приступим. Первое, что нам нужно — это создать мир игры, наполнить его тестовым уровнем и попробовать бросить первую «птицу» в первую «свинью». Поскольку в самой Angry Birds мы видели псевдо-реальную физику — нам понадобится физический движок, который будет занят обработкой столкновений и перемещениями птиц, свиней и прочих объектов по игровому миру.

В качестве физического движка я выбрал Box2d по нескольким причинам:

1. У него достаточно сильное Flash-комьюнити, где можно попросить помощи или совета.
2. Библиотека Box2dWeb, которую я решил использовать — прямой порт Box2dAS, так что API Reference, как минимум, уже есть.
3. Достаточно полезной информации, правда на антимонгольском, о процессе разработки и внутренностях движка.
4. Ну и, конечно же, сотни успешных применений.

В общем и целом, движок у нас есть — теперь нужно примерно представить все, что нам нужно и создать наш маленькмй и уютненький мир. Начнем мы, пожалуй, с небольшой HTML-заготовки. Забросим на страничку canvas и создадим базовый объект нашей игры.

<html>
   <head>
        <title>Angry Birds WRYYYYYYYY!!!!</title>
        <script src="/js/jquery.js" type=«text/javascript»></script>
        <script src="/js/box2d.js" type=«text/javascript»></script>
 
    <script>
        $(document).ready(function(){            
            new Game();
        });
    </script>    
    <body>
        <canvas id=«canvas» width=«800» height=«600» style="background-color:#333333;" ></canvas>
    </body>
    <script type=«text/javascript»>
        Game = function() {
            …
        }
    </script>
</html>


Замечательно, у нас есть шаблон для дальнейших экспериментов. Теперь нужно создать физический мир и начать активно использовать Box2d. Этим мы и займемся.

    Game = function() {
 
           //Я решил сразу обозначить типы объектов константами
            const PIG = 1;
            const BIRD = 2;
            const PLANK = 3;
 
            //Флаг, который нам понадобится при обработке «броска».
            isMouseDown = false;
 
            //Будущая «резинка» для виртуальной рогатки
            mouseJoint = false;
 
            //Используется в процессе обработки броска
            body = false;
            mousePosition = {x:0,y:0};
 
            // Все необходимые ресурсы для наших текущих нужд. 
            // Решил сразу переопределить, чтобы не городить километры непонятного и нечитаемого кода.
            // Значение каждого ресурса я попытаюсь объяснить далее в заметке.
            b2AABB  = Box2D.Collision.b2AABB;
            b2World = Box2D.Dynamics.b2World;
            b2Vec2 = Box2D.Common.Math.b2Vec2;
            b2DebugDraw = Box2D.Dynamics.b2DebugDraw;
            b2Body = Box2D.Dynamics.b2Body;
            b2BodyDef = Box2D.Dynamics.b2BodyDef;
            b2FixtureDef = Box2D.Dynamics.b2FixtureDef;
            b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;
            b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;
            b2MouseJointDef = Box2D.Dynamics.Joints.b2MouseJointDef;
            b2ContactListener = Box2D.Dynamics.b2ContactListener;
 
            // Создаем наш мир.
            world =  new b2World(
                new b2Vec2(0, 10)   // Вектор гравитации.
               ,true                          // doSleep флаг.
             );
 
            init = function() { // Инициализация всего в нашем мире, вызывается при создании объекта
                …
            }  
 
            init();
        }


Немного подробнее хотелось бы остановиться на параметрах нашего мира. Первый — это вектор гравитации. Box2d достаточно подробно моделирует физику окружающего мира, поэтому без влияния гравитации тут не обошлось. У нас этим вектором задается ускорение свободного падения, равное 10 виртуальным метрам в секунду по оси Y. Второй параметр: doSleep, разрешает не обсчитывать в текущий момент неактивные элементы. Это очень сильно сказывается на скорости работы и я не советую менять этот параметр.

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

Думаю, все присутствующие здесь в курсе, что обработка экранного полотна в любой игре происходит покадрово, не исключение в этом и Box2d. Поэтому нам необходимо создать для него метод обновления и загнать этот метод в SetTimeout, чтобы он вызывался постоянно для пересчета объектов и перерисовки графики.

        Game = function() {
            …
            init = function() {
                window.setInterval(update, 1000 / 60);
            }
 
            update = function() {
                world.Step(1/60, 10, 10);
                world.DrawDebugData();
                world.ClearForces();            
            }
       }


В функции update() мы, в первую очередь, задали частоту обновления мира — 60 кадров в секунду, а так же задали предельное количество обрабатываемых событий изменения скорости объектов и их положения на 1 такт работы. При увеличении этих параметров общая скорость реакции будет увеличиваться, но с увеличением количества объектов мы можем уткнуться в системные ресурсы, при уменьшении — получим неправильную обработку объектов от такта к такту. 10 — вполне вменяемое среднее значение.

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

        Game = function() {
            …
            init = function() {
                buildWorld();
                initDraw();
 
                window.setInterval(update, 1000 / 60);
            }
 
            initDraw = function() {
                debugDraw = new b2DebugDraw();
 
                debugDraw.SetSprite(document.getElementById(«canvas»).getContext(«2d»));
                debugDraw.SetDrawScale(30.0);
                debugDraw.SetFillAlpha(0.5);
                debugDraw.SetLineThickness(1.0);
                debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
                world.SetDebugDraw(debugDraw);   
            }
 
            buildWorld = function() {
                fixDef = new b2FixtureDef();
 
                fixDef.density = 1.0;
                fixDef.friction = 0.5;
                fixDef.restitution = 0.2;       
            }
       }


Да, стоит добавить, что для отображения в текущем виде мы будем использовать только Debug mode — т.е. прямое представление объектов в нашем физическом мире без текстур и графики. Что называется — As Is. Именно для этого мы в функции update() вызываем метод DrawDebugData() и именно для этого отображения мы создаем объект b2DebugDraw(). Там же устанавливаем масштаб, полупрозрачность и битами задаем, что именно нам нужно рисовать. Более детально все по b2DebugDraw() достаточно хорошо описано в API Reference.

Так же стоит добавить, что Box2d не оперирует пикселями как данными о координатной сетке. Он изначально пректировался под использование стандарной системы Си — поэтому мы имеем метры, килограммы, секунды, и соответствующие преобразования. Сейчас я не буду углубляться подробно в эту область, просто имейте в виду, что 1 метр в представлении Box2d — это примерно 30 пикселей экранного пространства.

Ну и еще немного добавлю про структуру fixDef() — это набор основных параметров физики объектов. Именно в этой струкруре описываются основные параметры, по которым будет обсчитываться физика процесса взаимодействия объектов. Как видите, мы указали параметры для трения, плотности и упругости объектов.

Рисование


Мы вплотную приблизились к нашей заветной цели — проектированию механики игры, но для начала нужно поместить в наш мир какие-то объекты и уже затем попробовать что-то с ними сделать. Поэтому добавим в наш init() методы построения ограничителей для нашего пространства и, прорисуем наш уровень с прямоугольниками в качестве неразбиваемых досок и кругами в качестве «свиней».

        Game = function() {
            …
            PigData = function() {};
            PigData.prototype.GetType = function() {
                    return PIG;
            }
 
            PlankData = function() {}
            ... // GetType() аналогичный PigData.GetType()
 
            buildWorld = function() {
                ...              
                bodyDef = new b2BodyDef();            
                bodyDef.type = b2Body.b2_staticBody;
 
                fixDef.shape = new b2PolygonShape;
                fixDef.shape.SetAsBox(20, 2);
                bodyDef.position.Set(10, 600 / 30+1.8);
                world.CreateBody(bodyDef).CreateFixture(fixDef);
 
                ... //еще 3 стенки
 
                canvasPosition = $("#canvas").offset();
            }
 
            buildLevel = function() {
 
                createPlank(22, 20, 0.25, 2)
                ... // Отрисовка еще 8-ми досок
 
                createPig(20,11,0.5);
                ... // Еще 2 дополнительные свиньи
            }
 
            createPlank = function(x,y, width, height) {
                bodyDef.type = b2Body.b2_dynamicBody;
                fixDef.shape = new b2PolygonShape;
                fixDef.shape.SetAsBox (
                     width
                  ,  height
                );
 
                bodyDef.position.x = x;
                bodyDef.position.y = y;
                plank = world.CreateBody(bodyDef);
                plank.SetUserData(new PlankData());
                plank.CreateFixture(fixDef);    
            }
 
            createPig = function(x, y, r) {
                ... // Метод, аналочичный методу createPlank, за исключением того, что используется CircleShape и PigData();
            }  
        }


Box2d позволяет привязывать к любому физическому объекту доподнительный «юзерский» интерфейс, с помощью которого можно непосредственно реализовать игровую логику. Именно поэтому мы добавили 2 объекта — PigData и PlankData, которые возвращают нам тип объекта. Это станет важным чуть позже, когда мы займемся обработкой столкновений.

Структура BodyDef предназначна для описания геометрических характеристик объекта и его общее поведение (FixtureDef же описывает его физические свойства). Именно в BodyDef мы указываем, как будет обсчитан тот или иной блок, будет ли он статическим, или же будет динамическим. Вообще в Box2d, как нам рассказывает документация, есть 3 типа объектов — static, т.е. полностью статические объекты (в DebugView — зеленые объекты), dynamic — полностью независимые динамические объекты, и kinematic — объекты, отвечающие за движение. В качестве примера static — это дорога, dynamic — это подвеска автомобиля и колеса, а kinetic — это двигатель автомобиля.

Там же мы указываем геометрическую форму объекта, положение его левого верхнего угла и размеры. Все в метрах ( Ох как же я долго догадывался, почему у меня размеры и пиксели не совпадают! ).

Ну и в конце мы дорисовываем новый метод drawLevel(), который будет отрисовывать нам весь уровень вместе с досками и свинками.

Рогатка


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

Для этого нам понадобится мышь.

        Game = function() {
            …
             init = function() {
                bindMouse();
                …
            }
 
            bindMouse = function() {
                $(document).mousedown(function(e){
                    isMouseDown = true;
                    handleMouse(e);
                    $(document).bind(«mousemove», {}, handleMouse);
                });
 
                $(document).mouseup(function(e){
                    isMouseDown = false;
                    $(document).unbind(«mousemove»);
                });                               
            }
 
             handleMouse = function(e) {
                mouseX = (e.clientX - canvasPosition.left) / 30;
                mouseY = (e.clientY - canvasPosition.top) / 30;
            };
 
            update = function() {                
                if(isMouseDown) {
                    if(!(body)) {
                        mousePosition = {x:mouseX, y:mouseY};
                        createPig(mouseX, mouseY, 0.40);
                        body = getBodyAtMouse();
 
                        md = new b2MouseJointDef();
                        md.bodyA = world.GetGroundBody();
                        md.bodyB = body;
                        md.target.Set(mousePosition.x, mousePosition.y);
                        md.collideConnected = true;
                        md.maxForce = 300.0 * body.GetMass();
                        mouseJoint = world.CreateJoint(md);
                        body.SetAwake(true);                     
                    }
 
                    body.SetPosition(new b2Vec2(mouseX, mouseY));
                }
 
                if(mouseJoint && !isMouseDown) {
                        mouseX = mousePosition.x;
                        mouseY = mousePosition.y;
                        if(getBodyAtMouse()) {
                          world.DestroyJoint(mouseJoint);
                          mouseJoint = null;
                          body = false;
                        }
                } 
            …
            }  
        }


Итак, что мы здесь сделали? Первое — это, конечно же, прибили обработчики мыши mousedown и mouseup к нашей маленькой игре. Теперь при нажалии на клавишу мыши в игровом поле будет устанввливаться флаг isMouseDown, ну и при движении мышью координаты, сохраняемые в mouseX и mouseY, будут меняться. Второе, чего мы добились — это динамическое создание объекта при клике мышки, эту часть я вынес в метод update(). Грубо говоря, мы сразу же создаем новую «птичку», если ее не было, хоть она у нас и является объектом с типом «свинья» — летает она не хуже.

Дальше интереснее — при помощи метода GetBodyAtMouse() мы сразу получаем на обработку тело объекта нашей птички и используя MouseJoint привязываем ее к нашему миру. Т.е. именно MouseJoint создает резинку рогатки, которая будет запускать птичку. Там же мы указываем направление действия нашей гибкой сцепки и максимальное усилие.

В примере четко видно, что после нажатия на клавишу мыши появляется новая окружность с характерной голубоватой связкой, прицепленной к центру. Если вы не будете отжимать клавишу мыши — связка так и будет тянуться к центру окружности. Но если отжать…

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

На методе GetBodyAtMouse() я хотел бы остановиться поподробнее, уж очень он интересен.

            function getBodyAtMouse() {
                mousePVec = new b2Vec2(mouseX, mouseY);
 
                var aabb = new b2AABB();
                aabb.lowerBound.Set(mouseX - 0.001, mouseY - 0.001);
                aabb.upperBound.Set(mouseX + 0.001, mouseY + 0.001);
 
                selectedBody = null;
                world.QueryAABB(getBodyCallback, aabb);
                return selectedBody;
            }            
 
            getBodyCallback = function(fixture) {
                if(fixture.GetBody().GetType() != b2Body.b2_staticBody) {
                   if(fixture.GetShape().TestPoint(fixture.GetBody().GetTransform(), mousePVec)) {
                      selectedBody = fixture.GetBody();
                      return false;
                   }
                }
                return true;
            }


Первое, что здесь делается — создается новый вектор с текущим положением мыши. Дальше мы создаем структуру, выделяющую часть экранного пространства в системе координат Box2d под указателем мыши, и при помощи getBodyCallback определяем, пересекатся ли область под мышью с каким-либо телом вообще, и если да — устанавливаем новый selectedBody. В принципе, все просто и нетривиально.

Свинки


До этой части, в принципе, все шло достаточно монотонно в канве стандартных примеров. Но для правильной обработки столкновений мне пришлось порядочно зарыться в мануал. Что же теперь необходимо сделать? Всего самую малость — добиться того, чтобы наши свинки, расположенные на уровне, исчезали. Но не просто так от контакта, а от контакта с определенным усилием. Для этого нам будет необходимо переопределить стандартный обработчик контактов и добавить подсчет суммарного импульса, который получила наша свинка в процессе столкновения.

        Game = function() {
            …
            GameContactListener = function() {};
            GameContactListener.prototype = b2ContactListener.prototype;
 
            GameContactListener.prototype.PostSolve = function(contact, impulse) {
                if(contact.GetFixtureB().GetBody().GetUserData()) {
                    var BangedBody = contact.GetFixtureB().GetBody();
                    if(contact.GetFixtureB().GetBody().GetUserData().GetType() == PIG) {
                        var imp = 0;
                        for(in impulse.normalImpulses) {
                            imp = imp + impulse.normalImpulses[a];
                        }
 
                        if(imp > 3) {
                            destroyedBodies.push(BangedBody);
                            BangedBody.visible = false;
                            BangedBody = null;
                        }
                    }
                }
 
                var contactListener = new GameContactListener();
                world.SetContactListener(contactListener); 
                update = function() {
                    …
                    destroyedBodies = Array(); 
                    …
                    world.ClearForces(); //  ВАЖНО!
                    while(destroyedBodies.length > 0) {
                        world.DestroyBody(destroyedBodies.shift());
                    }
                }
            }


Первое, что ммы здесь делаем — переопределяем стандартный ContactListener. Это объект, отвечающий за обработку всех столкновений. Там же при помощи нашего метода мы проверяем UserData() и удовлетворяемся тем, что действительно мы ударили свинку, и если нас устраивает импульс, с которым ее ударили — мы скрываем ее с поля боя и заносим в массив на удаление.

Честно посыпаю голову пеплом — силу импульса подобрал наугад, так как уже не было никаких сил считать необходимый импульс усилия вручную. Надеюсь, подобрал оптимальный вариант. Так же это условие важно тем, что при создании мира так же происходят столкновения до тех пор, пока мир не придет в статическое состояние (Возможно, вы наблюдали в некоторых играх на Flash подобной тематики при создании уровня легкое подергивание плит перекрытий? В моем примере это тоже есть. Вот, собственно, чтобы свиньи не исчезали при таких коллизиях — нужна проверка суммарного импульса).

Дальше в конце метода update после вызова ClearForces() мы перевариваем наш массив и удаляем всех свинок, которые были нещадно избиты летающей сестрой. Я специально выделил это место, как важное — у вас не получится удалить ни один объект до тех пор, пока идет процесс обсчета физики. Таковы условия Box2d. Только после того, как все объекты освободились от математики — можно свободно проводить их удаление. Именно поэтому свинки лопаются только после того, как сцена, фактически, завершилась.

Итоги


Фуф, вроде как простыня завершена. Прошу прощения за изобилие кода и возможные ошибки.

Как я и обещал — ссылка на пример, в котором полностью реализовано все то, что я описывал выше.

Ну и скриншот получившегося произведения:



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

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

Спасибо за внимание, буду раз конструктивной критике ^_^

Список полезного


code.google.com/p/box2dweb — Box2dWeb, порт Box2dAS для Javascript'а.
www.box2dflash.org/docs/2.1a/reference — API Reference на антимонгольском.
docs.google.com/View?id=dfh3v794_41gtqs6wf4&pli=1 — Достаточно полное руководство на русском. Спасибо VirtualMaestro и его блогу — flashnotes.ru. Там же очень много полезного по поводу Box2d.
www.emanueleferonato.com — Очень полезный блог про Box2d в частности и инди в целом.
ant-karlov.ru — блог вдохновителя, если кому-то это будет интересно ^_^
Tags:
Hubs:
+147
Comments 35
Comments Comments 35

Articles