Ещё одна «Солнечная cистема» на HTML5 Canvas

от автора


Перед Новым годом на хабре были опубликованы два топика (первый, второй) о создании «Солнечной системы» на HTML5 Canvas. Бегло прочитав их и изучив результаты профилирования я удивился тому что такая простенькая программа так неэффективно работает. Вооружившись Notepad++ решил проверить всё ли так плохо, написав свою реализацию.

ТЗ остаётся всё тем же. 12 планет, скорость вращений первой — 40 секунд, каждой последующей на 20 секунд дольше. Изначально планеты имеют случайное расположение на своих орбитах. У каждой планеты есть описание, которое отображается при наведении курсора на неё. При клике на планету она останавливается. Если курсор находиться над орбитой — подсветить её. Всё это должно работать в Opera 12+, IE9+, Chrome и FF.

— Я не хочу ничего читать, давай результат!
— Держи: жмяк

Приступим. Создаю новую директорию в публичной папке Dropbox. Стандартно делю проект на каталоги js/css/img, в корне создаю файл main.html, который объединяет набор скриптов в одно целое.

Первые строчки

В наследие от предыдущих реализаций мне достались три картинки: солнце, задний фон и тайлы планет (на самом деле картинок больше). Отлично, теперь нужно как-то загрузить ресурсы в приложение, а за одно и описать структурные объекты. К слову, объектов у меня будет четыре: Point, Orbit, Planet и Tile. По порядку о каждом. Point это служебный объект, имеет два поля, x и y — положение точки на холсте, и несколько методов:.set(), .clone(), .getDis() — установить значения координат, клонировать объект и посчитать расстояние до другой точки. Объект Orbit содержит центр орбиты, её радиус и планету, которая движется по ней. (В идеале орбиты должны описываться формулами, но это в идеале, а у меня все орбиты — окружности). Третий объект — Planet. Планета имеет имя, точку расположения центра на холсте, радиус, скорость перемещения, и угол наклона в градусах. Последний объект Tile хранит изображение и четыре значения описывающие положение рисунка планеты на изображении: координаты верхнего левого угла, высоту и ширину. Тайл обладает методом .draw(x, y), который рисует его на холсте в указанной точке.

Впрочем зачем так много текста, лучше код

// Point.js function Point(x, y) {     this.x;     this.y;     this.set(x, y); // Установить координаты }; Point.prototype = {     set: function(x, y) {         this.x = x || 0;         this.y = y || 0;     },     getDis: function(other) {         return Math.sqrt(Math.pow(other.x - this.x, 2) + Math.pow(other.y - this.y, 2));     },     clone: function() {         return new Point(this.x, this.y);     } }; // Orbit.js function Orbit(center, radius) {     this.center = center;     this.radius = radius;          this.planet = null;     // Сначала у орбиты нет плаенты     this.ctx    = null;     this.mouse  = null; }; // Planet.js function Planet(orbit, radius, time) {     this.pos    = new Point(0, 0);     this.orbit  = orbit;     this.radius = radius;     this.speed  = Math.PI*2 / (time * 1000); // Радиан в миллисекунду     this.angle  = ~~(Math.random() * 360);   // Случайное положение планеты     this.animate = true;     this.name;     this.tile;     this.ctx;     this.orbit.setProperty({'planet': this}); // Сообщить орбите о планете }; // Tile.js function Tile(ctx, img, x, y, w, h) {     this.ctx    = ctx; // Ссылка на канву     this.img    = img; // Ссылка на объект-изображение     this.x      = x;     this.y      = y;     this.width  = w;     this.height = h; }; Tile.prototype = {     draw: function(x, y) {         this.ctx.drawImage(this.img, this.x, this.y, this.width, this.height,             x, y, this.width, this.height);     } };  /**  * @param (object) property Список полей которые нужно добавить объекту  * @param (bool) add        Если у объекта нет полей передаваемых в property, стоит ли их создать  */ Object.prototype.setProperty = function(property, add) {     if (add !== true) add = false;     for (var key in property) {         if (property.hasOwnProperty(key)) {              if (typeof this[key] !== 'undefined' || add) {                 this[key] = property[key];             }         }     }     return this; } 

Что бы не писать для каждого объекта свой сетер, я решил считерить и создать функцию .setProperty() в прототипе Object. Функция добавляет новые поля и меняет значения у старых.

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

Загрузчик

var IM = {                      // Images Manager     store: {},                 // Массив картинок     imagesAdded: 0,             // Сколько добавлено     imagesLoaded: 0,            // Сколько загружено     add: function(url, name) {  // Функция добавления         var self = this;         var img = new Image();         img.onload = function() {             self.imagesLoaded++;             if (self.imagesAdded == self.imagesLoaded) {                 self.afterRun(); // Запуститься, если всё будет загружено             }         }         img.src = url;         this.store[name] = img;         this.imagesAdded++;     },     afterRun: function() {     // Что делать после загрузки         render(new Date() * 1); // Передаю время запуска рендера внутрь     }  }; IM.add('img/sun.png', 'sun');           // Загрузить картинку IM.add('img/planets.png', 'planets');   // И ещё одну 

Планеты

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

var planets = [];   // Массив планет var mouse = {};     // Будущий контроллер мыши var globalCenter = new Point(canvas.width / 2, canvas.height / 2); // Центр системы // Новая орбита с центром globalCenter и радиусом ноль var orbit  = new Orbit(globalCenter.clone(), 0).setProperty({     ctx:   ctx,     // контекст     mouse: mouse    // контроллер мыши, которого ещё нет }, true); // Новая планета с радиусом 50 и скоростью движени 1. А так же с тайлом и именем. var planet = new Planet(orbit, 50, 1).setProperty({     tile: new Tile(this.ctx, this._resources['sun'], 0, 0, 100, 100),     name: 'Sun',     ctx:  ctx }, true); planets.push(planet); // Список имён var names = ['Moon', 'Phobos', 'Deimos', 'Dactyl', 'Linus', 'Io', 'Europa', 'Ganymede',     'Callisto', 'Amalthea', 'Himalia', 'Elara', 'Pasiphae', 'Taurus', 'Sinope', 'Lysithea',     'Carme', 'Ananke', 'Leda', 'Thebe', 'Adrastea', 'Metis', 'Callirrhoe', 'Themisto',      '1975', '2000', 'Megaclite', 'Taygete', 'Chaldene', 'Harpalyke']; var tiles = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // Сдвиги тайлов вправо var time  = 40; shuffle(names); // Перемешиваю массивы shuffle(tiles); for (var i = 0; i < 12; ++i) {     // Первая планета удалена на 90 пикселей от центра, каждая последующая ещё на 26     orbit  = new Orbit(globalCenter.clone(), 90+i*26).setProperty({         ctx:   this.ctx,         mouse: this.mouse     }, true);     planet = new Planet(orbit, 13, time).setProperty({         tile: new Tile(this.ctx, this._resources['planets'], tiles[i]*26, 0, 26, 26),         name: names[i],         ctx:  this.ctx     }, true);     this.planets.push(planet);     time += 20; } 

Отлично, теперь есть планеты, но вот проблема, они ещё не умеют двигаться и не знают как нарисовать себя. Нужно исправить! Создаю функцию render(lastTime), которая принимает время последнего обновления сцены. Ренден запускает методы отрисовки у планет и следит за временем. Далее в прототипе Planet создаю метод .redner(deltaTime), который принимает время, прошедшее с последнего обновления сцены. Функция рассчитывает положение планеты с учётом времени и рисует планету в обновленных координатах. Так же на будущее создаю функцию .showInfo() для отображения информации о планете.

Смотреть

function render(lastTime) {     var curTime = new Date();     requestAnimationFrame(function(){ render(curTime); });          ctx.clearRect(0, 0, canvas.width, canvas.height);     for (var i = 0, il = planets.length; i < il; ++i) {         planets[i].render(curTime - lastTime);     } } Planet.prototype = {     drawBorder: function() { // Обводка планеты         var ctx = this.ctx;         ctx.beginPath();         ctx.arc(this.pos.x, this.pos.y, this.radius * 1.1, 0, Math.PI * 2, true);         ctx.closePath();         ctx.stroke();     },     showInfo: function() {         var x = this.pos.x + this.radius * 0.7; // В какую точку нарисовать подсказку         var y = this.pos.y + this.radius * 0.9; // по ox и oy                      ctx.fillStyle = '#002244';         ctx.fillRect(x, y, 100, 24);         ctx.fillStyle = '#0ff';         ctx.fillText(this.name, x + 50, y + 17);     },     render: function(deltaTime) {         // r(fi) = radius, r - смещение, fi - угол в градусах         this.pos.x = this.orbit.globalCenter.x + this.orbit.radius * Math.cos(this.angle);         this.pos.y = this.orbit.globalCenter.y + this.orbit.radius * Math.sin(this.angle);         this.angle += this.speed * deltaTime; // Увеличиваю угол                      if (typeof this.tile !== 'undefined') { // Если у планеты есть тайл то рисую её             this.tile.draw(this.pos.x - this.radius, this.pos.y - this.radius);         }     } }; 

Запускаю, исправляю ошибки, опять запускаю и ура: планеты кружатся вокруг статичного Солнца.
image
Осталось совсем чуть-чуть: отобразить орбиты, анимацию их выделения и отображение информации о планетах. Нужна информация о мыше, а именно куда она движется, движется ли, нажаты или отжаты ли кнопки на ней. За её поведением над канвасом будет следить MouseController. Имея информацию о координатах указателя можно определить событие hover. Если модуль разности положения курсора и центра орбиты меньше некоторого значения (у меня это 14px), то это и есть hover. Теперь если событие ховер присутствует, то рисуется окружность вокруг центра орбиты линией пожирнее, та часть её, над которой находиться планета удаляется и на этом месте рисуется ещё одна окружность вокруг, но уже вокруг планеты планеты. Если ховера нет, то рисуется цельная окружность худой линией.
С отображением описания планет всё проще. Определяем над какой планетой находится курсор, и этой планеты вызываем .showInfo(). Есть одно но, подсказку на холст нужно рисовать последней, иначе другие объекты могу нарисоватся поверх неё.

Смотреть

Orbit.prototype = {     draw: function() {         var ctx = this.ctx;         var hover = this.mouse && Math.abs(mouse.pos.getDis(this.center) - this.radius) < 13; // Вот он ховер         if (hover) { // Выделенная орбита             ctx.lineWidth = 2;             ctx.strokeStyle = 'rgb(0,192,255)';             ctx.beginPath(); // Орбита             ctx.arc(this.center.x, this.center.y, this.radius, 0, Math.PI * 2, true);             ctx.closePath();             ctx.stroke();                          if (typeof this.planet !== null) { // Если на орбите есть планета                 // Сначала почистю кусок где находится планета                 ctx.clearRect(this.planet.pos.x - this.planet.radius, this.planet.pos.y - this.planet.radius,                     this.planet.radius * 2, this.planet.radius * 2);                 // И на его месте нарисую окружность вокруг планеты                 this.planet.drawBorder();             }         } else { // Обычная орбита             ctx.lineWidth = 1;             ctx.strokeStyle = 'rgba(0,192,255,0.5)';             ctx.beginPath();             ctx.arc(this.center.x, this.center.y, this.radius, 0, Math.PI * 2, true);             ctx.closePath();             ctx.stroke();         }     } function render(lastTime) {     var curTime = new Date();     requestAnimationFrame(function(){         render(curTime); // Заказать на рисование следующий кадр     });          ctx.clearRect(0, 0, canvas.width, canvas.height);   // Очистить всё     var showInfo = -1;                                  // Индекс планеты у которой нужно вывести описание     for (var i = 0, il = planets.length; i < il; ++i) { // Перебор планет         planets[i].orbit.draw();                        // Рисую орбиты         planets[i].render(curTime - lastTime);          // Рисую планеты         if (Math.abs(planets[i].pos.x-mouse.pos.x) < planets[i].radius  // Есть ли ховер над планетой             && Math.abs(planets[i].pos.y-mouse.pos.y) < planets[i].radius) {             showInfo = i; // Если да, то над какой             //if (mouse.pressed) { // Остановить планету если был клик по ней             //    planets[i].animate = planets[i].animate ? false : true;             //}         }     }     if (showInfo > -1) { // Показать информацию о планете, изменить курсор         planets[showInfo].showInfo();         document.body.style.cursor = 'pointer';     } else {         document.body.style.cursor = 'default';     } } }; Остановку по клику я вводить не стал. Позже переложил код в аккуратный объект App.  

Демо | Скачать

Выводы

В теории идея где каждый элемент рисуется на определенное полотно должна обеспечить лучшую производительность, и наверняка это так для объёмных приложений. Но в маленьких приложениях это правило не работает, там где нет сложных анимаций незачем создавать много полотен.
Результаты профилирования на моём ПК (AMD Athlon64 х2 4600+ 2,4GHz, GeForce 210).
Оригинал:

На LibCanvas (похоже что у него ограничение в 60 fps):

Моя реализация:

Спасибо за внимание.

ссылка на оригинал статьи http://habrahabr.ru/post/164899/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *