バーチャルオフィスを個人開発で作ってみた – リモートワーク最前線
リモートワークが当たり前になって数年が経つ。ツールは揃ってきた。でも「同じ空間にいる感覚」だけはどうしても再現できなかった。
SlackもZoomも便利だ。ただ、「チームが今オフィスにいる」という空気感——誰かが近くで作業している感覚、ちょっと声をかけられる雰囲気——はなかなか出せない。
だったらそれを作ればいい。そう思って、バーチャルオフィスアプリを個人開発した。
作ったもの
ブラウザ上でアバターを動かせる2Dの仮想空間。
既製品でいえば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 サーバーやユーザー認証など追加要素が必要だが、チーム内ツールとして使うなら十分なものが作れた。「既製品にない自分たちのワークフロー専用の空間」を作れるのが、個人開発の醍醐味だと改めて実感した。