JavaScript
Canvas
24 December 2012

История одного хабраспора



    Не так давно, просматривая глубокой ночью достаточно интересную заметку о игре на основе JS/Canvas (со своими ошибками и заблуждениями, которые были и у меня, что уж греха таить, понастальгировал всласть), я наткнулся на очередную порцию откровенно холиварных комментариев, после прочтения которых мир за окном стал серым и безрадостным, еда потеряла вкус, а любимый чай оказался несладким. И в тот момент то ли звезды сошлись, то ли срочных и важных багов и фич на вчера стало немного меньше, но я решил ввязаться в спор с достаточно резкими тезисами и вступиться за любимую технологию, которую так откровенно поливали непонятно чем. Так бы и осталось все это на уровне беспредметного перебрасывания пакетов с доводами через забор, если бы в ту же ветку не решил написать RussianSpy, и не об абстрактных попугаях, которых легче переписать в 3D, а о вполне конкретной задаче. И промелькнувшая фраза «Могу прислать ТЗ...» плавно намекнула на то, что вечер обещает быть интересным.


Подготовка

21.12.12, 2:29

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

    Дочитав последний комментарий, уже четко задевавший мое эго, не долго думая, отвечаю «присылайте ТЗ», и буквально через 5-10 минут получаю письмо в почтовом ящике Хабра с запросом контактов. Отвечаю, запрос контактов в Скайпе, приветствие, поехали. Получил архив с графикой. ТЗ оказалось достаточно простым, с одной стороны, но в то же время — интересным в качестве практики. Да и собственная гордость (подлая такая дама, ага) теперь бы мне просто не позволила ударить лицом в грязь.

    Если описывать задачу кратко — нужно было построить генератор гелиоцентрических планетарных систем с небольшим GUI, привязанным к планетам и их орбитам. В 2D. Описывалась она, по большому счету, кратко в самом диалоге, так что возникло еще несколько уточняющих вопросов. Но в общем, все было достаточно прозрачно. В 2:53 я уже во всю разглядывал то, что мне досталось и обдумывал, каким образом все это воплотить в жизнь.

Техзадание
Планеты вращаются вокруг звезды по часовой стрелке. Скорость вращения ближайшей  планеты — 40 сек на оборот. Время на оборот каждой последующей планеты на 20 секунд больше предыдущей. Состав системы случайный. При каждом обновлении планеты занимают случайное место на своей орбите, с которого и начинают вращение и картинка для планеты также случайна.

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

При клике на планету выпадает меню. При наведении мыши на планету и при клике по ней анимация данной планеты останавливается, остальные планеты продолжают свое движение.

При наведении мыши на орбиту — орбита подсвечивается, планета нет.

Курсор мыши при наведении на планету или орбиту меняется на pointer.

Должно работать: Opera 12+, IE9+, актуальные версии Chrome, FF и Safari под огрызок.

21.12.12, 6:13

    В общем и целом реализация задачи к этому времени уже стала более или менее понятна и ясна. Практически первое, что я сделал — продумал, какие шаги мне нужно сделать для успешной отрисовки хотя-бы центральной звезды, и на этом остановился. Создал отдельный Virtualhost в своем dev-окружении, git-репозиторий, закомитил index.html и отправился в кровать. По пути в голове роились мысли на тему анимации, трансформаций с поворотами в offscreen-canvas, проблемы обсчета координат мыши и попутно еще тонна всяких сладостей. Из-за этого я достаточно долго не мог уснуть, но природа взяла свое.

Процесс

22.12.12, 21:00

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

    По опыту работы над FiveGUI понял, что в этом проекте нужно будет что-то подобное. Решил реализовать все через некое подобие AnimationController'а, в котором будет ссылка на основное панно рисования, и динамически собирать картинку в offscreen-canvas'ах его child-объектов. Немного покопавшись в Mozilla Developers Network и на HTML5Rocks, восстановив знания вокруг requestAnimationFrame принялся за базовую структуру проекта и объектов, которые я буду в итоге отрисовывать.

    Первым делом разбил весь проект в корне Vitrualhost'a на js/css/img, сложил всю доставшуюся в наследство графику в соответствующую папку и набросал пример структуры с ссылками на изображения. Во весь рост встала проблема предзагрузки этих самых изображений и основной контроллер обзавелся loader'ом графики. Реализовал я его, в принципе, достаточно топорно — просто сравнивая общее количество ссылок в структуре с counter'ом загруженных изображений, который обновлялся через замыкание в методе load каждого изображения.

Общий концепт на тот момент
//main.js
var PlanetController = new Planets({}); // Контроллер отрисовки

var resources = { //Изображения,
    stars: {
        "1": "/img/stars/1.png"
    }
    // Ресурсов на самом деле больше ^_^
}

for(var a in resources) {// Добавление ресурсов в контроллер
    for(var i in resources[a]) {
        PlanetController.pushResource(a+"-"+i, resources[a][i]);
    }
}

//Planets.js
var Planets = function(){ /* ... */ } // "Конструктор" контроллера

Planets.prototype.loadResources = function(cb) { // Загрузчик изображений
    var self = this;
    self.data.loadedResourcesCount = 0;
    self.data.resourcesCount = Helpers.objLength(self.resources);
    for(var resource in self.resources) {
        if(self.resources.hasOwnProperty(resource)) { // Жуткий костыль, который был обнаружен в самом конце, да. Не делайте так! Никогда!
            var tempImg     = new Image();
            tempImg.onload  = function() {
                self.data.loadedResourcesCount++;
                if(self.data.loadedResourcesCount == self.data.resourcesCount) {
                    self.startTimestamp = new Date();
                    self._flags['_resources_loaded'] = true;
                    cb();
                }
            }
            tempImg.src = self.resources[resource];
            self.resources[resource] = tempImg;
        }
    }
}



    Дальше нужно было каким-то образом после успешной загрузки ресурсов запустить сам проект на отрисовку. Я не стал заморачиваться с событийной моделью (что, в последствии, при доработке может сыграть со мной злую шутку) и решил это сделать через вызов callback'а при совпадении счетчиков, по факту — после загрузки последней картинки. Самое время, как я считал. Сам callback и все его окружение вынес в отдельный файл main.js, callback решил вызывать через call в области видимости window для того, чтобы иметь прямой доступ к своему контроллеру без геммороя (он к тому времени уже лежал именно в window).

    Подкрутил немного опечатки и явные баги, добился вызова callback'а и оповещения в консоли о том, что все изображения успешно загружены и двинулся дальше, в дебри объектов.

    Здесь наступила пора самого интересного. У меня уже появился файл Helpers.js, который предназначался для сервисных функций и всего расширяемого, что я предполагал использовать в проекте. Дальше оставалось только продумать структуру объектов.



    И она, как видно по изображению выше, родилась достаточно быстро. Каждый объект наследовался от основного родителя (в моем случае — объект Element) при помощи нехитрой комбинации через замену прототипа ( знаменитая функция Extend ). Как мне показалось — мое решение было наиболее логичным, исходя из строения любой «солнечной» системы. У нас есть основной родитель — Звезда, которая включает в себя некоторое количество Орбит. У Орбиты же есть детеныш Планета, которая уже включает в себя всплывающие окошки с информацией о себе (Popup — Float) и некоторыми действиями (Popup — Static — Options). Если присмотреться к схеме — у меня есть достаточно глобальная ошибка, которая может стать «фичей» — несколько планет на одной орбите. Я, если честно, не вдавался в подробности, аозможно ли такое, но, я думаю, таким образом можно будет достаточно быстро построить отношение между планетами на одной орбите и в будущем сделать подвид планеты «спутник». Но это в будущем.

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

    Тут я вспомнил свою оптимизацию портированной на JS TinyTower и как я мучался там с быстродействием отрисовки «по-горячему», поэтому, загодя, сделал в каждом объекте отрисовку на внутренний offscreen-canvas персонально для этого объекта, с последующей сборкой снизу вверх. Тут же возникло решение о подмене canvas'a для текущего объекта тем, который вернулся из его детеныша, если он у детеныша больше по размерам, чем текущий canvas родителя. Таким образом в конце цепочки всегда будет самое большое панно с уже отрисованными данными по всем планетам, звездам и орбитам. Получился такой себе многогранный аналог Z-buffer'а для видеокарт. И как показывает мой опыт — отрисовка в память сложных фигур, графики и прочего выполняется в разы быстрее.

    Отрисовка Звезды не заняла много времени. Через размер изображения Звезды и общего canvas'a подсчитал смещение и повесил картинку на свое место.

    С Орбитой было чуток сложнее. Поскольку у каждой орбиты был задан радиус — нужно было готовить под нее персональный canvas и рисвать дугой диаметром Pi*2 радиан. Вспоминая свою боль по конвертации радиан в градусы и обратно решил, что все же лучше перестроить мозги, чем городить огород c конвертом.

    С Планетами было интереснее всего. Мало того, что Планету нужно было позиционировать на Орбите, что и было реализовано чуть выше в объекте Орбиты, так еще и нужно было сделать задел на будущее вращение при вызове отрисовки.

    В общем и целом же рисование всего выглядело достаточно банально: В Объект-родитель добавлялись объекты-детки, после этого родитель пробегал по всем деткам, у каждого последовательно вызывал .draw(), и комбинировал все полученные результаты на свой canvas, после этого возвращал этот canvas своему собственному родителю. Таким образом .draw(), вызванный из контроллера, получал последнюю актуальную версию того, что должно было рисоваться, чистил главное панно и в одно действие отрисовывал новый кадр на экране.

    Все это аккуратно завернуто в кросс-браузерный requestAnimationFrame().

На самом деле это выглядит так
//this.dc - offscreen-canvas для текущего элемента
//this.dctx - offscreen-canvas контекст для рисования

//Planets.js

Planets.prototype.draw = function(timestamp) {
    var self = this;
    self.frame++;

    self.cx.clearRect(0, 0, this.width, this.height); //Чистим кадр
    for(var a in self.elements) {
        if(self.elements.hasOwnProperty(a)) { // Все тот же ужасный костыль
            var drawData = this.elements[a].draw(timestamp);

            self.cx.save();
            self.cx.translate((this.width-drawData.width)/2, (this.height-drawData.height)/2); // переводим начало координат
            self.cx.drawImage(drawData, self.elements[a].getX(), self.elements[a].getY());
            self.cx.restore();
        }
    }

    requestAnimFrame(function(){self.draw(new Date());}); // Запрашиваем новый loop анимации для отрисовки
}

// Star.js

Star.prototype.draw = function(timestamp) {
    var orbitData = null;
    var drawData = null;

    this.dctx.clearRect(0, 0, this.width, this.height);
    // Compose all orbits

    for(var a in this.orbits) {
        if(this.orbits.hasOwnProperty(a)) {
            drawData = this.orbits[a].draw(timestamp);

            // Замена текущего canvas'а большим из деточки, если нужно 

            if(orbitData == null) {
                orbitData = drawData;
                continue;
            }

            if(orbitData.width > drawData.width) {
                orbitData.getContext("2d").drawImage(
                    drawData,
                    (orbitData.width - drawData.width)/2,
                    (orbitData.height - drawData.height)/2
                );
                continue;
            }

            drawData.getContext("2d").drawImage(
                orbitData,
                (drawData.width - orbitData.width)/2,
                (drawData.height - orbitData.height)/2
            );
            orbitData = drawData;
        }
    }

    // Draw Star

    if(orbitData.width > this.width) {
        this.setWidth(orbitData.width);
        this.setHeight(orbitData.height);
    }

    this.dctx.drawImage(orbitData, 0, 0);

    this.dctx.drawImage(this.image,
        (this.dc.width - this.image.width) / 2,
        (this.dc.width - this.image.width) / 2
    );
    return this.dc;
}

//Orbit.js
Orbit.prototype.draw = function(timestamp) {

    var drawData = null;
    this.dctx.clearRect(0, 0, this.width, this.height);
    this.dctx.strokeStyle = "rgba(85, 183, 242, .5)";
    this.dctx.lineWidth = 1;

    this.dctx.beginPath();
    this.dctx.arc(this.width/2, this.height/2, this.radius, 0, Math.PI*2, true);
    this.dctx.closePath();
    this.dctx.stroke();

    for(var a in this.planets) {
        if(this.planets.hasOwnProperty(a)) {
            drawData = this.planets[a].draw(timestamp);
            this.dctx.save();

            this.dctx.translate(this.width/2, this.height/2);
            this.dctx.rotate(this.planets[a].getPosition());
            this.dctx.drawImage(drawData,
                (this.planets[a].width)/2*-1,
                (this.height/2-this.planets[a].height)
            );
            this.dctx.restore();
        }
    }

    this.update();
    return this.dc;
}

//Planet.js
Planet.prototype.draw = function(timestamp) {
    this.dctx.clearRect(0,0,this.dc.width, this.dc.height);
    this.dctx.drawImage(this.image, 0, 0);
    return this.dc;
}



Дальше пришло время анимации и событий…

23.12.12, 02:00

    Мне повезло в том, что анимировать в моем примере нужно было только планеты, а точнее — их перемещение. Еще раз посетовав на нестабильный fps завел в базовом контроллере метку времени старта и начал передавать при каждом обновлении в draw() текущий timestamp начиная с главного родителя. Такой путь позвояет делать анимацию для всех объектов на одно и то же временное смещение, не обращая внимания на длительность выполнения операций. В Техзадании скорость планеты была указана угловой величиной, поэтому и у меня скорость планет начала выражаться как Math.PI*2/. Дополнив объект Планеты методом расчета текущей угловой позиции в Орбите добавил цепочку translate-rotate для отрисовки планеты, при этом угловое значение поворота брал уже непосредственно из объекта Планеты.

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

    Стек событий практически без новшеств я позаимствовал у самого себя, слегка подкорректировав под собственные нужды. В общем приближении — любой mouseevent на объекте body передавался в контроллер, и уже контроллер передавал его деткам, последовательно проверяя, не случилось ли блокировки.

    Первое событие, которое я реализовал — это «подсвечивание» орбиты при наведении. Дополнив Helpers.js математикой расчера расстояния между двумя точками я начал проверять, совпадает ли текущее расстояние от центра звезды до Mouse-pointer'а, и если совпадает с определенной погрешностью — увеличивал толщину линии орбиты и менял ей цвет.

    С Планетами, как обычно, все обстояло намного интереснее. С одной стороны — облегчало задачу то, что обработку события можно было производить только тогда, когда родитель — Орбита — обработал свое событие «hover», с другой стороны — Планета все-таки круглой формы. На помощь по старой доброй памяти пришел метод isPointInPath(). Нехитрой проверкой состояния родителя у Планеты я начал определять, нужно ли мне обрабатывать событие на Планете, отрисовывал тестовую прозрачную линию в виде дуги длинной Math.PI*2 (окружность, ага), оборачивал ее в блок beginPath() — endPath(), транслировал текущие координаты мыши и в родителе проверял, в нужном ли месте находится указатель мыши.

    Примерно на этом же этапе были добавлены всплывающие попапы с сервисной информацией. Раздобыл на просторах интерета списки планет, ников и альянсов. (Планеты на самом деле — названия небесных тел солнечной системы помимо реальных планет, Ники — реальные ники игроков NBA, а вот альянсы уже взял из генератора), добавил их в цикл генерации планет и практически сразу отрисовал первый попап при hover'е. Вышло клево, и я заморочился статическим попапом из ТЗ.

    Собственно, разница между статическим и динамическим попапом не только в том, что они по-разному выглядят, но еще и в том, что статический попап блокирует обработку событий для всего остального панно. Пришлось допиливать небольшой костыль в основной метод обработки событий с проверкой на существование попапа. Ну и в дополнение к уже имевшимся огрехам — ссылка на попап всегда должна присутствовать в контроллере, так как он существует выше всего в принципе (отрисовывается даже поверх звезды), но физически принадлежит планете. Вот такой диссо��анс, с которым я промучался достаточно долго.

    Добавление «опций» в статический попап, обработка событий мыши при клике на Планету и опцию была выполнена практически в том же стиле, что и hover для остальных объектов, разве что добавились обработчики событий в виде callback'ов в каждой опции.

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

Заключение

23.12.12, 21:16

    Отладив последние скользские моменты, явно выбивающися из ТЗ, добавив pointer и причесав все, что осталось, выбросил все на свой хостинг, попросил друзей посмотреть в Ослике, после их подтверждения, что все работает, кликается и вращается — отправил ссылку RussianSpy. Примерно через 10 минут поступила первая реакция, достаточно прохладная, как мне показалось, но тогда я оказался не прав, и мой оппонент скорее проверял, как все это сделано и почему же оно не тормозит и не падает. После 15 минут обсуждений технических моментов на душе стало тепло и хорошо, миру вернулись былые краски и райские птицы запели за окном. Александр увидел, что «нетормозящий и работающий» Canvas возможен и, более того, прямо перед его глазами. Конечно же были замечания по поводу «академичности» этого примера, был баг с ивентами в опере, но в общем и целом я смог добиться своего и показать, насколько хорошо это работает.
Всегда приятно слышать такое в свой адрес
[23.12.12, 21:40:28] RussianSpy: В общем ты меня впечатлил.

[23.12.12, 21:48:57] RussianSpy: Знаешь — тем не менее тебе удалось продемонстрировать нетормозящее решение этой задачи
[23.12.12, 21:49:18] RussianSpy: А значит ты в тему канваса копнул достаточно глубоко.

Еще бы, думал я в этот момент. Уж с канвасом-то я найду общий язык.


    Обсудив некоторые моменты для этой статьи мы еще около часа обсуждали то, что я показал и в общем то, что у меня было по Canvas'у. Продемонстрировал FiveGUI, зарезанный порт TinyTower на JS, чем Сашу сильно заинтересовал и мы договорились не терять друг друга из виду.

    После этого можно было отправляться отдыхать довольным.

Выводы


    Какие выводы я для себя из этого вынес? Много вcякого.
  • Прежде всего, ч��о в суматохе ежедневных тасков не забыл основ программирования графики на Canvas'e и еще что-то умею.
  • Canvas — замечателньый инструмент, и при должном рвении и умении ничуть не хуже Flash по возможностям
  • Доказательство в стиле Хакатона — просто офигенный буст настроения!

И напоследок...


Исходники на Github
Рабочее Demo

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

+166
62.5k 272
Comments 136