蟻地獄

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

MikuMikuDanceのファイルを表示するブログパーツを作ってみた

mmd.gl.enchant.jsとか使ってMMDのファイルを表示するGoogleガジェットを作ったよ!

(ブラウザがWebGLに対応している必要があります)

自分のブログへの貼り付け方

ここでコードを取得できます。
PMDファイル・VMDファイルのURLを設定するだけ。テクスチャとかtoon*.bmpはPMDファイルと同じディレクトリに置いて下さい。ファイルが足りないとミクさんが真っ黒になったりするので、そういうときはJavaScriptコンソールで取得に失敗しているファイルがないか見てみてね。

WebGLすごい!

2014/11/28 追記

Googleガジェットがサービス終了したっぽいよ。

Node.jsでAmazon Product Advertising APIを叩く話

前回の続き。

5000円占いAmazon商品情報のクローラはnode.jsで書きました。なぜnode.jsを選択したのかというと、JavaScriptが好きだからです。

AmazonProduct Advertising APIを叩くのには、node-apacというモジュールを使用しました。

使い方は簡単。まずはnpmでインストール。まだstable versionが無いので、@latestを付ける必要があります。

npm install apac@latest

require('apac')でインポートして、OperationHelperのインスタンスを生成。

var OperationHelper = require('apac').OperationHelper;

var opHelper = new OperationHelper({
	endPoint:	'ecs.amazonaws.jp', // APIのエンドポイント。日本の場合はecs.amazonaws.jp
	awsId:		'XXXXXXXXXXXXXXXXXXXX', // 自分のAccess Key ID
	awsSecret:	'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // 自分のSecret Access Key
	assocId:	'xxxxxx-22' // 自分のアソシエイトID
});

例として、100円〜5000円の和書を売り上げ順に表示するのはこんな感じ。

opHelper.execute('ItemSearch'/* オペレーション名 */, {
	// リクエストパラメータ。Product Advertising APIのパラメータ名をそのまま書けばOK
	'SearchIndex': 'Books',
	'BrowseNode': 465610,
	'MinimumPrice': 100,
	'MaximumPrice': 5000,
	'ResponseGroup': 'Small,OfferSummary',
	'Sort': 'salesrank'
}, function(error, results){
	if(error){
		console.error(error);
	}else{
		// resultsはProduct Advertising APIのレスポンスボディをxml2jsしただけのもの
		var items = results.Items.Item;
		for(var i = 0; i < items.length; ++i){
			console.log(
				'ASIN:' + items[i].ASIN
				+ '\tTitle:' + items[i].ItemAttributes.Title
			);
		}
	}
});

前回も書きましたが、Product Advertising APIには1時間2000リクエストまでというかなり厳しい回数制限があります。また、リクエスト同士の間隔が短すぎても503が返されるようなので、あまり激しく叩いてはいけません。愛をもって優しく叩きましょう。

そんなわけで、Product Advertising APIはわりと簡単に使えて楽しいので、みんなもやってみると良いよ!

Amazonガチャシミュレータの作り方

Amazonガチャシミュレータできたよ!
5000円占い

作り方

Amazonガチャの説明によると、アルゴリズムは以下のようになっています。

a)現代用語を含む約85万語の辞書から任意に1用語を抽出
b)その用語をキーワードとしてAmazonで全カテゴリを対象に検索をかける
c)最もキーワードにマッチしている商品を抽出する
d)購入予定額(4600円)と比較し、商品価格が予定額に収まっている場合は注文実行、超えている場合はa)へ戻り再度実行
※購入予定額ははd)が終わり次第計算しなおし、4500円以上の買い物をするまで繰り返す

しかし、この方法はリアルタイムで検索を行うのには向いていません。なぜかというと、Product Advertising APIには1時間2000リクエストまでという制限があるからです。

上記のアルゴリズムの場合、1回ガチャるのにa)〜d)を平均5回繰り返すとして、1時間400ガチャ程度しか捌けません。また、こちらの方が試しているように、最後の1個がなかなか決まらずかなりの回数a)〜d)を繰り返してしまう可能性があります。

では、5000円占いではどうやっているかというと、事前にクローリングして商品データをDBにキャッシュしておき、占うボタンを押したときにはそのキャッシュの中から価格の上限/下限を指定してランダムで選んでいます。

ただし、事前にクローリングするにしても1時間2000回の制限があるので、Amazonの全商品をキャッシュすることはできません。なので、各カテゴリごとに人気順で商品を取ってくるとか、価格帯がいい感じにバラけるようにするなど、ある程度優先順位をつける必要があります。

ちなみに、Product Advertising APIの規約では、

  • 商品データを24時間以上キャッシュしてはいけない
  • キャッシュ更新頻度が1時間に1回以下の場合は価格情報の近くにキャッシュ日時を表示しなければならない
  • 価格情報が変更される可能性がある旨を明記しなければならない

など細かい決まりがあります。ちゃんと守らないとサイト審査で落とされるよ!

クローリングはnode.jsでやってるんだけど、そのあたりは需要があればまた書くかも。Amazon商品のランダム選択の実装はなにげに奥が深くて楽しいので、みんなもやってみるといいよ!

avatar.enchant.jsでアニメーションパターンを追加する方法

こんにちは!

avatar.enchant.jsって便利だよね!絵が描けなくてもこんなゲームがさくっと作れちゃいます。

今あるアニメーションパターンは、

  • stop (立ってる)
  • run (走ってる)
  • attack (攻撃)
  • special (なんか光る攻撃)
  • damage (痛そう)
  • dead (うずくまってる)

の6種類。

これだけでもゲームは作れるけど、どうせならいろんな攻撃パターン作ったり防御とかジャンプとかしたいよね!
実は、avatar.enchant.jsはアニメーションパターンを簡単に追加できるんです!

やりかた

Avatarクラスのオブジェクトは、内部にこんな感じの16パターンの画像を持っています。

(実際の画像はライセンスが微妙なのでイメージ画像)

で、こんな感じでanimPattermにフレーム番号の配列を設定することで、actionに指定して使うことができます。

var avatar = new Avatar('2:2:0:2031:21310:0');
avatar.animPattern['ride'] = [13,4];
avatar.animPattern['attack2'] = [6,5,10,11,10,5,6,6,-1];

avatar.action = 'ride';

サンプルゲーム

こんなん作ってみたよ!
9leap : Error - どこでも遊べる、投稿型ゲームサイト

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空間の任意の位置にDOM要素の吹き出し重ねたりできるかな?ってやってみたら結構簡単にできました。

動かし方

前回の記事と同様に、WebGLが動くブラウザを用意して下さい。
最新のFirefox開発版で動作確認済みです。Chromiumでも動くけど、最新版だとなんかオクルージョンが変だったり動作が怪しいのでFirefoxのほうがいいかも。
で、ブラウザの準備ができたら下記のURLにアクセス!チャットを入力するとねこさんが喋ります。
http://meengr.sakura.ne.jp/demo2/

解説

吹き出しはWebGLのテクスチャではなく、DOM要素を動的に追加してCanvasに絶対位置指定で重ねています。毎フレームねこさんの位置からウィンドウ座標を計算し、適切な位置に移動させています。詳しくはソースを見てね。
でもこれってパフォーマンス的にどうなんだろう?けっこう重い気がする・・・