Canvas colaborativo con Node.js, Socket.IO y Windows 8

- 18 minute read

Una de las novedades más interesantes que incorpora IE10 es la implementación de la API de WebSockets definida en la especificación del W3C. Los WebSockets es una tecnología que nos proporciona un sistema de comunicación bidireccional para aplicaciones web y nos permite intercambiar información con un servidor web con una latencia muy baja.

En esta entrada vamos a hacer uso de WebSockets para crear una aplicación para Windows 8 que permita dibujar de forma colaborativa desde varios clientes. Utilizaremos el elemento canvas de HTML5 para dibujar trazos y mediante WebSockets enviaremos la información de los puntos dibujados a nuestro servidor, que se encargará de distribuirlo a todos los clientes conectados.

Dibujando en el canvas

Comenzamos con lo básico: cómo dibujar en un elemento canvas. El código que viene a continuación es uno que ya he utilizado en alguna presentación y me permite demostrar que cualquier código JavaScript funciona en una aplicación para Windows 8. En el código se registra un controlador para los eventos mousemove, mousedown y mouseup y según el evento disparado iniciamos un nuevo trazo mediante el método beginPath o dibujamos una línea con los métodos lineTo y stroke. Si queréis más información sobre estos métodos, podéis consultar la MSDN donde aparecen todos los métodos disponibles del objeto canvas.

var sketch = (function () { use strict;

    var context;
    var isPainting;
    
    var onMouseDown = function (event) {
        isPainting = true;
        context.beginPath();
    };
    
    var onMouseMove = function (event) {
      if (isPainting) {
          drawLine({ x: event.clientX, y: event.clientY });
      }   
    };
    
    var onMouseUp = function (event) {
        isPainting = false;
    };
           
    var drawLine = function (data) {
      context.lineTo(data.x, data.y);
      context.stroke();
    }
    
    return {
        init: function () {
            var canvas = document.getElementsByTagName('canvas')[0];
            context = canvas.getContext('2d');
            
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
                        
            context.strokeStyle = "green";
            context.lineJoin = "round";
            context.lineCap = "round";
            context.lineWidth = 15;
           
            canvas.addEventListener('mousemove', onMouseMove, false); 
            canvas.addEventListener('mousedown', onMouseDown, false);
            canvas.addEventListener('mouseup', onMouseUp, false);
        }
    } })();

Para probar este código solo tenemos que crear una página HTML que tenga la referencia al fichero de script anterior (sketch.js), un elemento canvas y una llamada al método sketch.init en el controlador del evento onload.

<!DOCTYPE html>
<html>
  <head>
    <script src="sketch.js"></script>
</head>
<body onload=”sketch.init()”>
  <canvas></canvas>
</body>
</html>

Servidor WebSockets

Una vez tenemos el pintado de trazos solucionado, el siguiente paso es montar el servidor de WebSockets que reciba los puntos enviados por cada uno de los clientes y los reenvíe al resto. El servidor de WebSockets se puede implementar con multitud de tecnologías. Yo he elegido Node.js con Socket.IO porque me parece de lo más sencillo de implementar y podremos publicarlo posteriormente en Azure. Además, Socket.IO nos proporciona una API que también podemos utilizar desde el cliente con la ventaja que determina el mejor mecanismo de conexión, utilizando alternativas de transporte a WebSockets para los escenarios en los que no sea posible su uso. Si no conocéis Node.js o Socket.IO podéis encontrar en Internet multitud de tutoriales y guías, pero os recomiendo que comencéis por la documentación de las páginas de los productos: Node.js y Socket.IO.

La implementación básica del servidor es la que aparece a continuación, en ella estamos indicando que al recibir un mensaje “draw” se reenvíe la información recibida a todos los clientes conectados. Esto lo conseguimos utilizando el método broadcast.emit.

var io = require('socket.io').listen(1380);

io.sockets.on('connection', function (socket) {
  socket.on('draw', function (data) {
    socket.broadcast.emit('draw', data);
  });
});

Para poner en marcha el servidor tenemos que ejecutar desde la línea de comandos:

node app.js

Canvas multiusuario

El cliente tal y como lo tenemos planteado funciona perfectamente con un único usuario, pero antes de verlo en ejecución podemos intuir que no funcionará si queremos hacerlo multiusuario. El primer problema que nos encontramos es que en el canvas no podemos tener dos trazos iniciados a la vez, es decir, no podemos utilizar los métodos beginPath y stroke en eventos distintos ya que el trazo no continuará en la posición correcta si hay dos llamadas (mi movimiento y el de otro usuario) al método beginPath y no obtendremos el resultado esperado. Por otro lado, el objeto canvas no nos permite tener dos instancias distintas del contexto, así que la única solución es mantener la última posición del cursor de todos los clientes y recuperarla cuando queramos volver a pintar una línea. Vamos a hacer a continuación los cambios necesarios en el código.

Necesitamos crear un array (clients) y un método (setPosition) que llamaremos en los métodos onMouseDown y onMouseMove. Esté método se encargará de guardar la posición del puntero (que se pasa por argumentos) por cada uno de los clientes. Para identificar a cada cliente utilizamos un identificador único que se almacena en la variable selfID. De momento esta variable tiene un valor fijo, después veremos cómo asignarle uno para cada cliente. El otro cambio a realizar está en el método drawLine, al que también le tenemos que pasar el identificador. Este método se encarga ahora de iniciar el trazo y, en el caso de que exista una posición guardada para el ID de cliente, de mover el cursor a esa posición mediante el método moveTo. Ahora mismo no sería necesario pasar el identificador en estos métodos, pero como vamos a utilizarlos también para pintar las posiciones de los otros clientes, me adelanto y me evito la refactorización posterior.

El código siguiente muestra todos los cambios realizados.

var sketch = (function () {
    "use strict";
    var context;
    var isPainting;
    var clients =  [];
    var selfID = "self";
   
    var onMouseDown = function (e) {
        isPainting = true;
        setPosition(selfID, {x: e.clientX, y: e.clientY, action: "down" });
    };
    
    var onMouseMove = function (e) {
      if (isPainting) {
        var data = {x: e.clientX, y: e.clientY, action: "move" };
        drawLine(selfID, data);
        setPosition (selfID, data);
      }
    };
    
    var onMouseUp = function (e) {
        isPainting= false;
    };
           
    var drawLine = function (id, data) {
      context.beginPath();
      
      if (clients [id ]) {
        context.moveTo(clients [id ].lastx, clients [id ].lasty);
      }
      
      context.lineTo(data.x, data.y);
      context.stroke();  
    };
    
    var setPosition = function (id, data) {
      clients [id ].lastx=data.x;
      clients [id ].lasty=data.y;
    };

    return {
        init: function () {
            var canvas = document.getElementsByTagName('canvas') [0 ];
            context = canvas.getContext('2d');
            
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
                        
            selfColor = "green";
            context.lineJoin = "round";
            context.lineCap = "round";
            context.lineWidth = 15;
           
            canvas.addEventListener('mousemove', onMouseMove, false); 
            canvas.addEventListener('mousedown', onMouseDown, false);
            canvas.addEventListener('mouseup', onMouseUp, false);
            });
            
        }
    }
})();

Conectando con el servidor

Vamos a establecer ahora la conexión desde el cliente. Para hacerlo, primero necesitamos añadir la referencia al script socket.io.js en la página HTML.

<script src="http://localhost:1380/socket.io/socket.io.js"></script>

También podemos incluir nosotros el fichero js u obtenerlo del CDN.

<script src="http://cdn.socket.io/stable/socket.io.js"></script>

Además de establecer la conexión con el servidor, necesitamos solventar otros problemas. Como hemos dicho antes, necesitamos tener una lista de identificadores de los clientes para poder mantener la posición del cursor para cada uno. Así que, al establecer la conexión con el servidor, vamos a solicitar que el usuario introduzca un ID (por ejemplo, un nombre de usuario). Este ID se enviará al servidor con el mensaje ‘addclient’ y el servidor al recibirlo enviará otro indicando que la lista de clientes se ha modificado. Así cada cliente tendrá una lista actualizada de todos los identificadores.

socket = io.connect('http://127.0.0.1:1380');
socket.on('connect', function () {
  selfID = prompt("Your ID?");
  socket.emit('addclient', selfID);
  socket.on('updateclients', onUpdateClients);             
  socket.on('draw', onPaint);
});

Es evidente que a este código le falta la comprobación para asegurarnos de que el ID sea único, aunque también podríamos generar un GUID como identificador. De momento, lo dejaremos así.

También tenemos que modificar el método setPosition para enviar al servidor la posición del cursor cada vez que se pulsa un botón del ratón o se mueve mientras se mantiene pulsado.

var setPosition = function (id, data) {
  clients [id ].lastx=data.x;
  clients [id ].lasty=data.y;
  
  if (id != selfID) return; 
  
  socket.emit("draw", data);
};

Veamos las modificación que tenemos que realizar en la parte del servidor para procesar el mensaje “addclient”.

var io = require('socket.io').listen(1380)

var clients = {};

io.sockets.on('connection', function (socket) {
  
 socket.on('addclient', function(id){
  socket.username = id;
  clients [id ] = id;
  io.sockets.emit('updateclients', clients);
  socket.broadcast.emit('updateclients', clients);
 });
  
  socket.on('draw', function (data) {
    socket.broadcast.emit('draw', socket.username, data);
  });

  socket.on('disconnect', function() {
    delete clients [socket.username ];
  });  
});

Vemos que en el servidor también tenemos que mantener una lista de ID de clientes que es la que se envía (a todos los clientes) cuando se recibe un mensaje “addclient”. También establecemos la propiedad “username” del socket para poder eliminarlo de la lista cuando se desconecte y para enviarlo en el mensaje ‘draw’.

A continuación está la implementación de los métodos que responden a los eventos ‘updateclients’ y ‘draw’. En el primero (onUpdateClients) únicamente se inicializa la posición del array de clientes y establecemos el valor a cero de las propiedades lastx y lasty, donde se guardan las coordenadas del cursor.

var onUpdateClients = function(ids) {  
  for (var id in ids)
  {
    if (!clients [id ]) {
      clients [id ] = { lastx: 0, lasty: 0 };
    }
  }
};

Y en el segundo (onPaint) dibujamos la línea llamando a drawLine cuando la acción realizada es un movimiento (cuando el valor de action es move). Si solo se ha pulsado un botón del ratón (action == down), únicamente tenemos que establecer la posición.

var onPaint = function (id, data) { if (data.action === “move”) { drawLine(id, data); } setPosition (id, data); };

Finalizando, de momento…

Con todo lo anterior ya tenemos todo lo necesario para poner en marcha la aplicación. Si ejecutamos la página desde varios navegadores o varias pestañas veremos cómo podemos pintar sobre el lienzo y que cada trazo se pinta en cada uno de los clientes conectados. ¡No está mal! Pero esto es solo el principio, la verdad que pintar en un color o con un solo grosor tiene poca gracia. Además los clientes solo ven el dibujo desde el momento que se conectan, no desde que se conectó el primero. Y… ¿qué pasará cuando tengamos 10 usuarios conectados? ¿Y 100? ¿Y 10000? Todo esto lo veremos en próximas entradas.

Pero… ¿y Windows 8?

Pues sí, he dicho que era una aplicación para Windows 8, pero de momento ni rastro de él. Todo lo que hemos visto hasta ahora funciona en cualquier navegador web que soporte WebSockets. Lo bueno de esto es que si funciona en IE10, significa que todo el código de cliente lo podemos trasladar a una aplicación para Windows 8 (Windows Store App) y funcionará de la misma forma. Sin embargo, tenemos que tener en cuenta ciertos aspectos que sólo nos encontramos en las aplicaciones de Windows 8.

Para comenzar con nuesta aplicación Windows 8, creamos un nuevo proyecto de aplicación Windows Store con JavaScript utilizando la plantilla en blanco. Una vez generado, añadimos el fichero sketch.js en la carpeta js y realizamos la llamada método sketch.init en el controlador del evento onactivated que lo podemos encontrar en el fichero default.js

app.onactivated = function (args) {
    if (args.detail.kind === activation.ActivationKind.launch) {
        if (args.detail.previousExecutionState !== activation.ApplicationExecutionState.terminated) {
            // TODO: This application has been newly launched. Initialize
            // your application here.
        } else {
            // TODO: This application has been reactivated from suspension.
            // Restore application state here.
        }
        args.setPromise(WinJS.UI.processAll());
        sketch.init();
    }
};

Por supuesto, tampoco tenemos que olvidar agregar el elemento canvas y la referencia al script en la página default.htm.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>GangCanvas</title>

    <!-- WinJS references -->
    <link href="//Microsoft.WinJS.1.0/css/ui-light.css" rel="stylesheet" />
    <script src="//Microsoft.WinJS.1.0/js/base.js"></script>
    <script src="//Microsoft.WinJS.1.0/js/ui.js"></script>

    <!-- GangCanvas references -->
    <link href="/css/default.css" rel="stylesheet" />
    <script src="/js/socket.io.js"></script>
    <script src="/js/sketch.js"></script>
    <script src="/js/default.js"></script>

</head>
<body>
    <canvas></canvas>
</body>
</html>

Aquí es donde nos encontramos la primera diferencia respecto a la página que ejecutabamos con IE10. Si agregamos la referencia a http://localhost:1380/socket.io/socket.io.js y ejecutamos la aplicación obtendremos el mensaje siguiente: «No se puede cargar <http://localhost:1380/socket.io/socket.io.js>. Una aplicación no puede cargar contenido web remoto en el contexto local.» Esto es debido a que la página default.html se ejecuta en el contexto local y en este contexto no pueden haber referencias a scripts externas. Puedes consultar la MSDN para conocer más sobre las características y restricciones por contexto. Para salvar esta restricción tenemos que incluir el fichero socket.io.js en el directorio js. y podremos ejecutar la aplicación sin problema.

El último cambio que tenemos que hacer es sustituir la llamada a la función prompt para solicitar el identificador de usuario. En las aplicaciones Metro con JavaScript no se pueden utilizar los métodos alert, confirm ni prompt ya que son método que bloquean la interfaz de usuario. En su lugar lo vamos a sustituir por el nombre del usuario que ha iniciado sesión.

socket.on('connect', function () {
    Windows.System.UserProfile.UserInformation.getDisplayNameAsync().done(function (result) {
        if (result) {
            selfID = result;
        } else {
            selfID = (Math.random() * 10000) >> 0;
        }

        socket.emit('addclient', selfID);
        socket.on('updateclients', onUpdateClients);
        socket.on('draw', onPaint);
    });
});

Si no se pudisese obtener el nombre del usuario se utilizaría un número aleatorio.

Conclusiones

En esta entrada hemos visto cómo dibujar trazos en un elemento canvas y enviar la información a un servidor de websockets creado con Node.js y Socket.IO para permitir dibujar sobre el mismo canvas desde distintos dispositivos. Por último hemos visto como aprovechar todo el código JavaScript para crear una aplicación para la Windows Store realizando unos sencillos cambios debido a varias restricciones del entorno de ejecución.