Physics Snake. С нуля. Часть первая

в 21:16, , рубрики: canvas, game development, Gamedev, javascript, метки: , ,

Статья с тэгом «обучающий материал». С нуля, поэтому будем писать свой не сложный (для начала) физический движок и сразу же не сложную игру (я выбрал змейку) на нем. Но статья скорее будет не об этом, так как это не такое уж и сложное задание, а о том, как это все будет на JavaScript, причем с максимально красивым (правильным) кодом (жду, что все что можно сделать еще лучше вы опишите в комментариях). «А в ответ полетели спелые помидоры..». Начнем.
(кто дочитал аж до сюда, держите печеньки, управления стрелками влево-вправо):
вот что будет: в части один
и это же (dev-mode)

Начнем с index.html

смотрим код

<!DOCTYPE html>
<html>

<head>    
  <meta charset="utf-8"/>
  <link rel="stylesheet" href="css/main.css"  type="text/css" />
         
</head>
	<body>
        <div id="container">            
            <div id="bg"></div>                               
            <canvas id="canvas" width="900" height="600"></canvas>            
        </div>
        
        <script src="js/LAB.min.js" type="text/javascript"></script>        
        <script type="text/javascript">
    		   $LAB
    		   .script("js/engine/CustomEvent.js").wait()
    		   .script("js/engine/Body.js").wait() 
                   .script("js/engine/World.js")    
                   .script("js/Bonus.js")                                        
    		   .script("js/EnemyCloud.js")
    		   .script("js/Hero.js")    		   
    		   .script("js/mainApp.js");                 
        </script>
        
	</body>
</html>

Подключаем скрипты перед закрывающим тегом body.

Почему не в head? Как только браузер видит тег script он блокирует дальнейшее построение страницы до момента, пока данный скрипт не будет загружен. То есть перед вами будет просто белый экран (и не нужно здесь о гигабитном интернете).

У нас много файлов. В идеале все нужно обфусцировать и закинуть в один файл. Или сгруппировать максимум в пару файлов. Мы же будем использовать библиотеку LAB.js для загрузки остальных файлов с скриптами.
Загружаем файлы параллельно.

Используем LAB.js (или еще можно например LazyLoad.js и т.д.). Она позволяет нам параллельно загружать файлы скриптов (как помните, уже говорилось выше, что на каждом теге вставки скрипта построение останавливается, то есть все скрипты грузятся по очереди подряд) плюс мы можем контролировать загрузку (например функция wait). Мы, к примеру, не можем использовать, скажем, плагин к jQuery, когда еще не загрузилась сама jQuery, понятно, мы ставим паузу на jQuery, и как только она загрузится — все пойдет дальше.

Переходим к файлу mainApp.js

смотрим код

(function() {
    
 var game = {    
    
    images: [
            { url: 'images/en_blue.png' },
            { url: 'images/en_white.png' },
            { url: 'images/en_red.png' }        
    ],        
        
    loadCount: 0,
                
    load: function() {
            var self = this,
            max = this.images.length;
               
        for (var i = max; i--;) {    
            this.images[i].img = new Image();
                                  
            this.images[i].img.onload = function () {                
                self.loadCount++;                
                                                        
                if (self.loadCount == max) {                    
                    self.loadComplete(self);                        
                }
            }                            
            this.images[i].img.src = this.images[i].url;
        }
    },
        
    loadComplete: function(self) {
        self.initControll().initGame().startLoop();    
    },
    
    canvas: document.getElementById('canvas'),
    
    config: {
        fps: 1000/30,        
        stageW: 900,
        stageH: 600                
    },    
    
    bodies: [],
    enemies: [],
    bonuses: [],
    
    initControll: function() {
        var handlers = this.controllHandlers();            
        
        if (document.documentElement.addEventListener) {
            document.body.addEventListener('keydown', handlers.keyDown); 
            document.body.addEventListener('keyup', handlers.keyUp);    
        }
        else {
            document.body.attachEvent('keydown', handlers.keyDown); 
            document.body.attachEvent('keyup', handlers.keyUp);
        }        
                                
        return this;
    },
    
    controllHandlers: function() {        
        var self = this;            
                    
        return {            
            keyDown: function(e) {
                self.keyDownControll(e.keyCode);
            },
            keyUp: function(e) {
                self.keyUpControll(e.keyCode);
            }                        
        }           
    }, 
    
    initGame: function() {        
        //-- world
        this.world = new World(this.canvas, this.config.stageW, this.config.stageH);
        
        //-- bonuses
        this.initBonuses();
        
        //-- enemies
        this.initEnemies();
        
        //-- Hero
        this.hero = new Hero();          
        this.world.addChild(this.hero.body);
        this.hero.initTales(this.world);
                
        return this;
    },
        
    initBonuses: function() {
        var self = this,
            all = 20,
            good = 10,
            offset = 20;            
        
        for (var i = 0; i < all; i++) {
            if (i < good) {
                this.bonuses.push(new Bonus({
                    x: offset + Math.random()*(self.config.stageW - 2*offset) ,
                    y: Math.random()*self.config.stageH,
                    bonuseType: true,
                    radius: Math.random()*1+2
                }));
            }
            
            else {
                this.bonuses.push(new Bonus({
                    x: offset + Math.random()*(self.config.stageW - 2*offset) ,
                    y: Math.random()*self.config.stageH,
                    bonuseType: false,
                    radius: Math.random()*1+2
                }));
            } 
            
            this.world.addChild(this.bonuses[i].body);
        }
    },
    
    initEnemies: function() {
        var enemiesData = [
            {
                x: 10,
                y: 470,
                speed: 3    
            },
            {   
                x: 10,
                y: 300,
                speed: 2    
            },
            {
                x: 10,
                y: 130,
                speed: 1    
            }            
        ];       
                   
        for (var i=0, max = enemiesData.length; i < max; i++) {
            this.enemies.push(new EnemyCloud({
                x: enemiesData[i].x,
                y: enemiesData[i].y                                
            }));
            
            this.enemies[i].body.speed.default_x = enemiesData[i].speed;
            this.world.addChild(this.enemies[i].body);
            
            //--bonuses
            this.enemies[i].bonuses = this.bonuses;
            this.enemies[i].world = this.world;
        }                        
    },
            
    keyDownControll: function(_keyCode) {
        switch (_keyCode) {
            case 37:
                this.hero.controll.left = true; 
            break;
            
            case 39:
                this.hero.controll.right = true;
            break;
            
            default:
            break;            
        }                        
    },
    
    keyUpControll: function(_keyCode) {
        switch (_keyCode) {
            case 37:
                this.hero.controll.left = false; 
            break;
            
            case 39:
                this.hero.controll.right = false;
            break;
                        
            default:
            break;            
        }                 
    },
        
    startLoop: function() {
        var self = this; 
        
        this.enterFrame = setInterval(function() {
            self.loop();
        }, this.config.fps);
    },
        
    loop: function() {
        var hero = this.hero,
            enemies = this.enemies,
            bonuses = this.bonuses;
             
        this.world.update();
        
        //-- controll hero
        if (hero.controll.left) {            
            hero.speed.angle -= hero.speed.rotation;  
        }
        if (hero.controll.right) {            
            hero.speed.angle += hero.speed.rotation;
        }
                        
        hero.body.speed.x = hero.speed.x*Math.cos(Math.PI*hero.speed.angle/180);
        hero.body.speed.y = hero.speed.y*Math.sin(Math.PI*hero.speed.angle/180);
        
        for (var i = 0, maxI = enemies.length; i<maxI; i++) {
            enemies[i].body.position.x += enemies[i].body.speed.default_x;
            
            if (enemies[i].body.position.x > this.config.stageW - enemies[i].body.config.width) {
                enemies[i].body.speed.default_x = -enemies[i].body.speed.default_x;
            }
            if (enemies[i].body.position.x < 0) {
                enemies[i].body.speed.default_x = -enemies[i].body.speed.default_x;
            }
        }
        
        for (var j = 0, maxJ = bonuses.length; j<maxJ; j++) {
            if (bonuses[j].body.userData.bonuseType) {
                bonuses[j].body.position.y += bonuses[j].speed.y;
                
                if (bonuses[j].body.position.y > this.config.stageH - 5) {
                    bonuses[j].body.position.y = 5;
                }            
            }
        }
                
        hero.enterFrame();                
    }
 }; 
 
 game.load();
 
})();

По пунктам:
Оборачиваем все в анонимную функцию и вызываем ее.

Область видимости в JavaScript определяется функцией. Переменная определенная в функции не будет видима за ее пределами (не будет засорят глобальное пространство имен). Плюс к этому, если вы еще и не определяете переменную, а сразу используете ее, например:

function myFunc() {
     a = 123;
}

она становится глобальной (свойством глобального объекта), это то же самое, что бы вы написали

function myFunc() {
    window.a = 123;
}

что, как понимаете, в какой то момент приведет к конфликту имен.

Сама игра у нас представляется объектом game, то есть здесь можно сказать у нас пространство имен «game». С чего все начинается? Правильно, с предзагрузки изображений. Поэтому единственное, что у нас вызывается это

 game.load();

Смотрим на нее, первое что видим:

 var self = this,
       max = this.images.length;               
  for (var i = max; i--;)  {    

Итак: сохранили ссылку на наш объект (game).
Куда ведет нас this?

This определяется в момент вызова функции. Вспомним те же call, aplly которые явно определяют контекст вызова. Контекст, то есть объект, на который указывает this. В нашем случаи мы видим функцию onload, если мы обратимся к this, он будет ссылаться на HTMLImageElement (изображения, что загрузилось).

Смотрим далее, получили длину массива, и написали цикл виду: for (var i = max; i--;) {
Скорость выполнения цикла

Самый медленный цикл

for in

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

  for (var i = 0; i<this.images.length; i++;)  {  

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

  for (var i = 0, max = this.images.length; i<max; i++;)  {  

получаем длину только один раз.
Ну и в конце (как оказалось, перебор массива с конца быстрее, чем с начала), ок, делаем:

  for (var i = this.images.length; i>0; i--;)  {  

и еще красивей:

  for (var i = this.images.length; i--;)  {  

Смотрим далее, установили обработчик onload, за ним начинаем загружать картинку

this.images[i].img.src = this.images[i].url; 

Сначала слушатель события, потом само событие.

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

По завершении загрузки будет выполнена инициализация управления, каких то игровых данных, запуск цикла

 loadComplete: function() {
        this.initControll().initGame().startLoop();    
 }

красиво и просто .

Перед тем как перейти к данным функциям, по коду видим еще:

    canvas: document.getElementById('canvas'),    
    config: {
        fps: 1000/30,        
        stageW: 900,
        stageH: 600                
    }

Делаем ссылки на html элементы, которые будут часто использоваться.

Операции с DOM являются наиболее медленными. Что будет если я в каждый кадр буду обращаться к DOM и искать нужный мне канвас? А если еще (что мы все любим) с jQuery, $('#canvas') — и это в цикле, добавляется еще и создание jQuery объектов. В общем все понимаем, что это зло.

Выносим все настройки в поле config (здесь все ясно).

Смотрим инициализацию управления с клавиатуры

смотрим код

   initControll: function() {
        var handlers = this.controllHandlers();            
        
        if (document.documentElement.addEventListener) {
            document.body.addEventListener('keydown', handlers.keyDown); 
            document.body.addEventListener('keyup', handlers.keyUp);    
        }
        else {
            document.body.attachEvent('keydown', handlers.keyDown); 
            document.body.attachEvent('keyup', handlers.keyUp);
        }        
                                
        return this;
    },
    
   controllHandlers: function() {        
        var self = this;            
                    
        return {            
            keyDown: function(e) {
                self.keyDownControll(e.keyCode);
            },
            keyUp: function(e) {
                self.keyUpControll(e.keyCode);
            }                        
        }           
    },

   keyDownControll: function(_keyCode) {
        switch (_keyCode) {
            case 37:
                this.hero.controll.left = true; 
            break;            
            case 39:
                this.hero.controll.right = true;
            break;            
            default:
            break;            
        }                        
    },
    
    keyUpControll: function(_keyCode) {
        switch (_keyCode) {
            case 37:
                this.hero.controll.left = false; 
            break;            
            case 39:
                this.hero.controll.right = false;
            break;                        
            default:
            break;            
        }                 
    }

Что то такое написал, много лишнего, нет? Почему бы просто не сделать в index.html файле что то типа

<body onkeydown="eventHandler">

и так далее.
Пишем ненавязчивый JavaScript

Об этом можно говорить очень много, начнем с Викы В двух словах, суть в том, что все должно быть разделено.

Смотрим, первое мы проверили на IE, причем, я мог бы написать

if (navigator.userAgent.indexOf(' MSIE') ! == -1) { 

но это является антишаблоном (если мы уже говорим о правильном коде), если нам например
нужен метод addEventListener,

то только проверка вида

if (document.documentElement.addEventListener) {

может нам однозначно что то сказать.

И все функции в здесь нужны были для того, чтобы «отвязаться» от события.
«Отвязываемся» от событий.

Смотрим строку:

self.keyDownControll(e.keyCode);

Здесь я передаю не ссылку на событие е, а е.keyCode, то есть если вдруг мне нужно будет «в ручную» симулировать действия на события клавиатуры, я просто напишу, например, game.keyDownControll(37);

О физике:

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

initGame: function() {        
        this.world = new World(this.canvas, this.config.stageW, this.config.stageH);
        this.hero = new Hero();          
        this.world.addChild(this.hero.body);
    },

Окей, создали мир, передали канвас, где все будет рисоваться, ширину, высоту мира. Создали объект класса Hero, в мир добавляем его физическую составляющую «body».

  this.world.addChild(this.hero.body);

Смотрим как мы инициализируем body.

  this.body = Body.create('Circle', {
        x: 20,
        y: 20,
        radius: 18,
        gravity: 0.1,
        bgColor: "#eeeeee",        
        fade: {
            x: 0.999,
            y: 0.999
        }        
    });

Body — это основной класс движка, Body.create — фабричный метод , позволяет создавать нам разные (у нас пока их не много) тела. Мы создали круг. И передали объект с настройками. По именам переменным думаю ясно, что за что отвечает.
Передали объект

Всегда передаем объект (если конечно количество передаваемых параметров планируется больше 2-х), тогда вам не придется делать очень популярные (среди начинающих) штуки, типа:

myFunc(null, null, null, null, null, 123, null, 'string_lol');

(так, нигде не упустил порядок параметра?) И это тоже все ясно.

Хорошо, создали какое то тело, добавили в мир, смотрим что далее нужно. Далее нужно (так как в нас динамическая игра) создать таймер, где по тику будет все обновляться. Смотрим часть кода

startLoop: function() {
        var self = this; 
        
        this.enterFrame = setInterval(function() {
            self.loop();
        }, this.config.fps);
    },
        
    loop: function() {
        var hero = this.hero,
              enemies = this.enemies,
              bonuses = this.bonuses;
             
        this.world.update();
      //...

На всю игру у меня всего один постоянный таймер (this.enterFrame).

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

Как видим я вызываю this.world.update(); каждый кадр, это обновляет весь мир выдает результаты столкновений и так далее.
Ясно, что весь перфоманс будет забирать эта функция, так как вызывается каждый кадр, по этому нужно сделать ее максимально «правильной»(на сколько это возможно), как видим, я сразу

       hero = this.hero,
       enemies = this.enemies,
       bonuses = this.bonuses;

переприсваиваю глобальные переменные на локальные.
Переменные и интерпретатор.

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

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

Hero.prototype.initEvents = function() {
    var self = this;
     this.body.addHitListener('bodies', function(e){
        self.hitAction(e.data);            
    });            
    /*this.body.addHitListener('limits', function(e) {   });*/    
 }

То есть на this.body (как мы помним это физический объект, по сути это голова нашей змеи) мы повесили обработчик события. Их может быть два вида: на физ.тела и на выход за рамки мира. В e.data хранится ссылка на объект, с которым столкнулся объект this.body. Круто. Все как мы и хотели.
Сделать свое событие не так то и сложно (а если jQuery использовать, так еще проще будет). Но мы напишем сами. У меня это вынесено в отдельный класс CustomEvent. Его можно использовать в любом другом месте, где понадобится.

смотрим код

  function CustomEvent(_eventName, _target, _handler) {
    this.eventName = _eventName;        
        
    if (_target && _handler) {        
        this.eventListener(_target, _handler);       
    }
  }    
    
  CustomEvent.prototype.eventListener = function(_target, _handler) {                
    if (typeof _target == 'string') {
        this.target = document.getElementById(_target);            
    }
    else {
        this.target = _target;    
    }
                
    this.handler = _handler; 
        
    if (this.target.addEventListener) {
        this.target.addEventListener(this.eventName, this.handler, false);
    }
        
    else if (this.target.attachEvent) {
        this.target.attachEvent(this.eventName, this.handler);
    }
  }
    
  CustomEvent.prototype.eventRemove = function() {        
    if (this.target.removeEventListener) {
        this.target.removeEventListener(this.eventName, this.handler, false);            
    }
        
    else if (this.target.detachEvent) {
        this.target.detachEvent(this.eventName, this.handler);
    }
  }
    
  CustomEvent.prototype.eventDispatch = function(_data) {                 
    if (document.createEvent) {
        var e = document.createEvent('Events');
        e.initEvent(this.eventName, true, false);
    }
        
    else if (document.createEventObject) {
        var e = document.createEventObject();            
    }
        
    else {
        return 
    }
                                    
    e.data = _data;           
        
    if (this.target.dispatchEvent) {
        this.target.dispatchEvent(e);
    }                
        
    else if (this.target.fireEvent) {            
        this.target.fireEvent(this.eventName, e);
    }        
  } 

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

   this.tales[j].moveTo(this.tales[j - 1].position.x, this.tales[j - 1].position.y, 5);

this.tales[j] — это физическое тело, то есть в движке есть еще какой то метод moveTo(_x, _y, _speed). Который как мы видим просто перемещает с текущей точки в точку с заданными координатами. Как сделать? Просто получить по координатам разницу между двумя точками, разделить на количество переходов (_speed) и это мы получим шаг за один переход. Все.

Еще хотим дев-мод. И тут ничего сложного. Так как это ставится для всего движка, логично, что это будет статическим свойством Body (напомню, Body основной класс движка). Смотрим конструктор класса Hero (так как отладочная информация будет для него, то и инициализируем здесь)

   Body.devMode.usePhysLimits = true;    
   Body.devMode.display = true;    
   Body.devMode.bodyId = this.body.id;
   Body.devMode.use = true;

   Body.useGravity = false;

  • Первое — использовать ли границы мира, как видим тела от них отбиваются.
  • Второе — отображать ли рамки физического тела (мы можем накладывать рисунок на тело, или просто заливать его цветом), а это рамки самого геометрического тела.
  • Третье — для какого тела отладка: да, для головы змеи, то есть this.body, и присваиваем по id (да, у каждого тела есть свой идентификатор) .
  • Четвертое — это отображать ли саму консоль.
  • И пятое — не совсем от дев-мода, говорит использовать ли гравитацию (какой же движок без гравитации), но у нас, как видите, игра «вид сверху» поэтому false.
Ну вот.

(кто дочитал аж до сюда, держите печеньки, управления стрелками влево-вправо):
вот что будет: в части один
и это же (dev-mode)
и линк на файлы возможно кому будет интересно посмотреть более тщательно.

P.S. Ну а во второй части попробуем сделать с этого уже что то более похожее на игру.

Автор: bob_lyashenko

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js