【JavaScript 応用講座】:[WebRTC/P2P] ZIPアセットをWebRTCで双方向送受信

1. はじめに

P2P simple-peer を活用した、各ユーザーの所有しているアセットデータをP2Pネットワークを活用して接続し、走行方向で受信できるサービスがようやく形になり完成しました。

現在は、まだ試験段階とはいえ、P2Pネットワークに接続したユーザー一覧表示から、各ユーザーをクリックした際に所有しているアセットデータの表示、 更には、各アセットデータをクリックした際に、データのdownloadをするところまで出来ています。

  1. P2Pネットワークへ接続
  2. 接続ユーザーリストを表示
  3. 接続ユーザーをクリックで所有アセットリストを右ウインドウに表示
  4. アセットリストをクリックでアセットデータをダウンロード
  5. ダウンロード後、indexedDBへアセットデータを保存
  6. アセット管理画面からアセットの変更でダウンロードしたデータでゲームプレイ

ここまで可能になってます。

今後、UIのレイアウト + 調整と、機能面の修正をしたら公開のめどが立ってくると思います。

以下は、昨日今日と、実装した内容をAIに記事化を依頼した内容になります。

バイブコーディングは基本しない方針で、コードは極力読んで理解した上で実装して、AIの提案されたコードをも可変して、修正も行ったり、逆にリファクタリングをして貰ったりと、共同作業でコーディングしていくスタイルを続けています。

なぜ「P2Pでアセット送受信」なのか?

ゲーム開発やコンテンツ制作において、画像・音声・動画などのアセットファイルを扱うことは日常茶飯事です。しかし、それらの配布や共有には意外と手間がかかります。

  • Webサーバにアップロードするにはホスティング環境が必要
  • 共有リンクを生成するにはクラウドサービスへの依存が発生
  • 同期やバージョン管理にも工夫が必要

こうした課題に対して「ブラウザだけでアセットをP2P共有できたら?」という発想が出発点でした。

送る側も、受け取る側も、Webページを開いて接続するだけ。 チャットのように軽い感覚で、画像や音声、動画をZIPファイルとして一括送受信。 しかも、その内容は自動的に解凍されてIndexedDBへ保存される。そんな世界観を目指しました。


P2Pの利点(中央サーバ不要/分散/リアルタイム)

この仕組みは、WebRTC(今回は simple-peer ライブラリ)を使ったP2P通信で構築されています。

P2Pの大きな特徴は以下のとおりです:

  • 中央サーバを通さずにユーザー同士が直接つながる
  • ファイル転送がリアルタイムで高速
  • クラウドサービスに依存せず、運用コストも最小限
  • シグナリングだけ用意すれば、通信は自己完結

ローカルネットワーク内でも使えるため、教育・ワークショップ・社内利用にも適しています。


ゲームアセットの配布・共有手段としての可能性

今回の実装は、特にカードゲームやビジュアルノベル形式のゲームに適した形式になっています。

  • アセットは ZIP にまとめて送信
  • 中身は画像 (.webp)、音声 (.mp3, .ogg)、動画 (.mp4) など
  • 解凍後は IndexedDB に {ファイル名: Blob} の形式で格納

これにより、たとえば:

  • ゲームのアセットセットを「ユーザー同士で交換」できる
  • 自作のキャラクターパックをP2Pで頒布できる
  • サーバ側に一切データを保持せず、安全かつ軽量なアセット流通を実現できる

今後、アセットにバージョン情報やチェックサムを加えることで、より堅牢で便利な仕組みへ進化させることも可能です。


2. 技術構成と全体像

このプロジェクトでは、サーバーを介さずにゲームアセット(画像や音声など)をZIP形式で送受信するP2P通信システムを構築しています。フロントエンド技術だけで完結するため、インストールやバックエンド不要で、軽量かつリアルタイムなアセット共有が可能になります。

✅ 使用ライブラリと技術スタック

  • simple-peer WebRTCベースのP2P通信ライブラリ。複雑なシグナリング処理やDataChannelのハンドリングを簡潔にしてくれる。
  • jszip JavaScriptでZIPファイルを展開・生成するためのライブラリ。Blobとの相性も良く、IndexedDBにそのまま格納可能。
  • IndexedDB API クライアント側の非同期ストレージ。ゲームアセットをローカルに永続保存し、再ダウンロード不要に。

🔗 通信構成:P2P接続と主従関係

simple-peer を用いたP2P接続では、initiator: true を設定した側が 接続の発起者(ホスト) になります。一方、initiator: false応答者(ゲスト)。この関係により、シグナリング情報(signalイベント)を交換して、直接通信を確立します。

主な流れは以下の通り:

  1. Peer オブジェクトを生成(initiator指定あり/なし)
  2. signalイベントで相手に自分の接続情報を送信
  3. 双方で signal を受信して peer.signal(data) により接続成立
  4. data チャネルを利用してメッセージやバイナリデータ(ZIPなど)を送受信

📦 チャンク転送+ZIP展開+DB保存の流れ

DataChannel上でファイルをやり取りするため、一度に大きなファイルを送信せず、64KB単位のチャンク に分割して逐次送信しています。

┌────────────┐         ┌──────────────┐         ┌────────────┐
│ ZIPファイル │ ─→ ①チャンク送信 ─→ │ DataChannel │ ─→ ②結合+Blob化 │
└────────────┘         └──────────────┘         └────────────┘
                                                ③ ZIP展開(JSZip)
                                                ④ IndexedDBへ保存
  • ✅ チャンク送信:FileReader を用いてArrayBufferを順に読み出し送信
  • ✅ 終了通知:__END__ を受信したら結合・Blob化
  • ✅ 展開処理:JSZip.loadAsync(blob) でZIP内のファイルを展開
  • ✅ 保存処理:saveToStore()IndexedDB に格納(キー:アセットタイトル)

3. 実装ステップ

この章では、実際にどのようにしてP2P通信を初期化し、ZIPファイルを送受信し、IndexedDBへ保存するかをステップごとに解説します。前章の構成をもとに、実装上の要点と工夫を紹介していきます。


3.1. P2P接続の初期化

P2P通信は simple-peer ライブラリを用いて初期化します。

🧑‍🤝‍🧑 接続の流れ:

  1. 各クライアントが Peer インスタンスを作成。
  2. initiator(発起者)と responder(応答者)を判定。
  3. peer.on("signal") でシグナリングデータ(SDPやICE候補)を取得。
  4. 相手にこの signal 情報を送信し、peer.signal(data) で反映。
  5. 双方の signal 情報が交換されることで、WebRTCのDataChannelが開通。
peer.on("signal", (data) => {
  sendSignalToRemotePeer(JSON.stringify({ type: "signal", data }));
});

peer.signal(remoteSignalData);

3.2. ZIPファイルの送信処理

ファイルの送信は、一括送信ではなく チャンク単位(例:64KB) で行います。

🔄 送信の流れ:

  1. ZIPファイルを FileReader で読み取り、一定サイズで分割。
  2. peer.send() で順次送信。
  3. 最後に __END__ マーカーを送って終了を通知。
function sendFileInChunks(file, peer) {
  const reader = new FileReader();
  let offset = 0;
  const chunkSize = 64 * 1024;

  reader.onload = () => {
    peer.send(reader.result); // ArrayBuffer
    offset += chunkSize;
    if (offset < file.size) {
      readSlice();
    } else {
      peer.send("__END__");
    }
  };

  function readSlice() {
    const slice = file.slice(offset, offset + chunkSize);
    reader.readAsArrayBuffer(slice);
  }

  readSlice();
}

3.3. ZIPファイルの受信処理

受信側は peer.on("data") イベントを使ってチャンクを順次受信・蓄積していきます。

📥 受信の流れ:

  1. Uint8Arrayで受信チャンクをバッファに格納。
  2. __END__ を受け取ったら、Blobとして結合。
  3. jszip.loadAsync(blob) によってZIPを展開。
  4. ファイルごとにオブジェクトとして整形。
let chunks = [];

peer.on("data", async (data) => {
  const decoded = new TextDecoder().decode(data);
  if (decoded === "__END__") {
    const blob = new Blob(chunks);
    const zip = await JSZip.loadAsync(blob);
    const extracted = {};
    for (const [name, file] of Object.entries(zip.files)) {
      extracted[name] = await file.async("blob");
    }
    await saveToStore("CardPieces", title, extracted);
    chunks = [];
  } else {
    chunks.push(new Uint8Array(data));
  }
});

3.4. IndexedDBへ保存

受信したファイルは ZIP展開後、Blob辞書として IndexedDB へ保存されます。

💾 保存処理の流れ:

  • saveToStore(storeName, key, value) を通じて、put() で保存。
  • ストア名 "CardPieces" に、アセット名(例:“ごちうさアセット”)をキーとして保存。
  • 値は {"1.webp": Blob, "2.webp": Blob, ...} のようなオブジェクト。
export async function saveToStore(store, key, value) {
  const db = await initDB(); // IndexedDBの初期化
  return db.put(DB_CONFIG.stores[store], value, key);
}

これにより、同一アセット名の重複を避け、永続的にローカル保存されたファイル群として再利用できます。

お待たせしました。以下が第4章「デバッグと工夫(ブラウザ間の検証など)」の草案です。


4. デバッグと工夫(ブラウザ間の検証など)

P2P通信やZIPアセット処理は一見スマートに見えますが、実際の開発では多くの落とし穴やブラウザ差異に直面します。この章では、それらをどう乗り越えたか、試行錯誤と工夫の記録を共有します。


4.1. Chrome ↔ Firefox の互換性確認

P2P(WebRTC)やIndexedDBはどちらのブラウザにも実装されていますが、挙動や警告ログには違いがあります。

🔍 発見された違い:

  • Firefox:一部のモジュールインポートに対して「not a module」警告が表示される。

    • type="module" 記述の微差、拡張子の有無、ローカル環境でのCORS制約などに注意。
  • Chrome:IndexedDBの構造がDevToolsから明確に見えるが、Firefoxでは「オブジェクトの中身」の表示にややクセあり。

  • ファイル受信後のBlob URL の表示がブラウザによって若干異なる。


4.2. サムネイル通信の工夫

通信コストを減らすため、初期段階では ZIPファイルそのものではなく「サムネイル画像だけ」を交換するようにしました。

📦 具体的な流れ:

  1. 各クライアントが自分のアセット一覧から titlethumbnail 情報を抜き出す。
  2. サムネイル画像はBase64化してDataChannel経由で送信。
  3. 受信側は p2p-remote-assets ウィンドウ内に表示。
  4. 画像をクリックするとそのアセットの本体(ZIP)を要求する。

こうすることで、最初の接続時は軽量に、必要なものだけを後から要求できるUXが実現できました。


4.3. __END__ マーカーの導入と意味

ファイルをチャンクで送る方式は手軽ですが、「最後のチャンク」を明示しないと永遠に待ち続けてしまいます。

そのため、以下のような終了マーカーを導入しました。

peer.send("__END__");

受信側は TextDecoder"__END__" を検知して受信を終了し、Blob化・展開処理に移ります。


4.4. DB保存のキー管理

ZIPファイルを受信した際に、そのファイルが どのタイトルに属していたのか? を識別する必要があります。

💡 対応方法:

  • request-zip 時に title 情報を添えて送信。
  • 受信側はそれをグローバル変数 receivingZipTitle に一時保持。
  • __END__ 処理時にこの変数を用いてDB保存キーとする。
await saveToStore("CardPieces", receivingZipTitle, extracted);

一時的な記憶ですが、これだけで整合性が取れるようになりました。


4.5. ヒューマンエラーとの戦い

複雑な通信フローの中で、もっともやっかいだったのは「人間の勘違い」でした。

  • サムネイルをクリックしても何も起こらない? → 実は相手側に確認ダイアログが出ていた
  • ZIPが保存されてない? → nullキーでDBに保存していた。
  • 表示されてない? → DOMが更新されてないだけで受信は完了していた

デバッグの9割は「自分が今どちらのウィンドウを見ているのか」という認識のズレとの戦いでした。


5. 応用と未来:ゲームアセット共有のこれから

今回のP2P通信によるZIPアセットの送受信は、あくまで「最低限のデータ共有」の仕組みにすぎません。しかし、ここから派生する応用や可能性は、想像以上に広がっています。


5.1. ダウンロード不要の「その場共有」

WebRTCの利点は、サーバーを介さずにリアルタイムでファイルをやり取りできる点にあります。これにより、次のような体験が可能になります。

  • 🎮 その場でゲームを開始できる:「あのキャラデータちょうだい」→受信して即反映。
  • 📁 インストール不要な軽量アセット交換:ZIPにしておけば構造も維持。
  • 👥 オフラインイベントやLAN環境でも使える:ネットのない環境でも動作可能。

5.2. バージョン管理と差分通信への応用

現時点ではZIP全体を送信していますが、今後の拡張として「差分だけを送る」仕組みも考えられます。

  • SHA256によるファイルの整合性検証
  • manifest.jsonの比較による差分検出
  • 新規ファイルや更新ファイルのみを送信

これは「アセット更新」の分散配信としても利用できます。


5.3. 信頼性・セキュリティの課題

ファイルを直接やり取りする以上、送信者を信用するしかないという構造的課題もあります。

  • ❗ 意図しないファイルを送られた場合
  • ❗ スクリプトやバイナリが含まれていた場合

今後は、次のような対応が必要になるでしょう。

  • ユーザー確認ダイアログの徹底(すでに一部実装済み)
  • ファイルの中身検査やプレビュー機能
  • 信頼済みユーザーのホワイトリスト化

5.4. データの可視化とUXの強化

現状は最低限のUIで構築されていますが、より実用的にするためのUI強化も今後の課題です。

  • サムネイルのカテゴリ分類(BGM/SE/Voiceなど)
  • 詳細プレビュー(曲名・長さ・作成者など)
  • ドラッグ&ドロップ対応
  • モバイル対応UI

単なる技術デモから「誰でも使える共有ツール」へと進化させたいところです。


5.5. 非中央集権的なゲーム文化へ

P2Pによるアセット共有は、単なる技術的選択肢ではなく「中央集権からの解放」という思想的意義もあります。

  • サーバー費不要
  • 運営が閉じても共有は続く
  • 世界中のプレイヤーがつながれる

かつてのMOD文化や二次創作文化が、P2Pとブラウザ技術によって再び花開く可能性があります。


🎉 おわりに

このプロジェクトは、思い付きと試行錯誤の繰り返しから生まれました。多くのバグやエラーにも悩まされましたが、最終的にはZIPアセットの送受信という"夢"をカタチにできたことは、小さくても確かな一歩です。

明日のゲーム開発者が、今日のあなたのコードを見て勇気づけられる。

そんな未来が来ることを願って。