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

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

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