やりたいこと
Unity のデスクトップアプリ上で HTTP サーバーと WebSocket サーバーを同時に立てる。
HTTP サーバーではローカルに向けて HTML ページを配信して、スマホからアクセスできるようにする。そのページから WebSocket で PC 上の Unity と接続させてデスクトップアプリを動かしたい。
前提条件
-
Unity 上で WebSocket サーバーを立てるライブラリを使用しているため、今回紹介する方法は Android/iOS/WebGL ビルドでは動作しません。
-
この記事で扱うのは同一 LAN 内での接続のみです。PC とスマホが同じ Wi-Fi に接続されている必要があります。
WebSocket ライブラリの選定
WebSocket ライブラリについて調べたところ、Unity での採用例が多いものとして websocket-sharp が見つかりました。一方で安定性や保守性に関する否定的な意見も散見され、加えて Unity へのインポート手順がやや煩雑である点が気になりました。そのため、今回は採用を見送っています。
また HttpListener をベースとした他のライブラリについても検討しましたが、Unity 上では WebSocket の Upgrade ハンドシェイクが正常に成立しない事例が報告されており、こちらも候補から除外しました。
以上を踏まえ、本記事では HttpListener に依存せず、Unity 上での動作実績もある WebSocket ライブラリとして Fleck を採用して進めます。
実装
本記事では Unity 6 (6000.3.2f1) で実装を行いました。
ただ Unity 6 固有の設定等はないため、Unity 2022 LTS 以降でも動作すると思います。
1. Fleck のダウンロード
NuGet から Fleck のパッケージをダウンロードします。
ページ右側のリストから「Download package」をクリックすればダウンロードできます。
次にダウンロードした fleck.x.x.x.nupkg ファイルの拡張子を .zip に変更して解凍します。
このあと fleck.x.x.x/lib/netstandard2.0 配下にある Fleck.dll を使います。
2. WebSocket サーバーの実装
2-1. Fleck.dll のインポート
先ほどダウンロードした lib/netstandard2.0/Fleck.dll を Assets/Plugins に配置します。
.
└── 📁 Assets
└── 📁 Plugins
└── Fleck.dll
2-2. WebSocketHost クラスの作成
今回はサンプルとして /text のような形式でメッセージを受け取り、それをコンソール上に表示するという内容を実装します。
WebSocket メッセージとして /text hello のように任意の文字列を受け取り、受け取った文字列 (例だと hello) を表示するデモ用の簡易プロトコルです。
注意
0.0.0.0 でバインドしているため、同一ネットワーク上の任意のデバイスからアクセス可能です。公共 Wi-Fi などで実行すると悪意のあるアクセスが行われる可能性があります。
using Fleck;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using UnityEngine;
public class WebSocketHost : MonoBehaviour
{
private WebSocketServer _server;
private readonly string _host = "0.0.0.0";
private readonly int _port = 8080;
// 接続中クライアント(ブロードキャストに使える)
private readonly Dictionary<Guid, IWebSocketConnection> _clients = new();
// メインスレッドで処理するためのキュー
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
void Start()
{
// Fleck のログレベル設定
FleckLog.Level = LogLevel.Warn;
// WebSocket サーバーインスタンスの作成・起動
_server = new WebSocketServer($"ws://{_host}:{_port}");
_server.Start(ws =>
{
// クライアント接続時のイベントハンドラ設定
ws.OnOpen = () =>
{
var id = ws.ConnectionInfo.Id;
lock (_clients) _clients[id] = ws;
Enqueue(() => Debug.Log($"Client connected: {id} ({ws.ConnectionInfo.ClientIpAddress})"));
};
// クライアント切断時のイベントハンドラ設定
ws.OnClose = () =>
{
var id = ws.ConnectionInfo.Id;
lock (_clients) _clients.Remove(id);
Enqueue(() => Debug.Log($"Client disconnected: {id}"));
};
// メッセージ受信時のイベントハンドラ設定
ws.OnMessage = message =>
{
if (message.Contains("/text "))
{
var id = ws.ConnectionInfo.Id;
Enqueue(() => Debug.Log($"Received message from {id}: {message}"));
}
};
// エラー発生時のイベントハンドラ設定
ws.OnError = ex =>
{
Enqueue(() => Debug.LogError($"WebSocket error: {ex.GetType().Name}: {ex.Message}"));
};
});
// サーバー起動ログ
Debug.Log("WebSocket Server Started.");
var ip = GetHostIPv4Address();
Debug.Log($"URI: ws://{ip}:{_port}/");
}
void Update()
{
// メインスレッドでの処理を実行
while (_mainThreadActions.TryDequeue(out var act))
{
act.Invoke();
}
}
void OnDestroy()
{
// Fleck の停止とクライアント切断
lock (_clients)
{
foreach (var c in _clients.Values)
{
try { c.Close(); } catch { }
}
_clients.Clear();
}
// サーバーの破棄
_server?.Dispose();
}
/// <summary>
/// メインスレッドで実行するアクションをキューに追加
/// </summary>
void Enqueue(Action act) => _mainThreadActions.Enqueue(act);
/// <summary>
/// ローカルのIPv4アドレスを取得
/// </summary>
string GetHostIPv4Address()
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
// IPv4のみ
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var ip = ua.Address.ToString();
// 169.254.x.x はリンクローカルなので除外
if (ip.StartsWith("169.254.")) continue;
return ip;
}
}
throw new Exception("IPアドレスの取得に失敗しました。");
}
}
2-3. GameObject に追加
適当な GameObject を追加して、先ほど実装した WebSocketHost をコンポーネントに追加します。
これでプレイモードを起動するとコンソール上に接続情報が表示されます。
3. HTTP サーバーの実装
次にスマホのブラウザで使うウェブページを配信するために簡易な HTTP サーバーを作ります。
3-1. HttpServer クラスの作成
Assets/StreamingAssets に index.html を配置して、それを読み込む想定で実装をします。
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.NetworkInformation;
using System.Text;
using System.Threading;
using UnityEngine;
public class HttpServer : MonoBehaviour
{
private readonly int _port = 3000;
private HttpListener _httpListener;
private Thread _listenerThread;
private string _indexHtml;
// スレッドの実行状態を管理するフラグ
private volatile bool _isRunning = false;
void Start()
{
// index.htmlの内容を読み込む
string filePath = Path.Combine(Application.streamingAssetsPath, "index.html");
if (!File.Exists(filePath))
{
Debug.LogError("index.html 取得失敗: " + filePath);
return;
}
_indexHtml = File.ReadAllText(filePath, Encoding.UTF8);
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_port}/");
// サーバー起動
try
{
_httpListener.Start();
_isRunning = true;
_listenerThread = new Thread(HandleRequests);
_listenerThread.Start();
Debug.Log($"HTTP Server Started.");
Debug.Log($"Open from smartphone: http://{GetHostIPv4Address()}:{_port}/");
}
catch (Exception ex)
{
Debug.LogError($"サーバー起動エラー: {ex.Message}");
}
}
void OnDestroy()
{
_isRunning = false;
// HttpListener を閉じる
_httpListener?.Close();
_httpListener = null;
// スレッドの終了を待つ
if (_listenerThread != null && _listenerThread.IsAlive)
{
_listenerThread.Join(1000);
}
Debug.Log("HTTP Server Stopped.");
}
void HandleRequests()
{
while (_isRunning)
{
try
{
// index.htmlをレスポンスとして返す
var context = _httpListener.GetContext();
var response = context.Response;
byte[] buffer = Encoding.UTF8.GetBytes(_indexHtml);
response.ContentType = "text/html; charset=UTF-8";
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
catch (HttpListenerException)
{
// Close された時に必ず発生するので、実行中でなければ無視する
if (!_isRunning) break;
}
catch (ObjectDisposedException)
{
// Listener が破棄された場合
break;
}
catch (Exception ex)
{
if (_isRunning) Debug.Log($"HTTP Error: {ex.Message}");
}
}
}
/// <summary>
/// ローカルのIPv4アドレスを取得
/// </summary>
string GetHostIPv4Address()
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
// IPv4のみ
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var ip = ua.Address.ToString();
// 169.254.x.x はリンクローカルなので除外
if (ip.StartsWith("169.254.")) continue;
return ip;
}
}
throw new Exception("IPアドレスの取得に失敗しました。");
}
}
補足 1
HttpListener の Prefix 設定について、Windows 環境では管理者権限が必要になる場合があります。
補足 2
説明を分かりやすくするために IPv4 の取得処理 (GetHostIPv4Address) をそれぞれのクラスに直接記述しています。
実際のプロジェクトでは共通クラスに切り出した方が良いです。
HttpServer も適当な GameObject にコンポーネントとして追加しておきましょう。
ここで一旦 HttpServer が正常に動作しているかを確認してみます。
Assets/StreamingAssets に index.html を作成して、シンプルなウェブページを用意します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket テスト</title>
</head>
<body>
<h1>WebSocket テストページ</h1>
</body>
</html>
これでプレイモードを起動するとコンソールに Open from smartphone: http://xxx.xx.xx.xxx:3000/ のように出力が出るため、スマホから URL を直接入力してアクセスしてみます。
画像のようなページにアクセスできれば成功です!
上手くいかない場合はファイアウォール設定を確認してください。
3-2. index.html に WebSocket 部分を追加
先ほど実装した index.html に WebSocket を行う部分を追加します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket テスト</title>
</head>
<body>
<h1>WebSocket テストページ</h1>
<div style="margin-bottom:8px;">
<input type="text" id="wsUrlInput" value="ws://127.0.0.1:8080" style="width:260px;" placeholder="ws://host:port">
<button id="wsConnectBtn">接続</button>
</div>
<div>
<input type="text" id="wsInput" placeholder="送信するテキスト">
<button id="wsSendBtn">送信</button>
</div>
<div>
<p>サーバーからのメッセージ:</p>
<pre id="wsMessages" style="background:#eee; padding:8px; min-height:40px;"></pre>
</div>
<script>
// WebSocket クライアント実装
let ws = null;
const wsMessages = document.getElementById('wsMessages');
const wsUrlInput = document.getElementById('wsUrlInput');
const wsConnectBtn = document.getElementById('wsConnectBtn');
// ログ表示用関数
function logMsg(msg) {
wsMessages.textContent += msg + "\n";
}
// デフォルトの WebSocket URL を生成する関数
function defaultWsUrl() {
// HTTP で開いているホスト(= Unity を動かしているPCのIP)をそのまま使う
const host = location.hostname || "127.0.0.1";
return `ws://${host}:8080`;
}
// WebSocket URL の初期値を設定
wsUrlInput.value = defaultWsUrl();
// WebSocket 接続処理
function connectWS() {
const url = wsUrlInput.value.trim();
if (!url) return;
if (ws) ws.close();
logMsg(`[接続試行中] ${url}`);
ws = new WebSocket(url);
ws.onopen = () => logMsg(`[接続しました] ${url}`);
ws.onmessage = (e) => logMsg("[受信] " + e.data);
ws.onclose = (e) => logMsg(`[切断されました] code=${e.code}, reason=${e.reason || ""}`);
ws.onerror = (e) => {
console.error("WebSocket error:", e);
logMsg(`[エラー] 接続に失敗しました`);
};
}
// 接続ボタンのクリックイベント設定
wsConnectBtn.onclick = connectWS;
// 送信ボタンのクリックイベント設定
document.getElementById('wsSendBtn').onclick = () => {
const text = document.getElementById('wsInput').value;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("/text " + text);
logMsg("[送信] " + text);
} else {
logMsg("[未接続] 送信できません");
}
};
// ページ読み込み時に自動で接続
connectWS();
</script>
</body>
</html>
上の input フィールドに HTTP で開いたホスト名を使って ws://{host}:8080 が自動入力されているはずなので接続を行います。
下の input フィールドには送信したいテキストを入力します。内部的には /text を文頭に入れて、簡易プロトコルに引っかかるように送信をしています。
4. 接続テスト
試しに「テストです」という文章を送信してみました。
画像のようにコンソール上に送信したテキストが表示されたら成功です!
おわりに
Fleck は Unity でも比較的安定して動作しそうです。
ここから発展させるとすれば WebSocket の中身に JSON を仕込んだり、Unity 側からのデータをブロードキャストして状態同期を行う等のアプローチが考えられます。またウェブページを React などに置き換えれば、よりリッチなウェブページを配信することもできそうです。
低遅延操作やインターネットを通じた操作用途には使えませんが、用途次第ではスマホを簡易コントローラーにしたり、同じ Wi-Fi 環境の端末をデバッグ UI として使うことができそうです。






