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);
WebGLで作った3DネトゲにjQueryで吹き出しチャットを付けてみた
WebGLでヌルヌル動く3Dネトゲを作ってみた
タイトルは釣りだけど、WebGLで3DネトゲっぽいUIを作ってみたよ!
動かし方
まだWebGLに正式対応したブラウザが無いから、開発版のブラウザをインストールする必要があります。めんどくさい!
詳しくはここらへんを見てね。
Getting a WebGL Implementation - WebGL Public Wiki
ここでは一番簡単に使えるChromiumのインストールのしかたを説明します。
- ダウンロードページ(http://build.chromium.org/buildbot/continuous/win/LATEST/)から最新版をダウンロード
- 以下のようにコマンドラインオプションを付けて起動
コマンドラインオプションを付けないとWebGLが有効にならないので注意してください。
あと、OpenGL2.0に対応したグラフィックボードが必要だから、ノートPCとかじゃできないかもよ。
で、ブラウザの準備ができたら下記のURLにアクセス!
http://meengr.sakura.ne.jp/demo/
作り方
ソースを見てね☆
使ったライブラリは以下の通り。
http://www.kelvinluck.com/assets/jquery/jScrollPane/jScrollPane.html
デフォルトのスクロールバーだと見栄えが悪かったので変えるのに使用。
タイヤ買取ナンバーワン、ホイール買取
アイコン画像を使わせていただきました。ありがとう!
Cometeoのサーバ構成を晒してみる
スケールアウトからスケールアップへの回帰:Kenn's Clairvoyance - CNET Japanに対抗して、
Lingrのクローンというかパクりの超高速無料レンタルチャットCometeoのサーバ構成を晒しちゃうよ!
サーバ: さくらインターネット 専用サーバ エントリープラン
CPU: Atom 1.6GHz
メモリ: 1GB
HDD: 160GB
回線: 10M共有
料金: 月額7,800円
これにMySQLとフルスクラッチのhttpd(C++で書いた!)が乗ってる。
DBのバックアップは毎晩AmazonS3に転送して直近7日分を保管。S3の月額は1ドル未満。
で、サービス状況はというと、
月間PV: 8万くらい
同時接続数: ピーク時で350〜400くらい
総チャットルーム数: 5500くらい
総発言数: 340万くらい
CPU: スカスカ
HDD: スカスカ
メモリ: 余裕
回線: 1ルームに200人とか来た日は辛いけど普段はスカスカ
で、ビジネス的にはどうかというと、
アフィリエイト収入: 月3,000円くらい
全然赤字だよ☆
GoでCometチャットサーバを書いてみた
最近流行りのGoでCometなチャットサーバを書いてみたよ!
いちおう動いてるっぽいけど正しいかは知らないよ!
package main import ( "flag"; "http"; "log"; "container/list"; "strconv"; "bytes"; "json"; "time"; ) type post struct { id uint64; name string; comment string; at *time.Time; } type chatData struct { lastId uint64; posts list.List; waits list.List; } type request interface { execute(data *chatData); } type addRequest struct { name string; comment string; } type getRequest struct { id uint64; resCh chan []post; } func (req addRequest) execute(data *chatData) { data.lastId++; p := post{data.lastId, req.name, req.comment, time.LocalTime()}; data.posts.PushBack(p); if data.posts.Len() > 100 { data.posts.Remove(data.posts.Front()); } for ch := range data.waits.Iter() { res := make([]post, 1); res[0] = p; ch.(chan []post) <- res; } data.waits.Init(); } func (req getRequest) execute(data *chatData) { if req.id == data.lastId { data.waits.PushBack(req.resCh); }else{ i := 0; e := data.posts.Front(); for ; e != nil; i,e = i+1,e.Next() { if e.Value.(post).id > req.id { break; } } res := make([]post, data.posts.Len()-i); for i = 0; e != nil; i,e = i+1,e.Next() { res[i] = e.Value.(post); } req.resCh <- res; } } var requestCh = make(chan request) var addr = flag.String("addr", ":8080", "http service address") func backend() { var data chatData; for { req := <- requestCh; req.execute(&data); } } func add(c *http.Conn, req *http.Request) { requestCh <- addRequest{req.FormValue("name"), req.FormValue("c")}; c.WriteHeader(http.StatusOK); } func get(c *http.Conn, req *http.Request){ id,err := strconv.Atoui64(req.FormValue("id")); if err != nil { c.WriteHeader(http.StatusBadRequest); }else{ q := getRequest{id, make(chan []post)}; requestCh <- q; res := <- q.resCh; c.SetHeader("Content-Type", "application/json; charset=utf-8"); var buf bytes.Buffer; buf.WriteString("{\"post\":["); last := len(res)-1; for i,val := range res { buf.WriteString("{\"id\":" + strconv.Uitoa64(val.id) + ",\"name\":" + json.Quote(val.name) + ",\"c\":" + json.Quote(val.comment) + ",\"at\":"+ json.Quote(val.at.String()) + "}"); if(i != last){ buf.WriteString(","); } } buf.WriteString("]}"); c.Write(buf.Bytes()); } } func main() { flag.Parse(); http.Handle("/", http.FileServer("www", "")); http.Handle("/add", http.HandlerFunc(add)); http.Handle("/get", http.HandlerFunc(get)); go backend(); err := http.ListenAndServe(*addr, nil); if err != nil { log.Exit("ListenAndServe:", err); } }