HTML5 INSIGHT


На HTML5 Camp в рамках открытия мы показывали несколько демок с использованием новых веб-технологий. Там были как сторонние проекты и решения вроде Disney Tron Legacy и Santa’s Media Queries, так и ряд примеров, подготовленных специально под мероприятие.

chat++

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

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

Веб-сокеты

Все началось с того, что нужно было наладить работу веб-сокетов. В нашем случае мы обошлись малой кровью, взяв в качестве основы готовый чат-клиент с html5labs.com. Обратите внимание, что там есть реализация как через нативные веб-сокеты, если они поддерживаются браузером, так и с fallback через Silverlight в противном случае.

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

Несколько важных деталей:

  • Internet Explorer. В IE веб-сокеты поддерживаются нативно, начиная с 10й версии, мы использовали IE10 PP4.
  • Firefox. В Firefox веб-сокеты доступны через вендорный префикс, поэтому вместо объекта WebSocket нужно использовать MozWebSocket.
  • Opera. В Opera веб-сокеты потребовалось включить вручную через настройки: opera:config -> websockets -> enable.

Чтобы разобраться, как все работает, давайте начнем с простого текстового чата:

<form id="myform">
    <input type="text" id="chat" placeholder="type and press enter to chat" />
</form>
<ul id="log"></ul>

На отправку формы вешаем событие, которое будет перехватывать сообщение и отправлять его через веб-сокет:

$("#myform").submit(function (event) {
    event.preventDefault();
                        
    // if we're connected
    // conn -- opened websocket connection
    if (conn.readyState === 1) {
        conn.send(JSON.stringify({
            sender:sender, // sender ID
            type:'chat', 
            chat:$('#chat').val()
        }));
                            
        log.html('<li class="you">' + $('#chat').val().replace(/[<>&]/g, function (m) { return entities[m]; }) + '</li>' + log[0].innerHTML);
        $('#chat').value = '';
    }
});

В коде выше мы проверяем, что соединение готово для использования и отправляем объект в виде json-строки, одновременно добавляя сообщение в лог чата. (Страшгая функция с регулярными выражениями просто экранирует “&”, “<” и “>”.

Теперь давайте разбираться, откуда берется соединение с веб-сокетом. Чтобы начать работать с веб-сокетами, нужно создать соответствующий объект:

 if (conn.readyState === undefined || conn.readyState > 1) {
    // ws -- WebSocket or MozWebSocket
    conn = new ws('ws://yousite.com:port/chat');
    ...
}

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

conn.onopen = function () {
    state.toggleClass('success');
    state.text('Socket open');
};

conn.onmessage = function (event) {
    var message = JSON.parse(event.data);
                       
    if (message.type == 'chat')  {
        // filter own messages
        if (message.sender != sender) {
            log.html('<li class="them">' + message.chat.replace(/[<>&]/g, function (m) { return entities[m]; }) + '</li>' + log[0].innerHTML);
        }
    } else {
        $('#connected').text(message);
    }
};

conn.onclose = function (event) {
    state.toggleClass('fail');
    state.text('Socket closed');
};

Так как новые сообщения рассылаются всем массово, то хорошо бы отфильтровывать собственные, для этого в коде проверяется sender (случайно генерируемый id).

text chat

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

Рисование

Останавливаться на том, как рисовать поверх canvas, пожалуй, будет излишним, так как примеров в сети и так пруд пруди. Остановлюсь только на трех интересных моментах.

Во-первых, довольно неожиданно выяснилось, что Firefox все еще не поддерживает offsetX/offsetY в MouseEvent для определения координат мыши относительно блока, над которым находится курсор. См. также мою статью “T&P. Canvas и Offset”. Это не то, чтобы очень большая проблема, но код, конечно, усложняет, если необходимо сделать его полностью кроссбраузерным.

Во-вторых, тоже неожиданно, оказалось, что Chrome при отрисовке в Canvas не включает Shadow и в целом в разных браузерах используется разное сглаживание и чуть разные алгоритмы.

sun and tree
(кликабельно)

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

Теперь, собственно, к передаче рисования через веб-сокеты. Если в текстовом чате отправка сообщения осуществлялась при отправке формы, то при рисовании разумной представляется отправка непосредственно в процессе отрисовки, поэтому добавляем необходимый код в событие onmousemove, повешенное на canvas:

// canvas - "canvas" object
// canvas.source - jquery object for canvas
// canvas.context - canvas.source[0].getContext("2d")
canvas.source.bind("mousemove", function(e) {
    if (canvas.isPainting) {
        var line = {x1:canvas.lastPoint.x, y1:canvas.lastPoint.y, x2: e.offsetX, y2: e.offsetY};
        drawLine(canvas.part, line);

        if (conn.readyState === 1) {
            conn.send(JSON.stringify({
                sender:sender, 
                type:"canvas", 
                part:canvas.part, 
                line:line
            }));
        }

        canvas.lastPoint = {x: e.offsetX, y: e.offsetY};
    }
});

Обратите внимание на код отправки сообщения – по структуре он ничем не отличается от кода для текста.

Осталось обновить прием сообщений:

conn.onmessage = function (event) {
    var message = JSON.parse(event.data);
                       
    if (message.type == 'chat')  {
        ...
    } else if (message.type == 'canvas') {
        if (message.sender != sender) {
            drawLine(message.part, message.line);
        }
    } else {
        ...
    }
};

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

// c1 and c2 - global vars
function drawLine(part, line) {
    var ctx = (part == 1) ? c1.context : c2.context;

    ctx.beginPath();
    ctx.moveTo(line.x1, line.y1);
    ctx.lineTo(line.x2, line.y2);
                        
    ctx.closePath();
    ctx.stroke();
};

Еще немного лоска добавляется через box-shadow и скрытие второй половинки при рисовании, чтобы не видеть, что рисует другой игрок:

hiding canvas

Готовый пример можно скачать c Я.Диска.