Как стать автором
Поиск
Написать публикацию
Обновить

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

Время на прочтение8 мин
Количество просмотров43K

Перед Новым годом на хабре были опубликованы два топика (первый, второй) о создании «Солнечной системы» на 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):

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


Спасибо за внимание.
Теги:
Хабы:
Всего голосов 73: ↑63 и ↓10+53
Комментарии96

Публикации

Ближайшие события