バーチャルオフィスを個人開発で作ってみた – リモートワーク最前線

リモートワークが当たり前になって数年が経つ。ツールは揃ってきた。でも「同じ空間にいる感覚」だけはどうしても再現できなかった。

SlackもZoomも便利だ。ただ、「チームが今オフィスにいる」という空気感——誰かが近くで作業している感覚、ちょっと声をかけられる雰囲気——はなかなか出せない。

だったらそれを作ればいい。そう思って、バーチャルオフィスアプリを個人開発した。

作ったもの

ブラウザ上でアバターを動かせる2Dの仮想空間。

  • キーボードでアバターを移動できる
  • 近くにいるメンバーと自動でビデオ/音声通話が始まる(WebRTC)
  • 席の概念があり、着席/離席の状態が共有される
  • チャットルームや会議室ゾーンが設定できる
  • 既製品でいえばGatherのイメージに近い。ただし完全に自分たちのチーム向けにカスタマイズできる。

    技術スタック

    フロントエンド: Vanilla JS + Canvas API
    バックエンド: Node.js + Socket.IO(リアルタイム通信)
    ビデオ通話: WebRTC(Peer-to-Peer)
    シグナリング: Socket.IO経由
    ホスティング: VPS(Ubuntu 22.04)

    ライブラリを最小限にしたのは、Canvas上のゲームループ的な描画とWebRTCの制御を自分で理解しながら実装したかったからだ。

    アーキテクチャ

    [ブラウザA] ←── Socket.IO ──→ [Node.js サーバー] ←── Socket.IO ──→ [ブラウザB]
         ↕ WebRTC (P2P)                                                      ↕
        直接通信(映像・音声)←────────────────────────────────────────────→

    Socket.IOサーバーはシグナリング(WebRTCの接続確立のためのSDP/ICE候補の交換)とアバター位置の同期に使う。実際の映像・音声データはWebRTCのP2P通信で流すので、サーバーの帯域を圧迫しない。

    コア実装

    キャンバスへのアバター描画

    // client/game.js
    const canvas = document.getElementById('office-canvas');
    const ctx = canvas.getContext('2d');
    
    const state = {
      myAvatar: { id: null, x: 200, y: 200, name: '中野', color: '#6366f1' },
      peers: {},  // { socketId: { x, y, name, color } }
    };
    
    function gameLoop() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawFloor();
      drawAvatar(state.myAvatar);
      Object.values(state.peers).forEach(drawAvatar);
      requestAnimationFrame(gameLoop);
    }
    
    function drawAvatar(avatar) {
      // 円形アバター
      ctx.beginPath();
      ctx.arc(avatar.x, avatar.y, 20, 0, Math.PI * 2);
      ctx.fillStyle = avatar.color;
      ctx.fill();
    
      // 名前ラベル
      ctx.fillStyle = '#1f2937';
      ctx.font = '12px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(avatar.name, avatar.x, avatar.y + 35);
    }
    
    requestAnimationFrame(gameLoop);

    キーボード移動

    const SPEED = 3;
    const keys = {};
    
    document.addEventListener('keydown', (e) => { keys[e.key] = true; });
    document.addEventListener('keyup', (e) => { keys[e.key] = false; });
    
    function updatePosition() {
      let moved = false;
      if (keys['ArrowUp'] || keys['w']) { state.myAvatar.y -= SPEED; moved = true; }
      if (keys['ArrowDown'] || keys['s']) { state.myAvatar.y += SPEED; moved = true; }
      if (keys['ArrowLeft'] || keys['a']) { state.myAvatar.x -= SPEED; moved = true; }
      if (keys['ArrowRight'] || keys['d']) { state.myAvatar.x += SPEED; moved = true; }
    
      // 境界チェック
      state.myAvatar.x = Math.max(20, Math.min(canvas.width - 20, state.myAvatar.x));
      state.myAvatar.y = Math.max(20, Math.min(canvas.height - 20, state.myAvatar.y));
    
      if (moved) {
        socket.emit('move', { x: state.myAvatar.x, y: state.myAvatar.y });
      }
    }
    
    setInterval(updatePosition, 16); // ~60fps

    近接判定とWebRTC自動接続

    const PROXIMITY_THRESHOLD = 100; // ピクセル
    
    function checkProximity() {
      Object.entries(state.peers).forEach(([peerId, peer]) => {
        const dx = state.myAvatar.x - peer.x;
        const dy = state.myAvatar.y - peer.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
    
        const wasNear = activeCalls.has(peerId);
        const isNear = distance < PROXIMITY_THRESHOLD;
    
        if (isNear && !wasNear) {
          // 近づいた → WebRTC接続開始
          initiateCall(peerId);
        } else if (!isNear && wasNear) {
          // 離れた → 通話終了
          endCall(peerId);
        }
      });
    }
    
    setInterval(checkProximity, 500);

    WebRTCシグナリング(サーバー側)

    // server/index.js
    const io = require('socket.io')(server);
    const users = {};
    
    io.on('connection', (socket) => {
      socket.on('join', (userData) => {
        users[socket.id] = { ...userData, id: socket.id };
        // 既存ユーザー一覧を新規ユーザーに送信
        socket.emit('users', users);
        // 他ユーザーに新規ユーザーを通知
        socket.broadcast.emit('user-joined', users[socket.id]);
      });
    
      // WebRTCシグナリング中継
      socket.on('offer', ({ to, sdp }) => {
        io.to(to).emit('offer', { from: socket.id, sdp });
      });
    
      socket.on('answer', ({ to, sdp }) => {
        io.to(to).emit('answer', { from: socket.id, sdp });
      });
    
      socket.on('ice-candidate', ({ to, candidate }) => {
        io.to(to).emit('ice-candidate', { from: socket.id, candidate });
      });
    
      socket.on('move', (pos) => {
        users[socket.id] = { ...users[socket.id], ...pos };
        socket.broadcast.emit('user-moved', { id: socket.id, ...pos });
      });
    
      socket.on('disconnect', () => {
        delete users[socket.id];
        io.emit('user-left', socket.id);
      });
    });

    実際に使ってみた感想

    チームで1週間ほど試用した。予想外によかったのは「気軽に声をかけやすくなった」こと。

    Slackだとメッセージを打つ手間があるので、ちょっとした確認事項を聞くのをためらってしまう。バーチャルオフィスだと「近づいて話しかける」だけなので、心理的なハードルが下がった。

    苦労した点

    WebRTCのNAT越えが最大の難関だった。社内ネットワーク同士でも、NAT環境(家のルーターなど)だとP2P接続が失敗する。本番投入するならSTUNサーバー(Googleの公開STUNを利用)とTURNサーバーの設置が必要だ。

    Canvas描画のパフォーマンスも気になった。ユーザーが20人を超えると描画処理が重くなった。アバター画像をImageBitmapでキャッシュするなど最適化が必要だった。

    まとめ

    WebRTC + Socket.IO + Canvas APIの組み合わせで、バーチャルオフィスの核心機能は個人開発でも実装できる。

    本番運用には TURN サーバーやユーザー認証など追加要素が必要だが、チーム内ツールとして使うなら十分なものが作れた。「既製品にない自分たちのワークフロー専用の空間」を作れるのが、個人開発の醍醐味だと改めて実感した。