Aprende a crear juegos en HTML5 Canvas

domingo, 24 de noviembre de 2013

Introducción a Socket.io

Ahora que hemos configurado Node.js, estamos listos para empezar con Socket.io, y agregar interacción multijugador a nuestros juegos.

Para empezar, hay que preparar nuestro proyecto con un archivo "package.json", en el cual especificaremos su nombre y versión:
{
  "name": "my-first-node-game",
  "version": "1.0.0"
}
Ahora que está listo nuestro archivo de paquete, necesitamos instalar Socket.io, lo cual se hace de forma sencilla desde la interfaz de línea de comandos, apuntando esta a la carpeta de nuestro proyecto, con la siguiente línea:
npm install --save socket.io
El comando "--save" agregará la versión que estamos usando de Socket.io a las dependencias del archivo "package.json", lo cual es esencial al subir nuestro código a un servidor, pues a través de este archivo, instalará las dependencias necesarias para que funcione correctamente.

Una vez instalado, haremos uso de él en nuestro servidor. Comencemos declarando una variable para controlar los sockets, e importando la librería "socket.io" con la que crearemos el controlador de sockets, asignándole el servidor que creamos en la ocasión anterior:
    io = require('socket.io').listen(server);
Inmediatamente después, agregaremos un escucha al controlador de sockets, el cual se activará en cuanto un nuevo jugador se conecte:
    io.sockets.on('connection', function (socket) {
        socket.playerId = nSight;
        nSight += 1;
        io.sockets.emit('sight', {id: socket.playerId, x: 0, y: 0});
        console.log(socket.id + ' connected as player' + socket.playerId);
    });
En la primer línea, asignaremos un número de jugador nuevo al recién conectado, y lo almacenaremos en la variable player de socket. Guardar una variable dentro de socket, nos permite conservarla durante la conexión de dicho jugador. La variable nSight la declararemos al comienzo, asignándole valor 0, y en la segunda línea, aumentamos su valor en uno para asignar a cada nuevo jugador un número identificador distinto.

En la tercer línea, haremos una emisión global al evento "sight" que más tarde veremos como manejar en el cliente, al cual enviaremos un objeto con las variables de número de jugador, coordenada x de 0, y coordenada y de 0, para que sea creado.

Por último en la cuarta línea, imprimiremos en la consola la notificación del nuevo jugador conectado, con su identificador y el número de jugador que le corresponde. El identificador socket.id es usado por el socket para manejar sus eventos, y en ningún momento debería ser modificado.

Posteriormente, crearemos un escucha al desconectarse el jugador, momento en el cual haremos una emisión global al evento "sight", con sus coordenadas en nulo, e imprimiremos en la consola la notificación de dicha desconexión:
    socket.on('disconnect', function(){
        io.sockets.emit('sight', socket.playerId, null, null);
        console.log('Player' + socket.playerId + ' disconnected.');
    });
Finalmente, crearemos un evento personalizado "mySight", enviado desde el cliente, el cual contendrá la nueva posición de la mirilla del jugador, y a través del servidor, lo enviaremos a todos los jugadores conectados con la emisión global al evento "sight":
        socket.on('mySight', function (sight) {
            io.sockets.emit('sight', {id: socket.playerId, x: sight.x, y: sight.y});
        });
Ahora que tenemos listo el manejo de sockets en el lado servidor, es tiempo de crear su interacción del lado cliente. Para el presente ejemplo, usaremos el código de Dispara al objetivo.

Iniciamos agregando la siguiente línea en index.html para poder usar socket.io del lado cliente, indicando su ubicación en la ruta de nuestro servidor node.js:
<script src="http://localhost:5000/socket.io/socket.io.js"></script>
Y en el código de nuestro juego, almacenaremos una conexión al servidor en una variable:
    var socket = io.connect('http://localhost:5000');
Cambiaremos las imágenes por una hoja de sprites que contenga varios colores de mirillas para los diferentes jugadores. A continuación dejo la imagen, para que tú efectúes los cambios necesarios en el código actual:


Para almacenar la posición de los distintos jugadores, declararemos un arreglo:
    var players = [];
Para manejar los eventos de los sockets, crearemos una función "enableSockets" que mandaremos a llamar en la función "init", justo después de "enableInputs". Dentro de la función, conectamos a los sockets en una nueva variable que declararemos al inicio, y luego leeremos el evento de socket personalizado "sight", enviado desde el servidor, el cual recibirá un objeto que contiene el número de jugador, y su coordenada x,y, tal como se muestra a continuación:
    function enableSockets() {
        socket.on('sight', function (sight) {
            if (sight.x === null && sight.y === null) {
                players[sight.id] = null;
            } else if (!players[sight.id]) {
                players[sight.id] = new Circle(sight.x, sight.y, 5);
            } else {
                players[sight.id].x = sight.x;
                players[sight.id].y = sight.y;
            }
        });
    }
Como podemos ver, hay tres condiciones en el evento personalizado "sight". En la primera, se comprueba si las coordenadas son nulas, tal como ocurre cuando el jugador se desconecta; si este es el caso, entonces el jugador en el arreglo de jugadores, se asigna a un valor nulo. Si no se cumple la primera condición, en la segunda se comprueba si no existe el jugador en el arreglo; en dado caso, se creará en dicha posición del arreglo un nuevo círculo con la posición x,y recibida. Por último, si no se cumplió ninguna de las condiciones pasadas, simplemente se asigna al jugador las coordenadas que le corresponden.

Cada vez que el cursor se mueva, querremos enviar la nueva coordenada al servidor. Para no estar enviando continuamente nuestra posición si no ha habido movimiento alguno, crearemos una variable "moving" que haremos verdadera en el evento "mousemove", permitiendo enviar dentro de la función "run" nuestra posición de la siguiente forma:
        if (moving) {
            socket.emit('mySight', {x: player.x, y: player.y});
            moving = false;
        }
Recordaremos que el servidor ya se encarga de manejar este evento personalizado. Finalmente, solo resta dibujar todas las mirillas activas dentro de la función "paint":
        for(i = 0, l = players.length; i< l; i += 1) {
            if(players[i] != null) {
                players[i].drawImageArea(ctx, spritesheet, 10 * (i % 4), 20, 10, 10);
            }
        }
Nota que la posición de la hoja de sprites es un módulo de 4. Esto nos permite tener una cantidad virtualmente ilimitada de jugadores sin que cause un error, aunque los colores de las mirillas se repitan.

Con esto concluido, activa el servidor y entra al enlace generado. Para simular un segundo jugador, puedes abrir el mismo sitio usando el modo de navegación privada de tu navegador. De esta forma, podrás ver que dos mirillas entran en interacción dentro del mismo canvas, desde orígenes distinto. ¡Felicidades! Has creado tu primer aplicación de multi-usuario en tiempo real.

Apéndice: Mensajes con Socket.io


Existen diversas formas de emitir datos dentro de Socket.io dependiendo su propósito; a continuación agregamos una breve referencia de estas funciones con su respectivo ejemplo:
socket.emit
Envía una emisión de regreso al mismo socket del que fue llamado.
Ejemplo: socket.emit('message', 'Welcome John!');
socket.broadcast.emit
Envía una emisión a todos los sockets conectados, excepto a aquel desde el cual fue llamada esta función.
Ejemplo: socket.broadcast.emit('message', 'John is here!');
io.sockets.emit
Envía una emisión a todos los sockets conectados, incluido aquel desde el cual fue llamada esta función.
Ejemplo: io.sockets.emit('message', 'Hello everyone!');
io.sockets.sockets(socketId).emit
Envía una emisión directa al socket cuyo id sea igual a "socketId".
Ejemplo: io.sockets.sockets(socketId).emit('message', 'Hello sweetheart <3');

Código final:

server.js:
/*global console, require */
(function () {
    'use strict';
    var serverPort = 5000,
        server = null,
        io = null,
        nSight = 0;

    function MyServer(request, response) {
        response.writeHead(200, {'Content-Type': 'text/html'});
        response.end('<h1>It\'s working!</h1>');
    }
    
    server = require('http').createServer(MyServer);
    server.listen(serverPort, function () {
        console.log('Server is listening on port ' + serverPort);
    });

    io = require('socket.io').listen(server);
    io.sockets.on('connection', function (socket) {
        socket.playerId = nSight;
        nSight += 1;
        io.sockets.emit('sight', {id: socket.playerId, x: 0, y: 0});
        console.log(socket.id + ' connected as player' + socket.playerId);

        socket.on('mySight', function (sight) {
            io.sockets.emit('sight', {id: socket.playerId, x: sight.x, y: sight.y});
        });

        socket.on('disconnect', function () {
            io.sockets.emit('sight', {id: socket.playerId, x: null, y: null});
            console.log('Player' + socket.playerId + ' disconnected.');
        });
    });
}());

game.js:
(function(){
    'use strict';
    window.addEventListener('load',init,false);
    var socket=io.connect('http://localhost:5000');
    var canvas=null,ctx=null;
    var time=0;
    var pause=true;
    var moving=false;
    var lastPress=null;
    var mousex=0,mousey=0;
    var score=0,counter=0;
    var bgColor='#000';
    var players=[];
    var player=new Circle(0,0,5);
    var target=new Circle(100,100,10);
    var spritesheet=new Image();
    spritesheet.src='targetshoot.png';

    function init(){
        canvas=document.getElementById('canvas');
        ctx=canvas.getContext('2d');
        canvas.width=300;
        canvas.height=200;

        enableInputs();
        enableSockets();
        run();
    }

    function random(max){
        return ~~(Math.random()*max);
    }

    function run(){
        requestAnimationFrame(run);
            
        var now=Date.now();
        var deltaTime=now-time;
        if(deltaTime>1000)deltaTime=0;
        time=now;
        
        act(deltaTime);
        paint(ctx);
    }

    function act(deltaTime){
        player.x=mousex;
        player.y=mousey;

        if(player.x<0)
            player.x=0;
        if(player.x>canvas.width)
            player.x=canvas.width;
        if(player.y<0)
            player.y=0;
        if(player.y>canvas.height)
            player.y=canvas.height;
        
        if(moving){
            socket.emit('mySight', player.x, player.y);
            moving=false;
        }

        counter-=deltaTime;
        if(!pause){
            if(lastPress==1){
                bgColor='#333';
                if(player.distance(target)<0){
                    score++;
                    target.x=random(canvas.width/10-1)*10+target.radius;
                    target.y=random(canvas.height/10-1)*10+target.radius;
                }
            }
            else{
                bgColor='#000';
            }

            if(counter<1){
                pause=true;
            }
        }
        else if(lastPress==1&&counter<-1000){
            pause=false;
            counter=10000;
            score=0;
        }
        lastPress=null;
    }

    function paint(ctx){
        ctx.fillStyle=bgColor;
        ctx.fillRect(0,0,canvas.width,canvas.height);
        ctx.strokeStyle='#f00';
        //target.stroke(ctx);
        target.drawImageArea(ctx,spritesheet,0,0,20,20);
        ctx.strokeStyle='#0f0';
        //player.stroke(ctx);
        for(var i=0,l=players.length;i<l;i++){
            if(players[i]!=null)
                players[i].drawImageArea(ctx,spritesheet,10*(i%4),20,10,10);
        }

        ctx.fillStyle='#fff';
        //ctx.fillText('Distance: '+player.distance(target).toFixed(1),0,10);
        ctx.fillText('Score: '+score,0,10);
        if(counter>0)
            ctx.fillText('Time: '+(counter/1000).toFixed(1),250,10);
        else
            ctx.fillText('Time: 0.0',250,10);
        if(pause){
            ctx.fillText('Score: '+score,120,100);
            if(counter<-1000)
                ctx.fillText('CLICK TO START',100,120);
        }
    }
    
    function enableSockets() {
        socket.on('sight', function (sight) {
            if (sight.x === null && sight.y === null) {
                players[sight.id] = null;
            } else if (!players[sight.id]) {
                players[sight.id] = new Circle(sight.x, sight.y, 5);
            } else {
                players[sight.id].x = sight.x;
                players[sight.id].y = sight.y;
            }
        });
    }

    function enableInputs(){
        document.addEventListener('mousemove',function(evt){
            mousex=evt.pageX-canvas.offsetLeft;
            mousey=evt.pageY-canvas.offsetTop;
            moving=true;
        },false);
        canvas.addEventListener('mousedown',function(evt){
            lastPress=evt.which;
        },false);
    }

    function Circle(x,y,radius){
        this.x=(x==null)?0:x;
        this.y=(y==null)?0:y;
        this.radius=(radius==null)?0:radius;
    }

    Circle.prototype.distance=function(circle){
        if(circle!=null){
            var dx=this.x-circle.x;
            var dy=this.y-circle.y;
            return (Math.sqrt(dx*dx+dy*dy)-(this.radius+circle.radius));
        }
    }

    Circle.prototype.stroke=function(ctx){
        ctx.beginPath();
        ctx.arc(this.x,this.y,this.radius,0,Math.PI*2,true);
        ctx.stroke();
    }

    Circle.prototype.drawImageArea=function(ctx,img,sx,sy,sw,sh){
        if(img.width)
            ctx.drawImage(img,sx,sy,sw,sh,this.x-this.radius,this.y-this.radius,this.radius*2,this.radius*2);
        else
            this.stroke(ctx);
    }

    window.requestAnimationFrame=(function(){
        return window.requestAnimationFrame || 
            window.webkitRequestAnimationFrame || 
            window.mozRequestAnimationFrame || 
            function(callback){window.setTimeout(callback,17);};
    })();
})();

26 comentarios:

  1. Muy buenos los tutoriales :D
    Ojala termines este de multijugador que se ve muy interesante sl2

    ResponderEliminar
  2. AAhhh!!! TE AMOOOO En el sentido NO GAY de la palabraaa!!! XD No tienes idea de cuanto me han ayudado tus tutoriales con un proyecto personal, que la verdad, fue la causa por la que estudie programación haha, hacer videojuegos... Y por primera vez en muchos años lo estoy logrando gracias a ti XD... Si alguno dia me hago rico con mis proyectos... te tendre en consideracion =) Sigue asi con estos tutoriales, de verdad, eres un gran apoyo para todos nosotros. Gracias por compartir tus conocimientos de una manera tan desinteresada.
    PD: Cuando pienso que ya he aprendido lo suficiente de tus tutoriales, haces algo como esto y sales con un super tema como este. Eres grande compañero, eres grande.

    ResponderEliminar
  3. Hola Karl, ¿podrias especificar cuales son los requerimientos para instalar socket.io?
    Al realizar los pasos que indicas en el tutorial, en el momento de instalar el socket.io se me muestran la linea de comandos varios errores relacionados con la falta de Visual Studio o Python, ¿podrías indicar cuales son las versiones requeridas de estos para que la instalación de socket.io no de errores?
    Saludos y gracias

    ResponderEliminar
    Respuestas
    1. Temo que no puedo, pues van cambiando conforme se van liberando nuevas versiones, y los requerimientos actuales han cambiado desde que escribí esta entrada hasta el día de hoy, y cambia dependiendo el Sistema Operativo. Pero la misma línea de comandos te indica cuales son las herramientas que requiere para instalarse, tal como ya lo has mencionado. Espero puedas resolver estos conflictos. ¡Suerte!

      Eliminar
  4. Este comentario ha sido eliminado por el autor.

    ResponderEliminar
  5. Este comentario ha sido eliminado por el autor.

    ResponderEliminar
  6. copie el codigo en el servidor con debian, server.js se ejecuta y muestra en la consola el mensaje "Server is listening on port 1337" hasta ahi ok, en el cliente llega hasta en mensaje "It's working!" y para no sale nada mas que puede ser? - otra pregunta no veo por ninguna parte como llama o comunica el server.js con el game.js ?

    ResponderEliminar
    Respuestas
    1. El servidor de Node es diferente en paralelo al servidor donde estaría tu juego. Prueba abrir index.html directo del archivo, no desde la ruta del servidor, y debería funcionar.

      De cualquier forma, si quieres el que servidor Node sea el mismo que el servidor de tu juego, en la siguiente entrega enseñó una forma simple para que Node pueda procesar archivos y servirlos al cliente desde el mismo.

      Eliminar
  7. Hola de nuevo, tengo enlazados 3 archivos en mi index.html:
    "socket.io.js"
    "server.js"
    "game.js"

    En la consola de chrome da fallos ya que server se ejecuta del lado del cliente y los require sale no definidos y este error Failed to load resource: net::ERR_CONNECTION_REFUSED

    lo ejecuto desde midominio.com/index.html

    ResponderEliminar
    Respuestas
    1. Para empezar ¿Estás generando tu propio socket.io.js? Eso podría entrar en conflicto con el generado por Socket.io, que es el que requieres.

      Independiente a ello, el error tiene otro origen. Indica que la conexión está siendo rechazada, la pregunta es "¿Por qué?". ¿Tienes alguna idea por la que esto pueda estar ocurriendo? ¿Donde tienes actualmente tu código cliente?

      Eliminar
    2. si no pongo el socket.io.js dice io is not define. Todo el código este en el servidor midominio.com/index.html

      Eliminar
  8. creo el servidor desde la consola y funciona, luego me voy al dominio al cual accedo desde chrome y me dice que require is not define del archivo server.js y http://xxx.xx.xxx.225:1337/socket.io/?EIO=3&transport=polling&t=L8r9qFU. No 'Access-Control-Allow-Origin' header is present on the requested resource.

    ResponderEliminar
  9. Creo que ahora funciona. Ejecuto node ./server.js y lo quito del index.html. Ahora aparecen mas players pero estaticos en el 0,0

    for(var i=0,l=players.length;i<l;i++){

    if(players[i]!=null) players[i].fill(ctx,cam);

    }

    ResponderEliminar
  10. Solucionado me faltaba socket.emit('mySight', {x: player.x, y: player.y}); ......estoy pájaro total.

    El problema de todo viene porque no se sabe que hacer con server.js yo por lo menos, si iba en el index.html o se ejecutaba en la consola.

    Una cosa Karl cuando quiero crear un server, siempre tiene que ser con node ./server?

    ResponderEliminar
    Respuestas
    1. ¡Que bueno ver que lograste resolver los problemas que tenías!
      Sobre tu pregunta, lo que estás ejecutando es el archivo que contiene las instrucciones al servidor. Si decides cambiar el nombre a este archivo, ese mismo nombre es el que debes poner después de la instrucción "node".

      Eliminar
  11. Queria decir que si siempre hay que hacerlos desde la consola

    ResponderEliminar
    Respuestas
    1. Salvo que tu servidor te ofrezca alguna alternativa interna, esta es la forma común en la que se manejan esta clase de operaciones en los servidores privados generalmente.

      Eliminar
  12. Muy amable Karl!! ahora me falta el crear rooms y asignar los players jajaja

    ResponderEliminar
  13. hola, intento correr el server.io, y me manda un error en:

    io = require('socket.io').listen(server);

    me dice:

    ReferenceError: server is not defined

    que puedo hacer, gracias

    ResponderEliminar
    Respuestas
    1. Parece ser que hay un problema en la forma en que declaraste tu variable server en tu código server.js ¿Es igual al código en este sitio? ¿O lo modificaste?

      Eliminar
  14. Saludos Karl.

    Mi nombre es Donny Bello Cisnero

    Admiro tu dedicación por los lenguajes de programación y te deseo éxitos en todos tus proyectos.

    Estoy iniciando ahora con estos tutoriales (apenas una semana) y no he podido hacer funcionar este programa. Te envío el error que me presenta Node.js al intentar ejecutar server.js.

    Por otra parte no he entendido bien lo de Socket.io cuando lo instalo no me aparece ningun dato en la carpeta.

    C:\Program Files\Brackets\samples\es\Primeros Pasos\JUEGOS>node ./server.js
    module.js:327
    throw err;
    ^

    Error: Cannot find module 'socket.io'
    at Function.Module._resolveFilename (module.js:325:15)
    at Function.Module._load (module.js:276:25)
    at Module.require (module.js:353:17)
    at require (internal/module.js:12:17)
    at C:\Program Files\Brackets\samples\es\Primeros Pasos\JUEGOS\server.js:17:1
    0
    at Object. (C:\Program Files\Brackets\samples\es\Primeros Pasos\J
    UEGOS\server.js:35:2)
    at Module._compile (module.js:409:26)
    at Object.Module._extensions..js (module.js:416:10)
    at Module.load (module.js:343:32)
    at Function.Module._load (module.js:300:12)

    C:\Program Files\Brackets\samples\es\Primeros Pasos\JUEGOS>

    ResponderEliminar
    Respuestas
    1. El error índica que Socket.io no se ha instado correctamente. ¿De casualidad te indica algún error cuando intentas instalarlo con esta línea de comando?

      npm install -g socket.io

      Eliminar
  15. Saludos Kart Tayfer

    Donny Bello Cisnero nuevamente.

    La instalación no presenta ningún problema. Socket.io se instala con todos sus archivos en la carpeta node_modules (dentro de nodejs), incluso en esta se encuentra socket.io.js, pero aun no he podido ejecutar node ./server.io me presenta ese error. Intente cambiando la ubicación de server.js a esta carpeta pero nada...

    ResponderEliminar
    Respuestas
    1. Después de desinstalar y reinstalar Node, descubrí que una actualización reciente ya no enlaza automáticamente los módulos globales. Lo que puedes hacer, es instalar el módulo como local en su lugar (npm install socket.io), o si ya tienes instalado el módulo global, enlazar manualmente a este (npm link socket.io). Espero esto resuelva tu problema.

      Eliminar