今回は技術記事ですがフワフワした点も多いのでこの文体でいきます。
Unity in ニコ生ゲーム
Motivation
これまでc++を使ってニコ生ゲームを開発する手法を探ってきましたが、これを応用すればUnityのWeb出力したゲームも原理的には動かせそうだなと思ったので色々と実験してました。別に自分でUnity使いたいわけでもないんですが、ニコニコが公式でツクールMVからのニコ生ゲーム出力に対応したというのを聞いてなんとなくUnityぶつけたろかなと思った次第です。
成果
試行錯誤した結果なんとか動いたので今回はその成果について「使い方」と「技術面」の2点についてまとめようと思います。まず、何が出来るようになったのか簡単にまとめます。
- Unityから出力したWebGL2ゲームがニコ生で実行できる
- 画面サイズ合わせ(シアターモードや全画面表示・スマホの回転などに追従)
- マウス・タッチイベントに対応
- ランキングゲーム対応(マルチは非対応)
- ニコ生プレイヤーの音量調整に対応
- 再生速度追従は非対応
5は↓の機能のことです。Unityで鳴らした音もここの音量が適用されます。
6はニコ生プレイヤーの2倍速再生機能などのことです。通常ニコ生ゲームはプレイヤーの再生速度に合わせてゲームの実行速度も変化しますが、今回そこまで対応できてないので常に等倍速度で実行されます。とは言えランキングゲームで速度に追従出来ないからと言って問題になることはほぼないので大丈夫でしょう。
使い方
こちらがGitリポジトリになります。最低限の使い方はここのReadmeにも載せてますが、本記事では更に細かく導入方法を示します。
手順1
akashic init -t javascript-shin-ichiba-ranking
とりあえずjsのランキングゲームとしてプロジェクトを作ります。image/audio/textフォルダは削除しておいてください。
手順2
GitリポジトリのReleasesから最新のUnityToNiconicoConverter.zipをダウンロード。適当なフォルダに展開します。
手順3
UnityHubで新規ゲーム作成。スクリーンショットのように3Dコアテンプレートで作成します。テンプレートは特にこれじゃなきゃダメというのは無いつもりですが、そもそもWeb出力できないテンプレートやビルド結果がやたら大きくなってしまうものは避けてください。 Unityのバージョンは2022.3.8f1で確認済み。バージョンによってどれくらい出力内容が変わるか分からないのでどのバージョンまで対応できているかは不明です。
手順4
プロジェクトが準備出来たらエディタが開くはずです。(しばらくかかります)
ファイル>ビルド設定を開いて以下のように設定します。 続いて左下のプレイヤー設定ボタンを押して以下のスクショのように設定します。
画像ではWebGLテンプレートがPWAになってますが、Minimalでも大丈夫。Defaultだとだめだった気もする。
データキャッシングや解凍フォールバックがONだと余分なスクリプトがついてきて変換失敗する可能性があるのでOFF推奨。
全部設定出来たらビルド設定ダイアログに戻って右下のターゲット切り替えを押して、切り替わりを待機、ビルドボタンが出てきたら押してください。この際出力フォルダ名を指定できますが、必ずBuildにしてください。(現在ツールが手抜きなためBuild固定)
手順5
コマンドプロンプトで以下を実行。各フォルダのパスは各自適切なものに置き換えてください。
コンバータを展開したフォルダ\UnityToNiconicoConverter 手順4で出力したフォルダ\Build 手順1を実行したフォルダ
成功すればニコ生ゲームフォルダは以下のようになっているはずです。
ゲーム │ .editorconfig │ .eslintrc.js │ .gitignore │ game.json │ package.json │ README.md │ ├─binary │ Build.data.gz │ Build.wasm.gz │ └─script Build.framework.js Build.loader.js main.js _bootstrap.js
手順6
akashic scan asset
でgame.jsonを更新します。ただし、バイナリファイル系はこのコマンドで追加できないので以下を手動で追加してください。
"Build.data": { "type": "text", "path": "binary/Build.data.gz" }, "Build.wasm": { "type": "text", "path": "binary/Build.wasm.gz" }
手順7
main.jsを以下で上書きします。
const loader = require('./Build.loader'); exports.main = void 0; function main(param) { var scene = new g.Scene({ game: g.game }); scene.onLoad.add(function () { // Unity側に渡すコンフィグデータを作成 var config = { dataUrl: g.game._assetManager.configuration['Build.data'].path, framework: function () { return require('./Build.framework')(this); }, codeUrl: g.game._assetManager.configuration['Build.wasm'].path, streamingAssetsUrl: "StreamingAssets", companyName: "作者の名前", productName: "ゲームの名前", productVersion: "1.0.0", matchWebGLToCanvasSize: false, showBanner: () => { } }; const canvas = document.createElement('canvas'); // ID名は何でもいいが空文字だとunity側でquerySelector出来なくなるので適当に付けておく canvas.id = 'unity-canvas'; canvas.width = g.game.width; canvas.height = g.game.height; // idついてないキャンバスがAkashicゲーム表示用のはず const mainCanvas = Array.prototype.find.call(document.querySelectorAll('canvas'), e => { return e.id.length == 0; }); mainCanvas.parentElement.insertBefore(canvas, mainCanvas); // キャンバスのスタイル変更ハンドラ const canvasLocation = new EventTarget(); const onWindowResize = () => canvasLocation.dispatchEvent(new Event('changed')); new MutationObserver(onWindowResize).observe(mainCanvas, { attributes: true, attributeFilter: ['style'], }); // メインキャンバスのスタイルをUnityキャンバスにコピー const resized = () => { for (const attr of mainCanvas.style) { canvas.style[attr] = mainCanvas.style[attr]; } }; canvasLocation.addEventListener('changed', resized); resized(); // ゲームのロード開始 loader(canvas, config, () => { }).then(instance => { }); }); g.game.pushScene(scene); } exports.main = main;
手順8
akashic-sandboxを実行してローカルホストをブラウザで開けばゲームが実行されるはずです。
手順9
エクスポートする場合は普通にコレ
akashic export zip --nicolive --minify --output ./game.zip -f
以上を参考に自分のゲームに適用してみてください。
技術的な話
ここからは具体的にどうやったのかについて技術的な話になるので興味のない人は読まなくて大丈夫です。
Unityが出力するファイル
まずUnityがWebGL出力で書き出すファイルがどんなものか見てみます。以下が肝となるファイルです。他にもアイコンとかhtmlファイルも出てきますがそれらは使いません。
Build/Build.data.gz Build/Build.wasm.gz Build/Build.framework.js.gz Build/Build.loader.js
それぞれ何なのか簡単に説明すると
- Build/Build.data.gz
- テクスチャやモデルなどアセットをひとまとめにしたもの。
- Build/Build.wasm.gz
- エンジンコードやゲームスクリプトをWebAssembly化してひとまとめにしたもの。
- Build/Build.framework.js.gz
- emscriptenが吐き出すjavascriptコードにUnity独自の改変を加えたもの。
- Build/Build.loader.js
- 上記ファイルを読み込みゲームを起動するためのスクリプト。外部に関数を公開している。
loader.jsに定義されたcreateUnityInstanceという関数を呼び出すことで他のファイルのロードやアセットの展開など非同期で実行してくれます。ただしloader.jsとframework.jsは、そのままではニコ生で動かせないので手を加える必要があります。
---ここからは検証不足なので大分推測が混ざってます。---
framework.jsは、emscriptenの出力(+Unity拡張)なためゲームを作りこんでいく中でたまに内容が変化します。その都度手作業で出力したjsを書き換えるのは現実的ではありません。そこで、スクリプトの書き換え処理を自動化することにします。
とは言え、どう変化するか分からないスクリプトを部分的に書き換えるとなると単純な文字列置換や正規表現では対処しきれそうにありません。そこで、スクリプトを抽象的な構造(構文木)として読み込んでそれに対してパターンマッチングを行い、より精度の高い置換を行うことにします。
今回は構文解析にEsprima.NETというライブラリを使用しました。*1esprimaはjsコードをパースして抽象構文木にしてくれるライブラリです。一応ツリーを操作するAPIもあるので置換もできます。*2
スクリプトのパターンマッチング
ツリー構造のドキュメントに対してパターンマッチング及び置換を行う例としてXPathがあります。XPathはXMLドキュメント用の正規表現のようなもので非常に便利です。
今回はそれには遠く及びませんがXPathを参考にパターンマッチャーを作りました。パターンは指定の識別子と一致や子要素のどれかが指定の条件に一致と言った単純な条件を&や|で接続して様々な条件を定義出来るようにしています。ちょうどパーサーコンビネータのような感じですね。*3
// 条件の基底クラス public abstract class Condition { // 指定したノードがこの条件を満たすかどうかを返す public abstract bool Satisfy(Node n); // 条件同士は&で接続出来る public static Condition operator &(Condition l, Condition r) { return new And(l, r); } // 条件同士は|で接続出来る public static Condition operator |(Condition l, Condition r) { return new Any(l, r); } } // 複数の条件をすべて満たすノードに適合する public class And : Condition { Condition[] conditions; public And(params Condition[] conditions) { this.conditions = conditions; } public And(IEnumerable<Condition> conditions) { this.conditions = conditions.ToArray(); } public override bool Satisfy(Node n) { return conditions.All(c => c.Satisfy(n)); } } // 複数の条件のどれか1つを満たすノードに適合する public class Any : Condition { Condition[] conditions; public Any(params Condition[] conditions) { this.conditions = conditions; } public Any(IEnumerable<Condition> conditions) { this.conditions = conditions.ToArray(); } public override bool Satisfy(Node n) { return conditions.Any(c => c.Satisfy(n)); } }
この辺りの実装はコンバータのソースの#region
で囲んだ範囲にあるので気になる方は見てみてもいいと思います。
ちなみにesprimaのノードは親ノードへの参照を持っていません。恐らく同じ内容のノードを使い回せるようにだと思われます。例えばスクリプトに何度も出てくる変数は1つの変数オブジェクトの使い回しで済むので経済的というわけです。ノードを辿る方法も基本的に上から下への再帰探索のみです。確かにそれでもやれないことは無いですが、XPathのように「親ノードへ辿るような条件式」を実装出来ないので非常に使いにくかったです。
改変した箇所解説
さて、それでは上記で紹介した機能で実際にスクリプトを書き換えていきましょう。以下はどこをどう書き換えたかという内容だけ書いているので実装についてはgitソースコードと合わせて見て確認してください。
loader.jsの改変点
gitソース内ReplaceLoader
関数に対応。
gl変数定義
loader.jsではロード以外にWebGLコンテキストの作成もしています。一応webgl2 > webglの順でコンテキスト作成を試みるようになっています。(フォールバックしたところでwebglじゃほぼ動かない気がするけど・・・)作成が成功した方のコンテキストをグローバル変数に保存するんですが、その変数の宣言が無いのでakashic export時に構文チェックでエラーとなってしまいます。古いjsコードでは宣言せずに代入した場合グローバル変数扱いとなるはずなので悪くないと思うんですが、とにかくなんかダメみたいです。なのでスクリプトの頭に以下の宣言文を追加して解決しました。
var gl,glVersion;
framework.js読み込み
元のコードではframeworkスクリプトの読み込みにscriptタグを使っていますが、Akashic的にはrequireするだけでいいのでごっそり書き換えます。
// 元の処理の概略 new Promise(function (resolve, e) { // script要素作成 var s = document.createElement("script"); // コンフィグで指定したframework.jsのURLで読み込み開始 s.src = config.frameworkUrl; // 読み込み完了時のエラー処理やresolve呼び出しを設定 s.onload = ...; // DOMツリーに追加 document.body.appendChild(s); }).then(...); // 書き換え後 config.framework();
元の処理ではごちゃごちゃと読み込み処理をしてるんですが、Akashicでは読み込み処理が不要なのですっきりしてます。
余談
Akashicではスクリプトの依存関係分析を行うことでエクスポート時に必要なファイルを絞ってるっぽいんですが、どうやって依存解析しているかというと、require関数の呼び出しとその引数の文字列リテラルで見てるっぽいんですよね。(依存解析ライブラリの名前忘れた)逆にリテラルじゃなくて変数を介してモジュール名を指定するとそれだけで依存関係が辿れなくなってエクスポート時にスクリプトアセットが除外されちゃう問題があります。
// これなら問題ない require('./Build.framework'); // こうするだけで依存が追えない const path = './Build.framework'; require(path);
エクスポート時のメッセージに
excluded script/Build.framework.js due to unreachable/unhandled.
と出てきます。つまり「script/Build.framework.jsはどこからも参照されてないので除外しました。」 と言うことです。これのタチが悪いところはローカルでテストしてる時には動くのにニコ生でプレイした時だけ動かなくなるということですね。
データのgzip展開
UnityのWeb出力には圧縮設定があり、無圧縮・gzip・brotliの3種類があります。今回はgzipにのみ対応しましたが、検証初期では無圧縮で出力したものを自前のbzipで圧縮していました。しかし、考えてみれば当たり前なんですが、ブラウザのAPIに圧縮展開系の機能があるんですよね。
自前のものもwasmで実装してるのでそこそこ速いはずですが、さすがにネイティブ実装で動く標準APIの速度には全く勝てません。しかも、非同期処理にも対応していてブラウザが硬直することがないのでどう考えてもこれを使うべきでした。
ちなみに今回のツールではgzip展開を自前で処理していますが、通常は以下のページにあるようにサーバー側に設定さえしておけば何もせずともブラウザが勝手に解凍してくれます。流石にニコニコのCDNサーバーの設定をいじることはできないので今回は使えないテクニックです。
Unityが吐いたコードではdata.gzをfetchした後、圧縮解凍済みのデータとしてアクセスしてしまっているので、fetchの後に解凍する非同期処理を挟んであげます。
// 元の処理 t.then(function (e) { var r = new DataView(e.buffer, e.byteOffset, e.byteLength); ... }); // 置き換える処理 t.then(buf => new Response( new Response(buf).body.pipeThrough( new DecompressionStream("gzip") ) ).arrayBuffer() ).then(buf => new Uint8Array(buf) ).then(function (e) { var r = new DataView(e.buffer, e.byteOffset, e.byteLength); ... });
moduleエクスポート
元のコードはモジュールスクリプトではないのでエクスポート文がありません。末尾にエクスポート文を追加してあげましょう。
module.exports = createUnityInstance;
framework.jsの改変点
gitソース内ReplaceFramework
関数に対応。
いつものおまじない挿入
冒頭に
const process=undefined,setImmediate=undefined;
を追加します。c++の時もやってたやつですね。
プレイヤー音量適用
前提知識
そもそも前提としてWebAudioAPIの基礎知識が必要です。
要は音源データや出力先をノードという概念で表し、それらの接続によって柔軟なオーディオシステムを構築しようという思想です。
最終的にAudioContext.destinationに接続された音がスピーカーから聞えるという感じです。音を加工できるフィルタノードというものもいくつか提供されていて、音源と出力ノードの間にそれらを挟むことで簡単に音を加工できます。
Akashicの実装
Akashic内部では音量調節用にGainNodeが使われています。ノードの接続自体は音源ノード>Gainノード>出力ノードのように一つしか挟まってないんですが、AkashicのAPIではAudioPlayerごとの音量と、BGM・SEごとの全体音量と、ニコ生プレイヤー音量の3つの音量がかけ合わされるので、素直にやるならGainノードを3つ挟んでそれぞれで音量を適用すればいいんですが、恐らく処理負荷を抑えるためなんでしょうね。自前で3つの音量をかけ合わせて1つのGainノードに設定する作りになってます。
// Gainノードの音量に3つの音量をかけ合わせて設定するコード(だいたいこんな感じ) // どれか1つでも音量が変更されたらこの処理が走るようにハンドリングされてる this._gain.gain.value = this.volume * this._system.volume * this._manager.masterVolume;
ではどうやってニコ生プレイヤー音量の変更をハンドリングしているんでしょうか?内部実装(engineFilesV3_2_4.js)を見るとAudioManagerクラスの中でsetMasterVolume
というメンバ関数を使って自身に登録されたAudioAssetの_lastPlayedPlayer
プロパティを通してnotifyMasterVolumeChanged()
を呼び出しています。逆に言えばhoge._lastPlayedPlayer.notifyMasterVolumeChanged()
という呼び出しが可能なオブジェクトhoge
をAudioManagerに登録しておけば、ハンドリングできちゃうということです。そしてAudioManagerはg.game.resourceFactory._audioManager
として取得できるのでこうすればよいことになります。
const audioManager = g.game.resourceFactory._audioManager; audioManager.registerAudioAsset({ _lastPlayedPlayer: { notifyMasterVolumeChanged: function(){ gain.gain.value = audioManager.getMasterVolume(); }, }, });
Unityへの適用
以上を踏まえてUnityのオーディオに適用することを考えます。まずオーディオ―ノードの構成ですが、元々UnityでもGainノードが1つ挟まった構成でした。Akashicと同じようにこのGainノードに全ての音量をかけ合わせた値を設定することも可能だと思いますが、UnityにしろAkashicにしろオーディオ周りって結構ややこしいので今回は手を抜いて以下のように全体音量用のGainノードを追加する対応にしました。
Unityが出力したスクリプトではオーディオ関連の変数はWEBAudio
というオブジェクトにまとめているようです。
なのでそれに倣ってマスターボリューム用の変数を追加します。
var WEBAudio = { audioInstanceIdCounter: 0, audioInstances: {}, audioContext: null, audioWebEnabled: 0, audioCache: [], pendingAudioSources: {}, masterVolume: null, // 追加 };
次に_JS_Sound_Init
という関数内で初期化します。
// 関数末尾にこれらを追加 WEBAudio.masterVolume = WEBAudio.audioContext.createGain(); WEBAudio.masterVolume.connect( WEBAudio.audioContext.destination ); const setMasterVolume = () => { WEBAudio.masterVolume.gain.value = g.game.resourceFactory._audioManager.getMasterVolume(); }; setMasterVolume(); // 起動時に適用するため呼んでおく g.game.resourceFactory._audioManager.registerAudioAsset({ _lastPlayedPlayer: { notifyMasterVolumeChanged: setMasterVolume, }, });
そして最後にjsAudioCreateChannel
という関数内でマスターボリュームノードにつなぐ処理を追加します。
channel.setupPanning = function () { if (this.source.isPausedMockNode) return; this.source.disconnect(); this.panner.disconnect(); this.gain.disconnect(); if (this.threeD) { this.source.connect(this.panner); this.panner.connect(this.gain); } else { this.source.connect(this.gain); } this.gain.connect(WEBAudio.masterVolume); // 追加 };
以上で完了です。 ただ正直オーディオ周りの対応は足りない部分もあると思います。ツールのソースは公開してますので是非修正してプルリクでも投げていただければと思います(丸投げ)。
wasm読み込み
WebAssembly.instantiateStreamingを呼び出している箇所がありますが、ここも変更が必要です。
// 元のコードはこんな感じ return fetch(wasmBinaryFile, { credentials: "same-origin" }).then( function (response) { var result = WebAssembly.instantiateStreaming(response, info); return result.then(receiveInstantiationResult, function (reason) { return instantiateArrayBuffer(receiveInstantiationResult); }); } );
// それをこのように return fetch(wasmBinaryFile, { credentials: "same-origin" }).then( res => new Response(res.body.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer() ).then( function (response) { var result = WebAssembly.instantiate(new Uint8Array(response), info); return result.then(receiveInstantiationResult, function (reason) { return instantiateArrayBuffer(receiveInstantiationResult); }); } );
fetchしたwasm.gzを解凍してからインスタンス化するようにしただけですね。
エクスポートエラー対策
最後は地味な部分。emscriptenには自動で実行環境を判定して処理を切り替える仕組みがあります。framework.jsの中にもブラウザ実行時用の処理とnodeサーバー実行時用の処理などが混在していて適宜if文で処理を切り替えています。
// こういう感じのコードが混ざっている if (ENVIRONMENT_IS_NODE) { fs = require('fs'); }
node向けのコードは実行されることは無いんですが、require文が存在するとそれだけでエクスポートエラーが発生してしまいます。
一応、ダミーでそのモジュールを用意することである程度回避は可能っぽいんですが、今回はせっかくJSを書き換える処理を作っているので、これらの余計なコードを除去してしまいましょう。除去するルールとして以下の3つの変数を定数として展開した際に絶対実行されないコードを削除することとします。
変数名 | 展開値 |
---|---|
ENVIRONMENT_IS_NODE | false |
ENVIRONMENT_IS_WORKER | false |
ENVIRONMENT_IS_WEB | true |
例えば以下のようなコード変換が発生します。
if(ENVIRONMENT_IS_NODE) { hoge() } else if (ENVIRONMENT_IS_WEB) { fuga() } // ↑を↓に変換 fuga()
変数を定数扱いして実行されなくなるコードを刈るような機能はEsprimaには無いのでこれも頑張って実装してあります。*4
更にこの処理によってthrow文の後に他のステートメントが続くようなコードが発生してしまいました。いわゆる到達不能コードの発生ですね。確かそれでもなんかのエラーが出たのでthrow以降のステートメントを削除するようにして無事エクスポートできるようになりました。おかげでコードサイズも少し小さくなったのでめんどかったけどやっといて良かったと思います。
main.jsの解説
コンフィグ作成
Unityの初期化時に渡す設定オブジェクトを作っています。
var config = { dataUrl: g.game._assetManager.configuration['Build.data'].path, framework: function () { return require('./Build.framework')(this); }, codeUrl: g.game._assetManager.configuration['Build.wasm'].path, streamingAssetsUrl: "StreamingAssets", companyName: "作者の名前", productName: "ゲームの名前", productVersion: "1.0.0", matchWebGLToCanvasSize: false, showBanner: () => { } };
dataUrlやcodeUrlに設定している値は以下の記事でも紹介したやり方ですね。
dataやwasmはfetchで読み込むのでそのためのURLを設定しています。
frameworkはもともとframeworkUrlというプロパティでURL文字列を設定するところだったんですが、今回はスクリプトの読み込みをしたいわけではないので思いっきり書き換えてます。ちなみにこのframeworkに設定しているfunctionのthisはconfigとは別のオブジェクトを指しているので要注意です。
streamingAssetsUrlは未使用。
companyName、productName、productVersionはそのままですね。多分使われてないと思います。
matchWebGLToCanvasSizeはデフォルトがtrueなんですが、これがtrueだと勝手にcanvasサイズ変えられちゃって大抵ゲームが見えなくなるのでfalse必須です。
showBannerも使ってないので未設定でも大丈夫かもしれません。
キャンバスの作り方
Unity用のキャンバスはAkashicとは別に独自に用意します。 c++で作っていた時はオフスクリーンのキャンバス*5を作成してそこへWebGLで描画した後、それをAkashicのキャンバスに表示していました。
Unityでもその方針でやろうと思ったのですが、Unityの場合描画先のキャンバスとタッチイベントを受け付けるDOM要素が同じである前提の作りだったため、自前で作成したキャンバスをDOMツリーに登録してこれを直接表示することにしました。
そのために以前の記事の手法でAkashicキャンバスの上に自前キャンバスを重ねています。
細かい注意点としてUnityキャンバスには何かしらIDを設定しておく必要があります。Unityのスクリプト内でキャンバスのIDを最初に取得して後でそのIDで要素検索をしてキャンバスを取り出すといった操作をしているためです。
ランキングゲーム作る
実際にランキングゲームを作る時はg.gameオブジェクトへのアクセスが必須となります。C#からjsにアクセスするための方法はこちらの記事がよくまとまっています。
上記を参考にスコアの設定関数と乱数シードの取得関数を定義してみました。
mergeInto(LibraryManager.library, { SetScore: function(v){ g.game.vars.gameState.score = v; }, GetRandomSeed: function(){ // 予めmain.jsでg.game.vars.randomSeed = param.random.seed;としている前提 return g.game.vars.randomSeed; }, });
これをUnityプロジェクトのどこかの階層でPluginsフォルダの下に.jslibという拡張子で保存することでframework.jsに書き出されるようになります。そしてC#スクリプトのどこかで以下のように定義することで利用できるようになります。
[DllImport("__Internal")] private static extern void SetScore(long v); [DllImport("__Internal")] private static extern long GetRandomSeed();
注意点
今回のコンバータは一見まともに動いているように見えますが、実は色々と割り切ってあきらめているところがあります。そのせいで出来ないことや気を付けねばならない点についてまとめておきます。
更新タイミング
まず一番大きいところで言うとAkashicのonUpdateを使っていません。Unityはframework.js内で独自にスケジューリングを行っているんですが、今回はそこの改造にまでは手を出しませんでした。なぜならめちゃくちゃめんどかったからです笑
横着を正当化する理由を言うとするなら、ランキングゲームだけで言えばAkaschiのスケジューラに沿っていなくても実質的に問題が無いということが言えます。これが無いと何が問題かと言えば、まずシークバーを操作して配信を倍速再生したりすると本来はゲームも勝手に2倍速でプレイされるはずですが、それがされません。どんなに再生速度をいじっていても常に通常速度で実行されます。そのためゲーム画面と配信画面の同期が取れなくなりますがマルチゲームでもないので特に問題ないでしょう。また、リアルタイム視聴でないとランキングスコアは送信されないので「再生速度をいじっている」=「過去の配信見ている」状態なのでランキングにも影響しないです。
長々と書きましたが、以上の理由から今回はスケジューラの同期対応は見送りました。
マルチ
マルチゲームは非対応です。作れません。まずマルチゲームでは更新タイミングの同期が必須なんですが、上に書いた通り非対応なのでその時点でアウトです。しかしめっちゃ頑張って同期できるようにしたとして、以下の記事でも書いた通りサーバー側で動くスクリプトはUnityと別で作る必要があります。
普段はUnity上で開発しているのでそこでマルチの動作確認もしないといけないと思いますが、そのためにローカルサーバーを立てたりしないといけないし、Unityじゃ複数起動たぶんできないし、サーバー処理をニコ生ゲームサーバーでも動くようにしないといけないし、とハードルがやたらと多いので個人的にはお勧めしませんね。
ファイルサイズ
UnityでWebGL出力すると最小で数MB、そこそこ作りこんでると20MBくらいは軽くいくと思います。(gzip出力時)
しかし、数か月前ならつゆ知らず、今はニコ生ゲームのファイルサイズ上限は30MBに増えているので案外収まると思います。ただ、どうしても収まりきらないという場合には禁断の限界突破技が存在します。
ゲームファイルはzipでまとめてアップロードしていますが、そのままサーバー上に置かれているわけではなく、CDNサーバー上に展開され、各アセットファイルに固有のURLが付けられています。CDNサーバーですからそんな複雑な処理はしてなくてこれらのURLにアクセスすればブラウザからでもDLできちゃうわけです。
つまり、別のゲームのアセットであってもURLさえ合っていればアクセス出来てしまうのです。
アセットのURLを知る方法はさんざんやってきているのでそれを使えばいいですね。あとはデータ置き場用のゲームを投稿して本体のゲームからそのアセットを参照すれば実質容量制限は無限になります。
ただこの方法とてつもないデメリットがあって、何かというとデータアセットのURLを調べるのが超めんどいということです。実際にアップロードするまでURLは決まらないですし、決まったURLはゲームを起動してみないと調べられないので、しょっちゅうデータを更新する必要がある場合絶望的に手間がかかります。
まぁ禁術なので基本使わないと思いますが、使う場合はかなり工夫を強いられそうですね。