読者です 読者をやめる 読者になる 読者になる

蟻地獄

Twitterに書ききれない長めの文とか書くよ

Node.js + Socket.IO でアメピグっぽいアバターチャットを作った


こんにちは!

1年ほど前についったライフっていうアバターチャットをWebGL+WebSocketで作ったんですよ。本業のほうではMMOゲームサーバとか作ってる生粋のC++erなもんで、同じような感じで作ればいいやと思ってサーバはC++で書いてたんですね。でもほら、WebSocketってあれじゃないですか。仕様がぐりんぐりん変わったじゃないですか。使ってたC++のWebSocketライブラリが動かなくなっちゃったんですね。
そこでSocket.IOですよ。
C++ではprotobufとかnineとか使って頑張ってたんですけど、Socket.IOだとJavaScriptのオブジェクトそのまま送れるわ非同期コールバック簡単に書けるわハートビートやらコネクション管理やらもいい感じにやってくれるわで、チートレベルでお手軽にゲームサーバが書けちゃうわけですよ。C++なんていらんかったんや!
というわけで、Socket.IOのおかげでFirefoxでも動くようになったついったライフでみんな遊んでね!
ついったライフ | Twitterアカウントで遊べる3D仮想空間


え、サーバのソースが見たい?じゃあまるっと晒しちゃうよ!

//----------------------
// 定数定義
//----------------------
var LISTEN_PORT = 80;
var LOG_LEVEL = 1;

var DB_CON_STRING = 'tcp://user:pass@localhost:5432/twlife';

var ROOM_NAME_LENGTH_LIMIT = 24
var CHAT_LENGTH_LIMIT = 140;
var ROOM_OBJECT_NUM_LIMIT = 140;
var GET_ROOM_NUM_LIMIT = 100;
var ROOM_SAVE_INTERVAL_MSEC = 60000;
var SESSION_TIMEOUT_MSEC = 30000;

var CHARACTER = 'abcdefghijklmnopqrstuvwxyz0123456789'.split('');

var ResultCode = {
  OK: 0,
  NOT_LOGIN: 1,
  NOT_PERMITTED: 2,
  ROOM_NOT_FOUND: 3,
  ACTOR_NOT_FOUND: 4,
  API_COUNT_LIMIT: 5,
  DB_ERROR: 6
};

var ActorType = {
  AVATAR: 1,
  IMAGE: 2,
  USTREAM: 3
};

var EditType = {
  OWNER_ONLY: 1,
  ALL: 2
};

//--------------------
// 変数定義
//--------------------
var sessions = {};
var rooms = {};

var express = require('express')
  , app = express.createServer()
  , io = require('socket.io').listen(app)
  , pg = require('pg')
  , fs = require('fs')
  , Schema = require('protobuf').Schema
  , schema = new Schema(fs.readFileSync('TL.desc'))
  , RoomSaveData = schema['TL.RoomSaveData']
  , log4js = require('log4js');

var logger = log4js.getLogger();

//----------------------
// class Session
//----------------------
var Session = function(sessionId, userId, name, url, description){
  this.sessionId = sessionId;
  this.userId = userId;
  this.name = name;
  this.url = url;
  this.description = description;
  this.actorId = 0;
  this.socket = null;
  this.room = null;
  this.authTime = (new Date()).getTime();
};

//----------------------
// class ActorInfo
//----------------------
var ActorInfo = function(type, userId, name, url, description){
  this.actorId = 0;
  this.type = type;
  this.userId = userId;
  this.name = name;
  this.url = url;
  this.description = description;
  this.pos = {x:0, y:0, z:0};
  this.rot = {x:0, y:0, z:0};
};

//----------------------
// class Room
//----------------------
var Room = function(roomId, roomName, ownerId, ownerName, editType, isPrivate){
  this.roomId = roomId;
  this.roomName = roomName;
  this.ownerId = ownerId;
  this.ownerName = ownerName;
  this.editType = editType;
  this.isPrivate = isPrivate;
  this.actors = {};
  this.sessions = {};
  this.lastId = 0;
  this.saveTime = 0;
  this.saving = false;
  this.modify = false;
};

// ルーム情報更新
Room.prototype.SetRoomInfo = function(roomName, editType, isPrivate){
  this.roomName = roomName;
  this.editType = editType;
  this.isPrivate = isPrivate;

  var data = {
    room_id: this.roomId,
    room_name: this.roomName,
    owner_id: this.ownerId,
    owner_name: this.ownerName,
    edit_type: this.editType,
    is_private: this.isPrivate,
    avatar_num: 0
  };
  for(var i in this.sessions){
    this.sessions[i].socket.emit('RoomInfo', data);
  }

  // DBに保存
  var room = this;
  pg.connect(DB_CON_STRING, function(err, client){
    if(err){
      logger.error(err);
      return;
    }
    client.query(
      'UPDATE room SET room_name=$1,edit_type=$2,is_private=$3,update_at=now() WHERE room_id=$4',
      [room.roomName, room.editType, room.isPrivate, room.roomId], function(err){
      if(err){
        logger.error(err);
      }
    });
  });
}

// アクター追加
Room.prototype.AddActor = function(actor, session){
  ++this.lastId;
  actor.actorId = this.lastId;
  this.actors[actor.actorId] = actor;
  if(session){
    if(session.room){
      session.room.RemoveSession(session);
    }
    session.actorId = actor.actorId;
    session.room = this;
    this.sessions[session.sessionId] = session;
  }
  if(actor.type != ActorType.AVATAR){
    this.modify = true;
  }

  for(var i in this.sessions){
    if(this.sessions[i].actorId == actor.actorId) continue;
    this.sessions[i].socket.emit('ActorAdd', actor);
  }
}

// アクター削除
Room.prototype.RemoveActor = function(id){
  if(id in this.actors){
    if(this.actors[id].type != ActorType.AVATAR){
      this.modify = true;
    }
    delete this.actors[id];
  
    for(var i in this.sessions){
      this.sessions[i].socket.emit('ActorRemove', id);
    }
  }
}

// セッション削除
Room.prototype.RemoveSession = function(session){
  this.RemoveActor(session.actorId);
  delete this.sessions[session.sessionId];
  session.actorId = 0;
  session.room = null;
  
  if(this.IsEmpty()){
    this.SaveData();
  }
}

// アクター移動
Room.prototype.MoveActor = function(id, pos, rot, exceptId){
  if(id in this.actors){
    var actor = this.actors[id];
    if(actor.type != ActorType.AVATAR){
      this.modify = true;
    }
    
    actor.pos = pos;
    actor.rot = rot;

    for(var i in this.sessions){
      if(this.sessions[i].actorId == exceptId) continue;
      this.sessions[i].socket.emit('ActorMove', id, pos, rot);
    }
  }
}

// ルームにログインしている人がいなければtrue
Room.prototype.IsEmpty = function(){
  for(var i in this.sessions){
    return false;
  }
  return true;
}

// データ保存
Room.prototype.SaveData = function(){
  if(!this.modify || this.saving) return;

  logger.info('SaveData: @' + this.roomId);

  // DBに保存
  var room = this;
  pg.connect(DB_CON_STRING, function(err, client){
    if(err){
      logger.error(err);
      return;
    }
    // セーブデータ作成
    var saveData = {actor:[]};
    for(var i in room.actors){
      var actor = room.actors[i];
      if(actor.type == ActorType.AVATAR) continue;
      saveData.actor.push({
        type: actor.type,
        userId: actor.userId,
        name: actor.name,
        url: actor.url,
        description: actor.description,
        posX: actor.pos.x,
        posY: actor.pos.y,
        posZ: actor.pos.z,
        rotX: actor.rot.x,
        rotY: actor.rot.y,
        rotZ: actor.rot.z
      });
    }
    var data = new Buffer(RoomSaveData.serialize(saveData));
    client.query('UPDATE room SET data=$1,update_at=now() WHERE room_id=$2',
      [data.toString('base64'),room.roomId], function(err){
      if(err){
        logger.error(err);
      }
      room.saving = false;
    });
  });

  this.saving = true;
  this.modify = false;
}

//--------------------
// main
//--------------------

io.set('log level', LOG_LEVEL);
app.use(express.bodyParser());
app.listen(LISTEN_PORT);

// 認証
app.post('/auth', function(req, res){
  /* 実際はここで認証やってるけど省略 */
  if(req.body.sid in sessions){
    var session = sessions[req.body.sid];
    if(session.socket){
      session.socket.emit('ForceLogout');
    }
    if(session.room){
      session.room.RemoveSession(session);
    }
  }
  sessions[req.body.sid] = new Session(req.body.sid, req.body.uid, req.body.name, decodeURI(req.body.url), decodeURI(req.body.desc));
  logger.info('Auth: #' + req.body.sid + ' ' + req.body.name);
  res.send('');
});

// ゲームセッション
io.sockets.on('connection', function (socket) {
  var logger = log4js.getLogger();
  var session = null;

  // ログイン判定
  function CheckLogin(fn){
    if(!session || !session.room){
      socket.disconnect();
      return false;
    }
    return true;
  }

  // 切断
  socket.on('disconnect', function(){
    if(session && session.room){
      logger.info('Disconnect: #' + session.sessionId);
      session.authTime = (new Date()).getTime(); // ルーム切り替えの間にセッション削除されないように
      session.room.RemoveSession(session);
    }
  });

  // ログイン
  socket.on('Login', function(sessionId, roomId, fn){
    function Login(){
      var room = rooms[roomId];
      var actor = new ActorInfo(
        ActorType.AVATAR, 
        session.userId, 
        session.name, 
        session.url, 
        session.description);
      actor.pos.z = 10; // 上から落ちてくる演出
      room.AddActor(actor, session);
      fn(ResultCode.OK, actor, {
        room_id: room.roomId,
        room_name: room.roomName,
        owner_id: room.ownerId,
        owner_name: room.ownerName,
        edit_type: room.editType,
        is_private: room.isPrivate,
        avatar_num: 0
      });  
      logger = log4js.getLogger('[' + session.name + '@' + roomId + ']');
      logger.info('Login');

      var  data = [];
      for(var actorId in room.actors){
        if(actorId == session.actorId) continue;
        data.push(room.actors[actorId]);
      }
      socket.emit('ActorAddList', data);
    }

    if(sessionId in sessions){
      session = sessions[sessionId];
      if(session.socket){
        session.socket.emit('ForceLogout');
      }
      session.socket = socket;
      if(roomId in rooms){
        Login();
      }else{
        // DB検索
        pg.connect(DB_CON_STRING, function(err, client){
          if(err){
            logger.error(err);
            fn(ResultCode.DB_ERROR);
            return;
          }
          client.query(
            'SELECT room_id,room_name,owner_id,owner_name,edit_type,is_private,data FROM room WHERE room_id = $1',
            [roomId], function(err, result){
            if(err){
              logger.error(err);
              fn(ResultCode.DB_ERROR);
              return;
            }
            if(result.rows.length == 0){
              fn(ResultCode.ROOM_NOT_FOUND);
              return;
            }
            if(!(roomId in rooms)){
              // ルーム読み込み
              var row = result.rows[0];
              var room = new Room(
                row.room_id, 
                row.room_name, 
                row.owner_id,
                row.owner_name,
                row.edit_type,
                row.is_private);
              var buffer = new Buffer(row.data, 'base64');
              var data = RoomSaveData.parse(buffer);
              if(data.actor){
                for(var i = 0; i < data.actor.length; ++i){
                  var actor = data.actor[i];
                  var actorInfo = new ActorInfo(
                    actor.type,
                    actor.userId,
                    actor.name,
                    actor.url,
                    actor.description);
                  actorInfo.pos = {x:actor.posX, y:actor.posY, z:actor.posZ};
                  actorInfo.rot = {x:actor.rotX, y:actor.rotY, z:actor.rotZ};
                  room.AddActor(actorInfo);
                }
              }
              rooms[room.roomId] = room;
              logger.info('Load: @' + room.roomId);
            }
            Login();
          });
        });
      }
    }else{
      fn(ResultCode.NOT_LOGIN);
    }
  });

  // ログアウト
  socket.on('Logout', function(fn){
    if(!CheckLogin(fn)){
      return;
    }

    logger.info('Logout');
    delete sessions[session.sessionId];
    session.room.RemoveSession(session);
    session = null;
    fn(ResultCode.OK);
  });

  // アバター移動
  socket.on('ActorMove', function(actorId, pos, rot){
    if(!CheckLogin()){
      return;
    }

    if(actorId in session.room.actors){
      var actor = session.room.actors[actorId];
      if(actor.type == ActorType.AVATAR){
        if(actor.userId == session.userId){
          session.room.MoveActor(actorId, pos, rot, session.actorId);
        }
      }else{
        if(session.room.ownerId == session.userId || session.room.editType == EditType.ALL){
          session.room.MoveActor(actorId, pos, rot, session.actorId);
        }
      }
    }
  });

  // チャット
  socket.on('Chat', function(actorId, content){
    if(!CheckLogin()){
      return;
    }

    if(content.length <= CHAT_LENGTH_LIMIT){
      logger.info('Chat: ' + content);
      if(actorId in session.room.actors){
        var actor = session.room.actors[actorId];
        if(actor.userId == session.userId){
          for(var i in session.room.sessions){
            session.room.sessions[i].socket.emit('Chat', actorId, content);
          }
        }
      }
    }
  });

  // アクター追加
  socket.on('AddActor', function(type, url, description, pos, rot, fn){
    if(!CheckLogin(fn)){
      return;
    }

    if(type == ActorType.AVATAR){
      fn(ResultCode.NOT_PERMITTED);
    }else if(session.room.ownerId != session.userId && session.room.editType != EditType.ALL){
      fn(ResultCode.NOT_PERMITTED);
    }else{
      var count = 0;
      for(var i in session.room.actors){
        ++count;
      }
      if(count < ROOM_OBJECT_NUM_LIMIT){
        var actor = new ActorInfo(
          type, 
          session.userId, 
          session.name, 
          url, 
          description);
        actor.pos = pos;
        actor.rot = rot;
        session.room.AddActor(actor);
        logger.info("AddActor: '" + actor.actorId + ' ' + url);
        fn(ResultCode.OK);
      }else{
        fn(ResultCode.NOT_PERMITTED);
      }
    }
  });

  // アクター削除
  socket.on('RemoveActor', function(actorId, fn){
    if(!CheckLogin(fn)){
      return;
    }

    if(actorId in session.room.actors){
      var actor = session.room.actors[actorId];
      logger.info("RemoveActor: '" + actor.actorId);
      if(actor.type == ActorType.AVATAR){
        if(actor.userId == session.userId){
          session.room.RemoveActor(actorId);
          fn(ResultCode.OK);
        }else{
          fn(ResultCode.NOT_PERMITTED);
        }
      }else{
        if(session.room.ownerId == session.userId || session.room.editType == EditType.ALL){
          session.room.RemoveActor(actorId);
          fn(ResultCode.OK);
        }else{
          fn(ResultCode.NOT_PERMITTED);
        }
      }
    }else{
      fn(ResultCode.ACTOR_NOT_FOUND);
    }
  });    

  // ルーム追加
  socket.on('AddRoom', function(roomName, editType, isPrivate, fn){
    if(!CheckLogin(fn)){
      return;
    }
    if(roomName.length > ROOM_NAME_LENGTH_LIMIT){
      fn(ResultCode.NOT_PERMITTED);
      return;
    }

    // DBに保存
    pg.connect(DB_CON_STRING, function(err, client){
      if(err){
        logger.error(err);
        fn(ResultCode.DB_ERROR);
        return;
      }
      // ルームID生成
      var roomId = '';
      for(var i = 0; i < 12; ++i){
        roomId += CHARACTER[Math.floor(Math.random() * CHARACTER.length)];
      }
      // 空のルームデータ生成
      var data = new Buffer(RoomSaveData.serialize({actor:[]}));
      client.query(
        'INSERT INTO room (room_id,room_name,owner_id,owner_name,edit_type,is_private,data,update_at) VALUES ($1,$2,$3,$4,$5,$6,$7,now())',
        [roomId,roomName,session.userId,session.name,editType,isPrivate,data.toString('base64')], function(err){
        if(err){
          logger.error(err);
          fn(ResultCode.DB_ERROR);
          return;
        }
        rooms[roomId] = new Room(
          roomId, 
          roomName, 
          session.userId, 
          session.name, 
          editType, 
          isPrivate);
        logger.info('AddRoom: @' + roomId);
        fn(ResultCode.OK, roomId);
      });
    });
  });

  // ルーム一覧取得
  socket.on('GetRoomList', function(fn){
    if(!CheckLogin(fn)){
      return;
    }

    logger.info('GetRoomList');
    var data = [];
    for(var i in rooms){
      var room = rooms[i];
      if(room.isPrivate) continue;
      var avatarNum = 0;
      for(var sid in room.sessions){
        ++avatarNum;
      }
      data.push({
        room_id: room.roomId,
        room_name: room.roomName,
        owner_id: room.ownerId,
        owner_name: room.ownerName,
        edit_type: room.editType,
        is_private: room.isPrivate,
        avatar_num: avatarNum
      });
      if(data.length >= GET_ROOM_NUM_LIMIT) break;  
    }
    data.sort(function(a,b){return b.avatar_num - a.avatar_num});
    fn(ResultCode.OK, data);
  });

  // ユーザーIDでルーム検索
  socket.on('GetUserRoomList', function(ownerId, fn){
    if(!CheckLogin(fn)){
      return;
    }

    logger.info('GetUserRoomList');
    // DB検索
    pg.connect(DB_CON_STRING, function(err, client){
      if(err){
        logger.error(err);
        fn(ResultCode.DB_ERROR);
        return;
      }
      var query = 'SELECT room_id,room_name,owner_id,owner_name,edit_type,is_private FROM room WHERE owner_id = $1';
      if(ownerId != session.userId){
        query += ' AND is_private = FALSE';
      }
      query += ' ORDER BY update_at DESC LIMIT ' + GET_ROOM_NUM_LIMIT;
      client.query(query, [ownerId], function(err, result){
        if(err){
          logger.error(err);
          fn(ResultCode.DB_ERROR);
          return;
        }
        fn(ResultCode.OK, result.rows);
      });
    });
  });

  // ルーム情報更新
  socket.on('UpdateRoomInfo', function(roomName, editType, isPrivate, fn){
    if(!CheckLogin(fn)){
      return;
    }

    if(session.room.ownerId == session.userId && roomName.length <= ROOM_NAME_LENGTH_LIMIT){
      logger.info('UpdateRoomInfo');
      session.room.SetRoomInfo(roomName, editType, isPrivate);
      fn(ResultCode.OK);
    }else{
      fn(ResultCode.NOT_PERMITTED);
    }
  });
});

// 定期処理
setInterval(function(){
  var now = (new Date()).getTime();

  for(var i in sessions){
    var session = sessions[i];

    // セッションタイムアウト
    if(!session.room && now - session.authTime > SESSION_TIMEOUT_MSEC){
      logger.info('Timeout: #' + session.sessionId);
      delete sessions[i];
    }
  }

  for(var i in rooms){
    var room = rooms[i];

    // 自動セーブ
    if(now - room.saveTime > ROOM_SAVE_INTERVAL_MSEC){
      room.SaveData();
      room.saveTime = now;
    }

    // 誰もいないルームをアンロード
    if(room.IsEmpty() && !room.saving){
      logger.info('Unload: @' + room.roomId);
      delete rooms[i];
    }
  }
}, 1000);