isobe_yakiのブログ

ニコ生ゲーム開発者向けの記事を書きます

天体観測振り返り

今回ご出演いただいた神林ゆうこさん
今回は秋イベに参加した話を書きますが、半分くらいはUnityでニコ生ゲームを作ろうの実践編といった内容になってます。

秋のゲームイベント参加の流れ

今回も一応ニコ生 自作ゲームパーティー-秋-に参加しました。ゲーム作りは例によって直前になって始めたんですがそこまでの流れがいつもとはちょっと違ったので経緯を追って書いていきます。

Unityツール開発

夏イベが終わった後に自作ゲームフェス2023という大型企画の発表がありました。ニコ生ゲームに限らずありとあらゆる自作ゲームのお祭りということで色々な部門賞が用意されています。協賛企業も多く、あのUnityも含まれていました。個人的にはUnityはチョット触ったことがあるだけだったんですが、ニコ生ゲームとか関係なく普通にUnityゲームを作って応募するのもありかなぁなんて考えていました。

しかしここでセコい考えが浮かんできます。もしUnityでニコ生ゲームが作れたらゲーム1個で何部門もエントリー出来るのでは!?と…

まぁ本気で賞を取る気だった訳ではないんですが(笑)、そういう運営の想定外であろうことをするのはワクワクするのでやってみたくなったんですよね😁。それで言うと、7月に公式がRPGツクールでニコ生ゲームを書き出すツールを出してきてたんですが、そこに対してもUnityツールをぶつけてみたくなったというのもあります*1

というわけで、9月はツール開発をしてました。下旬あたりでほぼ完成はしたんですが、1ヶ月ほどプログラミングをお休みしてたので、ツールを公開したのは10/29になりました。

検証用ゲーム制作

ツール開発中は下の画面のようなプロジェクトで動作検証をしていました。

サウンド』『入力』『処理負荷』『グラフィック』の検証

しかし、このプロジェクトは散らかって汚くなったので、記事に載せるスクショを撮るためにも、新規ゲームを立ち上げました。これが後に「天体観測」になるんですが、そこまでにかなり紆余曲折してます。

コインプッシャー

紆余曲折1

コインプッシャープロト

せっかくUnityを使っているので3Dで物理演算をやりたいと思い、比較的簡単そうなコインプッシャーを作ることにしました。コインをBlenderで作成、台は適当にキューブを並べてプッシャーの動きを付けました。しかし、実際にWeb出力してスマホで動かしたところかなり重い!確認に使ったスマホはXperia10IVという2022年7月に出たばっかのやつなので結構性能はいいはずです。それでもコインがいっぱい重なった状態で全体が動くとガクッとします。通常物理演算はSIMDとかスレッドでがっつり高速化するものですがwasmはそれらが(基本的には)封印されてしまうので、そりゃそうだよなという結果になってしまいました。

重さ的には、ニコ生ゲームの「コイン押し機*2」くらいだと思うので、ありっちゃありだと思います。が、あまり印象が良くないことに変わりはないと思うので今回は諦めました。

ちなみにgifでは(実機と比べると)かなりコインがデカいですが、スマホで遊べるコインプッシャーって大体これくらいデカいですよね。それがいつも嫌だったんで最初は画像の3分の1くらいのサイズのコインでやってみたんですが酷いもんでした・・・。

ホントはこれくらいの密度にしたい

見た目はかなり現実のそれに近くなったんですが超ガクガクでとてもプレイできるレベルじゃありません。巷のプッシャーゲームのコインがやたらでかいのはしょうがないんだな~と思いました。

マーブルフィーバー

紆余曲折2

とは言えプッシャー系ゲームは元々作りたかったゲームなので悪あがきとしてマーブルフィーバーにも挑戦します。

www.youtube.com
音量注意!楽しかったなぁ(遠い目)

こちらはコイン(MeshCollider)と違ってガラス玉(SphereCollider)なので物理計算が大分軽いです。でもこっちはこっちでガラス玉を表現しようとすると描画負荷がヤバそうだし、メタリックな玉にしたらパチンコになっちゃうし、単純に見栄えするところまで作り込むのが大変そうだぞということで一旦保留にしました。いつかニコ生ゲーム外で作りたいね。

人間ミサイル

紆余曲折3

マーブルフィーバーを早めに諦めたもう一つの理由として、この辺からワンチャン秋イベに出そうかなと考え始めたというのがあります。卓球の記事でも書きましたが、今後は落選も全然あり得るので季節イベントのために作るというよりは何かのついでで応募するようにしとくとダメージ少ないかなと、今回なら「記事を書く検証用」+「Unityツールのサンプル」+「季節イベント」+「自作ゲームフェス」というバリア4段構えで望めます笑。なので2日くらいで作れそうな簡単なゲームなら秋イベ出してもいいなということで、「3Dを活かせる」「簡単操作」「処理の軽さ」なども考慮して、ユニティちゃんを人間大砲で飛ばしてフィールドをどれくらい壊せたかを競うゲームならいけるんじゃないのという結論に。

youtu.be
ゲームのイメージ

ただ破壊するフィールド考えたり作るのが地味にめんどいなということでこれは1時間くらい考えて没に。*3.

星座さがし

紆余曲折4

その後も当てもなくシーンを弄り回していました。その中でなんとなく背景を透過にしてみたくてやり方を調べているとこの記事を発見。これに従い背景を黒一色に変更。で、背景が真っ黒なので何となくそれに合わせてシーンのライトも夜っぽくしてみました。

背景透過テスト

実際にニコ生上で透過背景は成功したんですが、ポストエフェクトも使ってるとキャラの周囲が透過しないで黒いオーラを放ってるような見た目になってしまったので背景透過はすぐやめました。でも、夜っぽいライティング自体はいい感じなので残しました。ライティングはいいとしてゲームをどうするかですが、もう時間無いし「~さがし」系のゲームでいいかと、そして夜なので星座探したらええねんということで『天体観測』というテーマに行きつきました。制服着た女の子のモデルもあるしなんか恋愛ADVっぽく作ればまとまりそうです。*4

このように直前まで紆余曲折あったせいで天体観測ゲームのプロジェクト名は「Pusher」でシーン名は「Missile」になってます。結局散らかってるじゃねぇかよ笑

名前ぐちゃぐちゃ

天体観測ゲーム開発

結局、締め切り前日の夕方くらいから作り始めることに…果たして間に合うのか?

キャラアセット

さっきからユニティちゃんとか謎のショートヘア少女のモデルとかしれっと出してますが、これは↓のSDユニティちゃんアセットに含まれていたものです。

assetstore.unity.com

ユニティちゃんは公式HPで色々なアセットを二次創作許諾で配布しています。(使用する場合はライセンスをよく読んでください。)

unity-chan.com

ショートの子については、なんでユニティちゃんじゃないキャラも入ってるんだ?と思いつつかわいいからいいやと思って使ってたんですが、後で調べたら小碓学園という二次創作設定?に出てくる『神林ゆうこ』という名前のキャラらしいです。(まだよくわかってない)

今回作ってるときにゆうこちゃんについてた猫耳をただのアクセサリーだと思って外してしまったんですが(なんてことを・・・)、どうやら猫娘キャラだったみたいで着脱して良いものではなさそうでした。すいませんでした<(_ _)>

本来の姿

背景

とりあえず満天の星空がバックに欲しい。Unityで360°の背景が欲しい場合はスカイボックスを使うのが良さそうです。そのためにキューブマップというテクスチャアセットを用意しないといけません。適当にググってみたんですが意外といいのが見つからない。JAXAとかも見てみたけどいいの無いしそもそも使っていいかもわからない。めんどいのでShaderToyで探します。すると求めていたものを発見!

www.shadertoy.com

しかもライセンスもCC0なので使い放題です。あとはこれをこういう風に左右上下前後の順で横長の画像に焼き付ければ行けそうです。こういうのはc++が得意なのでサクっと作ります。

// ↓上記シェーダーをc++に書き換えた関数
uint32_t sky(float3 v) { ... }

void main() {
    // GDIPlusのBitmapクラスを使う(png保存のため)
    Bitmap bmp(Resolution * 6, Resolution);
    auto data = bmp.Lock();

    for_each(execution::par, 0, 6, [&](int faceIndex){
        for (int y = 0; y < Resolution; y++) {
            // この行の書き込み開始アドレス算出
            uint32_t* row= data.ptr + y * Resolution * 6 + faceIndex * Resolution;
            for (int x = 0; x < Resolution; x++) {
                // 面の番号とピクセル座標から視線方向の3次元ベクトルを算出する
                float3 v = indexToVector(faceIndex, x, y);
                // ベクトル方向の空の色を算出する
                row[x] = sky(v);
            }
        }
    });

    bmp.Unlock();
    bmp.Save("skybox.png");
}

上記は実際のc++コードを簡略化したものですが、6スレッドを起動して6面の夜空を並列に計算しています。今回は一面当たり1024*1024なので合計で約600万ピクセルを計算することになりますが、3秒以内で完了します👍シェーダーコードからc++への移植ですが以前c++で迷路ゲームを作っていた時にシェーダー風に書ける数学ライブラリを作っていたのでそれのおかげで簡単に移植できました。

計算した夜空

出来た画像をごにょごにょすればシーンに反映されます。

あとは、天体望遠鏡の無料素材もあったのでいい感じの位置に配置して背景完成です。

さがしゲーム

星座さがしとは言ったもののはっきりした仕様は考えてなかったのでぶっつけ本番で作ってます。最初はこんな感じのイラストを飛び回らせて、女の子が聞いてきた星座をタップという「手足さがし」みたいなゲームでいこうかと思ってました。でも夜空の星を直接指でつないで星座にするというコンセプトを思いついたのでなんかいい感じにできないかと考え直します。

で、色々考えはしたんですが、あくまで今回はUnityでニコ生ゲーム作れるよ~という技術デモだし、あまり悩んでもしょうがないわということで以下のルールに落ち着きました。

「10×6のグリッド上にランダムに配置された星から見本と同じ並びの星をタップするだけ」

見本と同じパターンが複数現れることもあるのでその場合も正解とするようにしました。

スコア計算

自分のゲームはいつもスコア計算がいい加減です。今回も回収した星1個=1点というシンプル計算。

それだけだと流石に単調すぎるのと、スコアランキングで上位が全部同じ点数になる現象が起きてしまうので、一応コンボとかボーナスどうするかも考えました。

  • ボツ案
    • 連続正解でスコア倍率UP
      • そもそも正解しないと次の問題にいかないので全てが連続正解
    • 素早く正解するとボーナス
      • もともと速い人ほど高スコアになるゲームなのにスピードにボーナス付けてもうまい人が更に高スコアになるだけ
  • 最終案
    • 精度にボーナス
      • 速度と精度はある程度トレードオフになると思うのでミスなくタップできたときだけボーナスを付ければ意味はありそう

かなり大味な仕様ですが、ないよりはマシ、、、だと思う。実装自体はすぐなんですが、この仕様があることをユーザーに何となくでも伝えないといけません。手抜きですが、一度選んだ星を選択解除したときに「出来るだけ間違えないようにしないと…」というメッセージを下に出すようにしました。なんとなく「間違えると良くないんだなー」ということをシステムメッセージではなく主人公の心の声で表したんですが、伝わったんだろうか…?

怖い部屋3Dの時もやってましたが、演出優先で分かりやすさを放棄するという愚行を今回もやっちゃいました。(反省0)

描画

今回のレンダリング周りの小話。

キャラクター

キャラシェーダーですが、これは上で紹介したユニティちゃんアセットに含まれているトゥーンシェーダーをそのまま使っています。

github.com

詳しく見てないですが、かなり高機能っぽいです。特に調整もなくリムライトのような効果も出てるし見栄えいいです。

リムライト。要は逆光で輪郭が明るくなるやつ

今回は夜のシーンでもあったので影は落とさなかったんですが、影に関しても細かく色味の設定なども出来るようです。

SDFシェーダー

星は無駄にsdfという技法で描画してます。 つまり画像を使わずシェーダーの計算でピクセルの色を決定しているということですね。星が瞬くアニメーションもシェーダー内で完結していてスクリプトでは何もしてません。更に言うと白い部分はただの白ではなく、中心に近いほど0xffffffより大きい値、つまりHDRレンダリングになっています。おかげで後述のブルームによって発光表現が適用されます。

ベクターグラフィックスなので拡大してもガビらない

ビルボード

星シェーダーを設定した板ポリが常にカメラの方を向くようにビルボードスクリプトをアタッチしています。ビルボードの詳しい説明はこちらがよくまとまってます。

nn-hokuson.hatenablog.com

// 記事を参考に作ったスクリプト(記事のコピペだとダメだった)
// UnityにBillboardRendererというのもあるが高機能すぎるので不採用
using UnityEngine;

public class BillboardTransformer : MonoBehaviour
{
    private void OnWillRenderObject()
    {
        var cam = Camera.current;
        if (cam != null)
        {
            transform.LookAt(cam.transform.position);
        }
    }
}

軌跡

星の回収エフェクト

星が回収される時の軌跡はTrailRendererコンポーネントを使っています。かなり手軽に派手なエフェクトをつけられるので便利です。

styly.cc

以下は今回作った軌跡の設定値です。発光する軌跡にしたかったのでマテリアルも専用のものを用意しました。

軌跡の各設定

一番下のレイヤーの順序で-1を設定しています。0のままだと星と軌跡エフェクトが入り混じって描画されてしまい、インスタンシングが効かなくなっていたので変更しました。エフェクト発生時のドローコールが数十減ります。

軌跡用マテリアル
分かりにくいですが色選択ダイアログの下の方の「強さ」の項目を1.6589にして発光するようにしてます。

また軌跡の軌道は3次ベジェ曲線を使ってます。

軌跡の模式図
赤点が始点で青点が終点で緑点が2つの制御点です。制御点は1つは始点と終点を結んだ線上で、もう1つは始点を中心とした円上のランダムな点を選んでます。

ブルーム

ブルームは全てを解決する。ブルームを信じろ。

という格言があるようにブルームエフェクトは手軽にビジュアルを強化する定番エフェクトです。軽いし実装簡単だし派手だしとりあえずぶっこんどけ的なやつです。

OP比較 左図:ブルームON 右図:ブルームOFF
エフェクト比較 左図:ブルームON 右図:ブルームOFF

Unity自体にもブルーム機能はありますが今回は簡易なもので良いので自作します。アルゴリズムとしてはほぼこれですが、更に簡易的な実装になってます。

高輝度抽出&ダウンサンプリングパスシェーダー

Blend Off

sampler2D _MainTex;
fixed4 offset;
float threshold;

fixed4 frag (v2f i) : SV_Target
{
    return max((tex2D(_MainTex, i.uv + offset.xy) +
                tex2D(_MainTex, i.uv + offset.zy) +
                tex2D(_MainTex, i.uv + offset.zw) +
                tex2D(_MainTex, i.uv + offset.xw)) * 0.25 - threshold, 0.0);
}

ダウンサンプリングパスシェーダー

Blend Off

sampler2D _MainTex;
fixed4 offset;

fixed4 frag (v2f i) : SV_Target
{
    return (tex2D(_MainTex, i.uv + offset.xy) +
            tex2D(_MainTex, i.uv + offset.zy) +
            tex2D(_MainTex, i.uv + offset.zw) +
            tex2D(_MainTex, i.uv + offset.xw)) * 0.25;
}

アップサンプリングパスシェーダー

Blend One One
BlendOp Add

sampler2D _MainTex;
fixed4 offset;
float power;

fixed4 frag (v2f i) : SV_Target
{
    return (tex2D(_MainTex, i.uv + offset.xy) +
            tex2D(_MainTex, i.uv + offset.zy) +
            tex2D(_MainTex, i.uv + offset.zw) +
            tex2D(_MainTex, i.uv + offset.xw)) * power;
}

どのパスのoffsetにもxyがMainTexの解像度の逆数でzwがそれを負にした値が入っています。このアルゴリズムだと縮小6パス、拡大6パスのたった12パスで非常に広範囲に光が溢れる表現ができます。ぼかし処理というのは半径が大きくなるほど指数的に重くなる処理ですが、よく使われる処理なだけに様々な方法で高速化が考えられていて奥が深いです。

余談

Unityはデフォルトでシーンを半精度小数テクスチャに描画しています。pngなどはRGB各8bitですが、半精度小数テクスチャではRGBが各16bitで、しかもIEEE754の小数点形式になっています。つまり、HDR撮影された写真のようなものということです。 そのため、ブルームのようなポストエフェクトが自然な形で実装できるのでめちゃくちゃ便利ですね。浮動小数テクスチャはWebGL2.0では標準サポートということらしいので、その辺も安心です。

カメラ

舞台裏。ゲームのカメラが切り替わると背景にちょうど星雲が映るようにワールド全体を回転させてます。

右はゲーム画面

また、空の回転ですが実際には空以外の全てを回転させてます。スカイボックス自体を回転させる方法もあるんですが、シェーダーの改造も必要で面倒だったのでやりませんでした。

表情

アセットに入ってます

SDユニティちゃんアセットには表情が6種入ってます。各表情をそれぞれ何%ずつブレンドするか調整することで色々な表情が作れます。

怒り+悲しみ=呆れ

しかし、これだけだと欲しい表情が出せないことがあります。例えば、デフォルトの笑顔の表情が下図なんですが、、、

満面の笑み

そうじゃないんだ!かわいいけど・・・!ブレンドをどういじっても欲しい笑顔が手に入らなかったので今回はBlenderを使って改造することにしました。

表情改造

まず以下の手順で元となるモデルを読み込みます。

  1. Blenderを開きファイル>インポート>FBXを選択
  2. Unityゲームフォルダ\Assets\SD Unity-Chan Haon Custom\Models\Parts\Face_01.fbxを開く
  3. シーンにデフォルトのCubeが置かれてるので消しておく
    こういう画面になる

画像の①にある緑のアイコンを押すと、②の所に各表情が一覧表示されます。目元は笑顔のものでいいんですが口は閉じた状態で微笑んでてほしいんですよね。なので多分笑顔+無表情の口元+口角上げが出来ればそれっぽくなりそうな気がします。

検索するとそのものずばりの解説記事がヒット!ちょっとBlenderのバージョン古いですが、こちらの説明を参考に笑顔と無表情を合体させます。 bluebirdofoz.hatenablog.com

  1. まずシェイプキーで「sml」を選択します。
  2. 「編集モード」に切り替えます。
  3. 「透過表示を切り替え」でオブジェクトの裏側も選択できるようにします。
  4. 投げ縄ツールで以下のように選択します。
  5. 「頂点」>「シェイプキーからブレンド」を選択します。
  6. 左下に入力欄が出るのでこのように設定。
  7. ツールバーでこの状態にしておきます。
  8. 後は口角付近の頂点をつまんでいい感じに調整します。
  9. 工事完了です・・・

あとはFBXでエクスポートするだけなんですがここが結構鬼門です。

  1. シーンコレクションでFace_01を選択しておく。
  2. 「オブジェクト」>「適用」>「全トランスフォーム」を実行する。
  3. 「ファイル」>「エクスポート」>「FBX」を実行する。
  4. どうだったか忘れたけどエクスポート設定のトランスフォームで「前方」と「上」をいじらないとダメだった気もする・・・
  5. 出力したFBXをUnityでインポートします。インポート設定はFace_01の設定を参考にしてください。
  6. 「Assets/SD Unity-Chan Haon Custom/Prefabs/Face/Face_01 BlendShapes.prefab」のメッシュを今回インポートしたものに差し替えて完了。

>>>
見たかった笑顔

カットシー

今回せっかくUnityなのでOPムービー的なものを入れてみました。

カメラワーク

MainCameraにAnimationをアタッチしてカメラワークのキーを打ちました。

カメラ演出

キャラ演技

キャラはアセットに入っていた待機モーションを垂れ流していますが、それ以外に空を見上げるモーションを再生してます。待機モーションと干渉しないようにクビの骨を追加してそっちにキー打ってます。視線もほんのちょっと上に動かしてます。

見上げる

あとほとんど分かんないと思うけどよーーく見ると目の中の光も上下にうるうる動かしてます。

うるうる🥺

ちなみにこのうるうるはスクリプトで動かしてるんですが、Update()でやっても再生しているモーションで上書きされます。首と同様に干渉しない骨を追加してそちらで動かしてもいいんですが、公式ドキュメントのイベント関数の実行順序を見るとLateUpdate()でやればいいことがわかります。イベント順序周りはかなり複雑なんでどれを使うかは要注意ですね。

マルチエンディング

今回スコアによって3つのエンディングがあります。一応アドベンチャーゲームっぽさを出すためにおまけ要素として入れたんですが、面白さとは関係ないしコスト無駄に高くて虚無りかけましたね…。とは言えBlenderでモデル改変したりするスキルが身についたので良しとします。

がっかり

サーセン
10点以下のエンディング。ほぼ放置プレイ時用。この子とは初対面なのにめっちゃ呆れられます。

よそよそしい

アッハイ
200点以下のエンディング。凡プレイだと大体これ。社交辞令の感謝を述べるだけで距離感凄い。

デレ

やったぜ。
200点越えのエンディング。仲良くなれた気がする…!ほほも染めよるね。

Unityroomにも出してみる

せっかくUnityで作ったのでUnityroomというUnityゲーム専用投稿サイトに登録してみました。

unityroom.com

プログラム対応

基本的にはニコ生ゲームのままで大丈夫ですが、一部プログラムで対処が必要です。

Akashic機能にアクセスしているところで事前に呼び出し可能かチェックするようにします。前回の記事で書いたjsプラグイン機能を使って以下のようにすると良いでしょう。

mergeInto(LibraryManager.library, {
    IsNiconico: function(){
        return typeof(g) != 'undefined';
    }
});
[DllImport("__Internal")]
private static extern bool IsNiconico();

ランキング対応

Unityroomは独自のランキング機能をサポートしてます。アツマールのランキングに似てますが、ランキングがリアルタイム更新されるので人が集まってるゲームだとプレイ中にランキングが移動して面白いです。

実装も10分くらいでできてめちゃくちゃ簡単なのでUnityroomに出された際にはぜひ対応した方がいいですね。

反省会

半年前の春イベで3Dゲームは動かない人いるから遊ばれたいならやるなと書いておきながらまたやっちゃいました。今回はUnityツールの宣伝がメインだったんでいいっちゃいいんですが、Unity製だと動かないということも大々的に知られてしまった気がするんで使う人いるか心配です。

ただやっぱり腐ってもUnity。開発速度がすごいですね。土曜から始めて水曜でマルチエンディングまで作れたのでゲームエンジンとしては十分優秀と言えるんじゃないでしょうか?まぁ正直触ってる時は重くてストレス溜まることが多いんですが、、、分からないことがあっても調べれば大抵20分くらいで解決するしなんだかんだでやっぱ使えると思います。もちろんニコ生ゲーム以外も簡単に作れますし(むしろニコ生ゲームがおまけだし)。

ゲーム性についてはもっと手書きで星座を描く感触を出してみたかったんですがそこまで実現するにはあと1週間必要でしたね…。ルール説明もまた手抜きになっちゃいました。理想を言えば『任天堂がこのゲームを作ったらこんな説明画面にしそう』というものを作れればいいんですが、、、そんな気力は無い😩

あとBGM!今回使わせていただいた曲ももちろんいい曲なんですが、作ってる間はもっとドラマチックなの想像してました。バンプの天体観測はもちろん君の知らない物語もいいですよね。ただもちろんそんなの使えるわけもなく笑。次に考えてたのはしゃーろ兄貴の星空.flvだったんですが残念ながらこちらもどこにも利用可能な配布は見つかりませんでした。残念。おかげでBGMさがしは今までで一番苦戦しました。

反省点も多いですが、今回もまた新しい事に色々挑戦出来たので面白かったです。ニコ生ゲームは制約きついんで次作る時は他でもいいなーとは思いました。

ニコニコ自作ゲームフェス2023 ニコ生ゲーム特別賞

こマ?完全にネタで言ってたのにほんとに受賞するとは・・・最後まであきらめてはいけない(教訓)

site.live.nicovideo.jp

後から結果発表の生放送も確認してみたんですけどゴー☆ジャスさんがプレイするゲームが予め5本に絞られてたっぽいですね。つまり予め運営が何らかの基準で5本選んでたんでしょうけど、そこに残ってたこと自体が凄く意外でした。

というのも今回の冬イベからスマホでプレイできないゲームはエントリーできませんと書いてて完全にワイのせいやんけ😅と思ってたので当然このゲームも選考から外れるもんだと思ってましたが何故か残していただけました。

うーん…真意はわかんないけどとにかく感謝ですね。

ちなみに3月21日に本社?で表彰式が行われてたんですが自分は普通に仕事で行けませんでした。しょうがないね。

追記

4/2 賞品類も届きました。

トロフィー ガラス製でずっしりしてる
新人賞のみかんゼリーは3ヵ月かかったので今回も忘れた頃に来るかと思ったんですが今回は速かったですね。と思ったけど授賞式に出席したらその場で商品手渡しだったらしいのでモノは用意してたのか。ただ特別賞は20日に放送枠の中で発表だったのでどっちにしろ後日送付になってたかも。じゃなかったら放送時点で決まってたことになるし…。

元々秋イベ落選の保険として参加(タグ付けるだけ)してたけどまさかこんなことになるとは。やってみるもんですね

*1:なぜニコニコ運営を挑発したがるのか・・・

*2:ラックさんの2Dコインプッシャー

*3:今にして思えばお城崩しとネタ被りするところでした笑

*4:とは言え恋愛ゲームとかノベルゲームはよく分からないので雰囲気だけ真似ることにします

Unityでニコ生ゲームを作ろう

今回は技術記事ですがフワフワした点も多いのでこの文体でいきます。

Unity in ニコ生ゲーム

Motivation

これまでc++を使ってニコ生ゲームを開発する手法を探ってきましたが、これを応用すればUnityのWeb出力したゲームも原理的には動かせそうだなと思ったので色々と実験してました。別に自分でUnity使いたいわけでもないんですが、ニコニコが公式でツクールMVからのニコ生ゲーム出力に対応したというのを聞いてなんとなくUnityぶつけたろかなと思った次第です。

成果

試行錯誤した結果なんとか動いたので今回はその成果について「使い方」と「技術面」の2点についてまとめようと思います。まず、何が出来るようになったのか簡単にまとめます。

  1. Unityから出力したWebGL2ゲームがニコ生で実行できる
  2. 画面サイズ合わせ(シアターモードや全画面表示・スマホの回転などに追従)
  3. マウス・タッチイベントに対応
  4. ランキングゲーム対応(マルチは非対応)
  5. ニコ生プレイヤーの音量調整に対応
  6. 再生速度追従は非対応

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
  • Build/Build.loader.js
    • 上記ファイルを読み込みゲームを起動するためのスクリプト。外部に関数を公開している。

loader.jsに定義されたcreateUnityInstanceという関数を呼び出すことで他のファイルのロードやアセットの展開など非同期で実行してくれます。ただしloader.jsとframework.jsは、そのままではニコ生で動かせないので手を加える必要があります。

---ここからは検証不足なので大分推測が混ざってます。---

framework.jsは、emscriptenの出力(+Unity拡張)なためゲームを作りこんでいく中でたまに内容が変化します。その都度手作業で出力したjsを書き換えるのは現実的ではありません。そこで、スクリプトの書き換え処理を自動化することにします。

とは言え、どう変化するか分からないスクリプトを部分的に書き換えるとなると単純な文字列置換や正規表現では対処しきれそうにありません。そこで、スクリプトを抽象的な構造(構文木)として読み込んでそれに対してパターンマッチングを行い、より精度の高い置換を行うことにします。

今回は構文解析Esprima.NETというライブラリを使用しました。*1esprimaはjsコードをパースして抽象構文木にしてくれるライブラリです。一応ツリーを操作するAPIもあるので置換もできます。*2

スクリプトのパターンマッチング

ツリー構造のドキュメントに対してパターンマッチング及び置換を行う例としてXPathがあります。XPathXMLドキュメント用の正規表現のようなもので非常に便利です。

今回はそれには遠く及びませんが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に圧縮展開系の機能があるんですよね。

developer.mozilla.org

自前のものもwasmで実装してるのでそこそこ速いはずですが、さすがにネイティブ実装で動く標準APIの速度には全く勝てません。しかも、非同期処理にも対応していてブラウザが硬直することがないのでどう考えてもこれを使うべきでした。

ちなみに今回のツールではgzip展開を自前で処理していますが、通常は以下のページにあるようにサーバー側に設定さえしておけば何もせずともブラウザが勝手に解凍してくれます。流石にニコニコのCDNサーバーの設定をいじることはできないので今回は使えないテクニックです。

docs.unity3d.com

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の基礎知識が必要です。

developer.mozilla.org

要は音源データや出力先をノードという概念で表し、それらの接続によって柔軟なオーディオシステムを構築しようという思想です。

基本的な仕組み

最終的に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: () => { }
};

dataUrlcodeUrlに設定している値は以下の記事でも紹介したやり方ですね。

isobe-yaki.hateblo.jp

dataやwasmはfetchで読み込むのでそのためのURLを設定しています。

frameworkはもともとframeworkUrlというプロパティでURL文字列を設定するところだったんですが、今回はスクリプトの読み込みをしたいわけではないので思いっきり書き換えてます。ちなみにこのframeworkに設定しているfunctionのthisはconfigとは別のオブジェクトを指しているので要注意です。

streamingAssetsUrlは未使用。

companyNameproductNameproductVersionはそのままですね。多分使われてないと思います。

matchWebGLToCanvasSizeはデフォルトがtrueなんですが、これがtrueだと勝手にcanvasサイズ変えられちゃって大抵ゲームが見えなくなるのでfalse必須です。

showBannerも使ってないので未設定でも大丈夫かもしれません。

キャンバスの作り方

Unity用のキャンバスはAkashicとは別に独自に用意します。 c++で作っていた時はオフスクリーンのキャンバス*5を作成してそこへWebGLで描画した後、それをAkashicのキャンバスに表示していました。

Unityでもその方針でやろうと思ったのですが、Unityの場合描画先のキャンバスとタッチイベントを受け付けるDOM要素が同じである前提の作りだったため、自前で作成したキャンバスをDOMツリーに登録してこれを直接表示することにしました。

c++でのキャンバス(左図)とUnityでのキャンバス(右図)の違い

そのために以前の記事の手法でAkashicキャンバスの上に自前キャンバスを重ねています。

isobe-yaki.hateblo.jp

細かい注意点としてUnityキャンバスには何かしらIDを設定しておく必要があります。Unityのスクリプト内でキャンバスのIDを最初に取得して後でそのIDで要素検索をしてキャンバスを取り出すといった操作をしているためです。

ランキングゲーム作る

実際にランキングゲームを作る時はg.gameオブジェクトへのアクセスが必須となります。C#からjsにアクセスするための方法はこちらの記事がよくまとまっています。

qiita.com

上記を参考にスコアの設定関数と乱数シードの取得関数を定義してみました。

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と別で作る必要があります。

isobe-yaki.hateblo.jp

普段はUnity上で開発しているのでそこでマルチの動作確認もしないといけないと思いますが、そのためにローカルサーバーを立てたりしないといけないし、Unityじゃ複数起動たぶんできないし、サーバー処理をニコ生ゲームサーバーでも動くようにしないといけないし、とハードルがやたらと多いので個人的にはお勧めしませんね。

ファイルサイズ

UnityでWebGL出力すると最小で数MB、そこそこ作りこんでると20MBくらいは軽くいくと思います。(gzip出力時)

しかし、数か月前ならつゆ知らず、今はニコ生ゲームのファイルサイズ上限は30MBに増えているので案外収まると思います。ただ、どうしても収まりきらないという場合には禁断の限界突破技が存在します。

ゲームファイルはzipでまとめてアップロードしていますが、そのままサーバー上に置かれているわけではなく、CDNサーバー上に展開され、各アセットファイルに固有のURLが付けられています。CDNサーバーですからそんな複雑な処理はしてなくてこれらのURLにアクセスすればブラウザからでもDLできちゃうわけです。

つまり、別のゲームのアセットであってもURLさえ合っていればアクセス出来てしまうのです

アセットのURLを知る方法はさんざんやってきているのでそれを使えばいいですね。あとはデータ置き場用のゲームを投稿して本体のゲームからそのアセットを参照すれば実質容量制限は無限になります。

ただこの方法とてつもないデメリットがあって、何かというとデータアセットのURLを調べるのが超めんどいということです。実際にアップロードするまでURLは決まらないですし、決まったURLはゲームを起動してみないと調べられないので、しょっちゅうデータを更新する必要がある場合絶望的に手間がかかります。

まぁ禁術なので基本使わないと思いますが、使う場合はかなり工夫を強いられそうですね。

*1:本家esprimaはnode.js向けのライブラリらしいんですがC#でツールを書きたかったので.NET版を採用しています。

*2:正直操作のAPI使いにくい・・・

*3:ただしパターンをコンパクトなスクリプト表現にするところまではやっていません。esprimaだと実用レベルに出来なさそうだったので今回はAPI化までにしてます。

*4:そういうのやってくれるライブラリありそうだよね

*5:DOMツリーに追加していないcanvas要素。画面に表示されないしタッチイベントも受け付けない

【小ネタ】ニコ生ゲームでバイナリ通信

マルチゲームで画像データなどをやり取りするためにg.game.raiseEvent()でバイナリデータを送りたいことがある。それ自体は何も難しくなく、送信データに[42, 16, 120, ...]のようなArrayオブジェクトやUint8Arrayオブジェクトを含めればいいだけである。しかしこの方法だとニコ生ゲームの仕様上かなりのオーバーヘッドが発生してしまうのだ。今回はその原因と対策(?)を紹介する。

ニコ生ゲームの通信仕様

ニコ生ゲームは配信ページ内のiframe要素の中で実行されている。しかし、WebSocketでサーバーと通信をするスクリプトはその外側に存在している*1。そのためイベントを送信する際はiframe内からpostMessage関数で外側のスクリプトに通信データを投げ、それを外側でmsgpack(MessagePack)というライブラリを用いてバイナリ化し、ソケットに書き込むという手順をとっている。受信はその逆である。

この過程にどういう問題があるのかについて説明するためにいったんpostMessageMessagePackというキーワードについて簡単に説明する。

postMessage

iframeの中と外は互いのコンテキストを自由に参照できないようにしてセキュリティを確保している。その安全性を保ちつつ別のウィンドウと通信をするのがpostMessage関数である。postMessage関数は構造化複製アルゴリズムを用いて送信データをディープコピーしているのだが、このアルゴリズムではオブジェクトに含まれる関数やクラス情報は削ぎ落され、純粋な配列、連想配列、数値、文字列といったデータのみがコピーされるので、送信先処理が渡ることがなく安全というわけだ。

MessagePack

MessagePack - Wikipediaから引用。

MessagePack(メッセージパック)は、バイナリ形式のデータ交換用フォーマット。配列や連想配列などの単純なデータ構造を表現できる。可能な限りコンパクトでシンプルになることを目指している。C言語C++C#D言語Erlang、Go、HaskellJavaJavaScriptLuaOCamlPerlPHPPythonRubyScalaSmalltalk、Swiftなどのプログラミング言語の実装が存在する。

ニコ生ゲームは各クライアントとサーバーの通信量を極力減らすために上記ライブラリでjsonをコンパクトなバイナリ形式に変換してから送受信している。

MessagePackではBuffer型オブジェクトをバイナリデータとして扱うように実装されている。Buffer型はnodeのクラスなのでブラウザで使う場合はポリフィルを定義する。実際iframeの外側にはBufferクラスが定義されているようだ。

バイナリ通信の問題点

生バイナリの通信はそもそも可能か?

ニコ生ゲームの通信処理をブレイクを張って追っていくとオブジェクトをバイナリに変換するための関数に辿り着く↓

オブジェクトのパース関数

ここで、isBufferという関数を呼び出しているがその実装は以下のようなものだ。

Buffer.isBuffer = function isBuffer(b) {
    return b != null && b._isBuffer === true && b !== Buffer.prototype
}

この条件を満たすオブジェクトは無加工でバイナリデータをそのままパッキングしてくれるのだが、果たしてこの条件を満たすことは可能だろうか?

まずBuffer型だが、iframeの外に定義されているのでそのままでは使えない。しかしこれに関していえば自分でポリフィルを定義すればいいだけなのでBuffer型オブジェクトを作ること自体はできる。

次にpostMessageだが、オブジェクトのクラス情報は通過できないので、b !== Buffer.prototypeがどうしても満たせない。

よってiframe内から生バイナリ判定されるオブジェクトは送れないことが分かる。ニコ生ゲームではmsgpackのバイナリ通信機能が封じられてしまっているのだ。(ドワンゴよ・・・)

他のオブジェクトだとどうなるか?

では、BufferをあきらめてUint8ArrayをMessagePackでエンコードした場合どのようになるだろうか?どうやらTypedArrayなどは配列扱いになってしまうようだ。配列のエンコード処理はここ、uint8はここにある通りだ。もし[255, 0, 80, 128]という4byteのバイナリデータをエンコードするとこうなる。

配列長 要素0 要素1 要素2 要素3
0x94 0xCC 0xFF 0x00 0x50 0xCC 0x80

内容はともかくとして、7byteにエンコードされた。元のサイズから何倍に膨らむかは簡単な計算で見積もることができる。

uint8は127以下で1byte、128以上で2byteにエンコードされる。

ランダムなバイナリデータの各バイトが127以下になるかどうかは平均で50%と考えると、元データに対して平均1.5倍に増加すると見積もることができる。

1.5倍は結構大きい。さらに問題なのは配列のエンコード処理がなかなかに重いということだ。TypedArrayなので各要素はすべて同じ型であると分かっているのに配列カテゴリとしてエンコードする(=Array型と同じエンコード)ので、配列要素1つ1つに対して型の取得や連想配列によるエンコーダの逆引きなどを行っており、バイナリデータが大きくなるほど処理のオーバーヘッドが膨大になっていくことが予想できるだろう。

// 配列要素1つ1つに対して重そうな処理が走る
for(const e of [...]) {
    TYPES[typeof e].encode(buffer, e);
}

結論として、Uint8Arrayでバイナリ通信は可能である。ただし、送信データは約1.5倍に膨らみ、エンコード処理にかなりのオーバーヘッドが発生するので、あまり良い解答ではなさそうだ。

文字列はどうか?

コードを読んでみるとutf-8 stringのエンコードがBufferの次にマシそうである。 実際に実行されているコードは以下のようなものだった。

// 一部を抜粋
function utf8ToBytes(string, units) {
    units = units || Infinity;
    var codePoint;
    var length = string.length;
    var bytes = [];
    for (var i = 0; i < length; ++i) {
        codePoint = string.charCodeAt(i);
        if (codePoint < 128) {
            if ((units -= 1) < 0)
                break;
            bytes.push(codePoint)
        }
    }
    return bytes
}

以下に生Uint8Arrayとutf-8 string版の比較を載せる。

Uint8Array utf-8 string
データ長 約1.5倍 約1.33倍
msgpack
オーバーヘッド
かなりでかい 比較的小さい
事前処理 不要 BASE64エンコード

バイナリデータを文字列化する方法として今回はBASE64を使用した。msgpackでutf-8エンコードする際ASCIIコードのみの文字列でないといろいろ面倒なためだ。比較した結果を見ると正直微妙なところである。データの内容によっては生のUint8Arrayの方が小さくエンコ―ドされることもある*2ため必ずしもutf-8 stringが優れているとは言えない。実測まではしていないのでどちらの方がより効率的かは分からないが選択肢にはなり得るのかなという感じ。

最後に

今回はマルチプレイのゲームにおいてバイナリデータを大量に通信したい場合BASE64エンコードした方が軽くなるかもしれない、という話だった。

そもそも、そんなにバイナリで通信をしたいのか?という話だが、これがc++で開発しているとめっちゃ使うのだ。c++の場合メッセージは構造体で定義しているため全てのメッセージは1つのバイナリデータとして扱える。であれば、json化せずバイナリのまま通信した方が効率的だということで今回の話につながった。なので、普通にjs/tsで開発している人はあまり気にしなくていいかもしれない。しかし、通信処理が裏で何をしているか知っておくことはきっと何か役に立つだろう・・・🤔。

というかドワンゴがちゃんと生バイナリで通信できるように改造しておいてくれればこんな事せずに済むのだが。

*1:もちろんゲームスクリプトからマルチサーバーに好き勝手通信されないためである

*2:0が多い場合など

ニコ生自作ゲーム新人コンテスト

今年の4~6月にニコ生自作ゲーム新人コンテストなる企画が開催されていました。

blog.nicovideo.jp

アツマールも閉鎖するし、クリエイターを少しでもニコ生ゲーム側に誘導する狙いの企画だと思います。あとは、クリエイターズキャンプの課題作品をお披露目する場の役割もありますね。

というわけで今回このコンテストに出してみたので振り返っていこうと思います。

応募?

新人って感じでもないんで全く出す気なかったんですが、よく見たら審査部門に『ベテラン部門』と『未完部門』というのもあるようです。新人部門とベテラン部門は単純にそのアカウントで公開したことのあるニコ生ゲームの数が2個以下か3個以上かの違いなだけのようです*1。未完部門というのは未完成作品でもいいから応募してみてねという部門のようです。

自分は既に3作を公開状態にしていたので*2自分が出せるとしたらベテランか未完ですね。ただ、怖い部屋3Dで結構燃え尽きてたしこの時期リアルがそこそこ忙しかったので新作なんか作る暇もなく、、頭の片隅に入れつつも何もできませんでした。

ちなみにベテラン部門の賞品は大賞が5万円で、大賞と優秀賞両方で公式ゲーム制作のオファーがついてきます。前者はいいとして後者が困るんですよね。結構忙しくなったりすることもあるし頼まれても作る気起きないだろうし自分なら断ってしまうだろうと、だったらもっとやる気のある方に受賞してもらった方がいいのでオファー付きの賞には応募できません。一方で、未完部門は高級みかんジュースorゼリーとのこと。ややふざけてますが、果物ゼリーを年中摂取してる自分としては「悪くないね🤤」という感じ、食べたら終わりなので後腐れがないのも良いですね笑。また、上記3部門に応募すると勝手にエントリーされる審査員特別賞みたいなものが何部門かあるようですが、こちらの賞品はスタッフの褒めちぎりだそうです。これも個人的にはちょっと困惑しちゃいそうです。一応希望者のみとなってますが断るのもちょっと気まずいですよね?

そして、出そうかどうか迷ったまま締め切りの3日前になってしまいました。ちなみに今回は締め切りが金曜日なので平日仕事の自分は何かするにしてもほとんど時間がありません。

突然蘇りし子供の頃の記憶――。夏休みの宿題、最後まで残した読書感想文や自由研究をどうにでもなれ!と適当に書き上げて出していたあの熱い(?)気持ちを思い出します。

やっぱり心のどこかでイベントにはちゃんとしたもの出さなきゃ、変なもん出したくないみたいな気持ちがあったんでしょうね。そのせいで、変に参加を躊躇してたんですが時間の無さが自分に赦し妥協を与えてくれました😇。

「そもそも狙うは未完部門・・・はなからゲームとして完成していることは求められてないんだし、ええい いったれ!

こんな心境でした。結局最後はいつもの"勢いだけ"になってしまいました。


今回も時間が無いのでまたしても3D迷路(仮)を転用してゲームを作りました。その名も『ニコニコ迷宮』*3。それぞれの内容を軽く紹介します。

3D迷路(仮)

3D迷路はwasmやWebGLやマルチゲームの技術的検証をするためのプロジェクトでした。なぜ迷路かというと「簡単に実装できて」「オブジェクトが大量にあって」「大人数の検証もしやすい」からです。内容としては3D空間の迷路を1人称視点で歩き回れるだけでゴールも無くゲームの体も成してません。しかし、技術的にはかなりいろんなことを詰め込んでいるのでその辺の凄さは分かる人には分かってもらえると思います。

以下、過去のツイートから引用。

そこからニコニコ迷宮へ

※これは現ニコニコ迷宮

3D迷路はゲームと言うより技術デモだったのでゲーム性が皆無です。これをあと2日でどうにかゲームっぽく作り変えるにはどうすればいいか、頭の中で遊ばれているところをイメージしながら案をひねり出します。

まず描画負荷検証の目的で配置していた壁なんですが、マルチゲーム的には他人の動きが見えてた方が面白いし、そもそも迷路として難易度が上がりすぎだと思ったので取っ払っちゃいました。で、そうすると画面がスカスカになって真っ黒の空ばかりになってしまうんですが、そもそも公式のニコ生ゲームとかって背景透過が多いですよね?背景作りこむくらいなら配信画面見えてるほうが見た目面白いし、楽だしいいじゃんということで背景も取っ払ってしまいました。ついでに床も光の床っぽいイメージになるようにデザインし直して綺麗な見た目にしました。ちなみにデフォルトのおっさんアバターの小汚さが浮いてしまってますが、これはもともと3D迷路の風景を砂漠の遺跡っぽくする予定だったので、アバターアンチャーテッドを参考にした学者兼冒険者風にしていたためです。

次にゴールですが、今まではただゴールに球体置いてるだけでゴール判定すらしてなかったんですが、ちゃんとサーバーで判定して、順位も決めるようにしました。それまでは実験レベルでしかなかったサーバーwasmでしたが、このためにちゃんとロジックも実装して実用レベルまで持っていきました。

あとはランクボードですが、先着順に画面右上から名前が並んでいくだけというかなり雑な物しか間に合いませんでした笑。のちのバージョンアップでかなり演出強化はするんですが、この時はもう演出なんか作りこんでる時間は無かったのでしょうがないですね。ともかく、これでギリッギリのゲーム性は確保できました(白目)

結果

そして締め切りから3か月近く経った8/22。ついに結果発表されました。

blog.nicovideo.jp

応募作は41作(少なっ)。各部門にどれだけ応募があったか分かりませんが、ニコニコ迷宮は狙い通り未完賞を受賞することが出来ました!!🎉

選考コメント

3Dの迷路で参加者たちがいっせいにゴールを目指します。まず見た目がインパクト大。分かりやすいゲーム性で誰でもすぐに遊べます。視点を動かし、ゴールの場所を見つけて、どこから行けばいいのか考えたり、ライバルが移動しているのを見てあせったりと、シンプルながら熱くなる要素があります。まだむきだしの型枠のみではありますが、マルチゲームとして賑やかに楽しめる作品となる可能性を感じました。

選考コメントを見るに技術的な評価はあまり無く、ラスト2日でやったゲーム性の改善が割と効いたみたいです。完成度は低いのに、ちゃんと完成させたときの「可能性」で見てるのがいいですね。一応アイテムとかアクションの構想だけはいっぱいあってそれらが入れば賑やかなゲームになるイメージだったのでそこを評価いただけたっぽいです(ほんとにやるとは言ってない)。他のゲームも大体そんな感じっぽいですね。

感想

今回初めて参加するイベントでしたが、季節イベントと違って応募の3~4か月後に発表されるまでただ待つだけなのでなんかピンと来ないですね。まぁ、ハラハラしないで済むのはいいですが😓

多分クリエイターズキャンプとセットの企画と考えた方がいいんでしょうね。今回の応募作がどれくらいキャンプ生のものだったか分かりませんが。 というか他にどんな作品があったのかも分からないんで地味に気になりますね。(見落としてるだけ?)

まぁそれはともかく賞品が楽しみです。まさかとは思うけどゼリー1個だったら二度と出ないからな!頼むぞ!(いや、普通に次回以降出ない気がするけど・・・)

追記

結果発表から約3ヵ月。ちゃんと高級ゼリー届きました!中に丸々1個みかんが入ってるんですが味が尋常じゃなく濃くておいしかったです^p^

*1:かなりいい加減な分類ですね・・・ベテランが新人賞狙いで新たにアカウント用意しても新人枠で出せちゃうだろうし

*2:1個は実験用でしたがそこは考慮されるのか不明だった

*3:ひねった名前より直球の方がいいかなと思ってる

温泉卓球振り返り

卓球娘

夏のゲームイベント参加

やってきました「ニコニコ自作ゲームパーティー夏」の季節。今回は今までより大分早めに参加を決断してましたよー。ニコニコのゲームイベントはこれまでに「春」と「新人」2回参加してるんですが、どちらも3日前とかから作り始めてたのでまぁ大変でした。一応それにも理由はあって、冷静になって冷めちゃう時間を自分に与えないという効果がありました(勢いは大事😎)。とは言えもうちょい余裕があってもいいだろうということで今回はなんと締め切りの一ヶ月も前から準備を始めました。

ああでもないこうでもない

一ヶ月後の締め切りに向けて何が作れそうか、何を作りたいかぼんやり考え始めます。最初の方に固まりかけたアイデアは「」のようなギミックを使った直感系ゲームです。ドラッグで鞭を振るうというアクションはまだニコ生ゲームにないんじゃないでしょうか?あるかな?滑らかに動く鞭の描画にキャンバス2D使っちゃおうかなとか悪だくみしつつ3ステージ構成で~とか内容もしっかり考えてたんですけど、最初に書いたようにじっくり考えすぎて「なんか違う気がする」と思い始めてしまい、結局試作版すら作らずにこのアイデアはボツになります。

そこで、次にネタ探しを始めます。無料ゲームランキングみたいのを見ていて、普通に面白そうだったのが「Twisted Tangle」というゲーム。

www.youtube.com

いわゆる紐解きゲームってやつらしいです。こういうの好きだし作りたい欲が刺激される!

でもこれもニコ生ゲーに合ってるかといわれると課題がありそうな気がします。今はまだどうまとめればニコ生ゲームになるか思いつきませんが、その内気が向いたら作るかもしれません(作らないかも)。

結局ニコ生ゲームって一見何でもありのバーリトゥードのようで*1遊ばれる傾向に制約を感じます。牛乳先生(なわとびずんずんの作者)がブログ(この記事切れ味鋭くて面白いです)で書かれているように説明読まずに遊べる定番ゲーなどが強いなというのは感じていました。もちろんそれ以外も結構遊ばれてますが、「圧倒的に」という意味ではトランプ系なんかが支配的です。この環境に風穴を開けるような新しいアイデアというのはまぁなかなか難しいんじゃないでしょうか?

こんな感じで、悩むだけ悩んでモノは1つもできずに時間だけが過ぎていきます。じゃあその間、全く手を動かしていなかったのかというとそんなことはなくひたすらブログを書いていました笑

実は6/2から1ヵ月以上止まっていたブログの更新ですが、7/6の投稿を皮切りに7/24までに5つも書きあげてしまいました。それも重い内容ばかりでよくこれだけ書いたなって感じですね。昔から忙しい時ほど関係ない作業が捗るタイプだったんですが、今回はそれがブログの執筆に向いてしまったようです😅

あきらめかけたその時

もう夏イベのアイデアも全然固まらないし今回はパスだな~、と半分あきらめてニコニコ迷宮のバージョンアップとかやってました。もう完全にそっちに集中しちゃってます。

しかし

ニコニコ迷宮もブログ執筆も一段落して一息ついていた7月28日。なんとなく昔やったことのあるゲームを思い返していたらふと、とある卓球ゲームを思い出します。

そのゲームとは・・・「嗚呼神速の卓球よ」です。

画像見つからなかったんで前作?の画面

結構覚えてる人いるんじゃないですかね?いないかな?このゲームをイイ感じに紹介してくれてるブログがあったので引用させていただきます。

「嗚呼神速の卓球よ」も疑似3D的な卓球ゲームで、モードが音速、高速、神速と速さ違いの対戦が3つとハイスコアを目指すラリーの4つのモードがありました。

ストレートやクロス打ちで返してロブが来たらスマッシュで打ち返す。単純だけど本当に卓球やってるみたいで面白かった。スマッシュを打った時に集中線が入る演出もあり決まるとめちゃくちゃ気持ちよかったんです。

ちょっとした暇つぶしや寝る前に1プレイなんてのにもってこいのゲームでしたが、私はハマリすぎて休日に朝からやってて気づいたら夕方になってたなんて事もありました。大げさに言ってるんじゃなくてこれマジの話なんです。

引用元:ガラケー時代このゲームがあったからauをやめられなかった話 - こうですか?わかりません

いやほんとこの通りなんですよねー。こんなシンプルな画面でしかもテンキー操作なのに卓球の気持ちよさが感じられる。神ゲーです。

「疑似3Dで作るのおもろそうだしアセットもあんまり要らなそうだし、恐らく遊び方もわかりやすい・・・。そして期限まであと10日・・・これだぁ!!

思いついてから即決でした。振り返ってみるとやっぱ細かいこと考えずに勢いで走り出すのが大事だなーと思います。

とは言えだよ

そして7/29(土)。週末を利用して制作を進めていきます。しかし決まったのはあと9日で「操作が気持ちいい卓球ゲーム」を作るということだけ*2。ノウハウもアセットもまったくの0から作る新規ゲームです。製作期間はいつもより1週間くらい長いですけど、今回は0ベースなので余裕が無いのは一緒です。

「うーん、何から手を付ければいいんだ?しかし、動かないことには何も始まらない・・・。よし、まずアイコンから描こう!」

は?

え、そこから?と思われた方もいるでしょう。正直どこから始めたらいいかわからなかったんで一種のコンセプトアートとも言えるアイコンから手を付けました。ここを起点にゲーム内容も考えることにしたんです。このアイコンを描きながら決まったことは2つ。

  1. ドット絵スタイルのゲームにする
  2. 温泉♨

ドット絵スタイルは「嗚呼神速の~」のリスペクトですね。画像アセットの製作コストを下げる狙いもあります。温泉要素は「ゆる~く遊べるよ」という記号で敷居を下げる狙いがあります。また、卓球部のジメっとした感じや(卓球部の方ごめんなさい)競技卓球のとっつきにくさと違って明るいイメージもあるので、女性の方にもいいのかなーと思ってます。

とまぁ、こんな感じで今回の温泉卓球というゲームが生まれたのでした。

開発日誌

ここからは開発中に考えていたことなどを概ね時系列順に書いていきます。

画面レイアウト

怖い部屋3Dの記事でも書きましたが、自分の場合まず最大のボトルネックであるアセット制作から取り掛かります。今回必要そうな画像は

  1. 背景
  2. 卓球台
  3. ラケット
  4. 対戦キャラ

この辺りですよね。球は最初からキャンバスで描くつもりだったので含めてません。ラケットは1枚絵描くだけなのでどうにでもなるでしょう。背景と卓球台は画面レイアウト及びゲーム性に関わって来るので特に重要です。というわけで、まずレイアウトを切ります。

画面レイアウト

この時点で結構重要なことがいくつか決まってます。

まず解像度ですが、6倍表示くらいにしようと思うのでゲームのサイズは6で割り切れる1260×720を採用します。なので210×120の背景があればいいんですが、レイアウトでは260×120と横長にしています。これは球が激しく左右に振れたときにカメラがパンすることを想定しているからです。このことから結構激しい打ち合いもある、というゲーム性にまでつながってきます。

さらに独特なのが視点の向きです。卓球ゲームは世の中星の数ほどありますが、ここまで視点が水平なものは珍しいんじゃないでしょうか。これは偶然というか記憶違いというか・・・記憶の中の嗚呼神速の卓球よがこれくらい水平だったイメージなのでこうなったというだけなんですが調べたら全然違いましたね笑。ただ、1人称視点に近くなったおかげでより没入度が増したんじゃないかと思っているので結果オーライです。

ちなみに既に対戦相手(この時は腕まくりしてる男のイメージ)も描かれてはいますが、キャラまで描いたら作画コストやべーだろと思ってたので最悪ラケットが宙に浮いてるだけでいいかなとは思ってました。

レイアウト描き終えた時点で作業量の多さを察してげんなりしたのはここだけの秘密。

背景

レイアウトが決まったので背景のドットを打っていきます。しかし、背景に何を並べるか?これが悩ましい。ボツ案は手元に残ってないので文字とイメージ画像での説明になりますが、第1案ではリアルな温泉を想像して、自販機やゴミ箱、大浴場への暖簾などを入れようとしました。

https://bokoro.com/cms/wp-content/uploads/DSC05989-300x225.jpg
大浴場入口の自販機などのイメージ

しかしあまりにもダサいデザインになってしまったので即ボツでした。センスの無さに絶望しつつ、画像検索で色々参考にしてみます。いっそ夜のテラスみたいなとこでやるオシャ卓球もいいなぁなんてデザインそっちのけで妄想し始めます。

https://prtimes.jp/i/16770/5/resize/d16770-5-650829-6.jpg
夜のテラス

第2案として試しにテラス背景描いてみたんですが、視点が動いたときに屋根とかのパースがついてこないと不自然じゃん!という基本的なところに気づいてこちらもやめました。「奥行きのある背景はダメ」「別に露骨な温泉要素が無くてもいい」ということに気づけたので、それを活かして第3案をデザインしてみました。

温泉卓球の背景か?これが・・・しかしドット自体は結構上手く描けたしここらへんで妥協することにします。というのもこれ描き終わった時既に夜の8時越えてたのでいい加減切り上げないと先に進みません。コードもまだ一行も書けてないし!

ドット絵スタイルゲームのワンポイントテクニック

ここで一旦この背景を表示するプログラムを書きますが、今回のようなドット絵スタイルの場合小さい絵を拡大して表示するとぼやけたような見た目になってしまうので最初から拡大したドット絵を使っているゲームもあると思います。しかし本作では全てのドット絵は原寸のままで、実行時にスプライトのscaleで拡大していますがぼやけていません。キャンバスの拡大縮小フィルターの設定をちょちょいといじってやることで実現できます。手っ取り早く言うと最初に以下を実行するだけです。

// ※一応これらのプロパティアクセスは`in`などで存在チェックをしてからの方が安全です。
g.game.renderers[0].canvasRenderingContext2D.imageSmoothingEnabled = false;

g.RendererのインスタンスがContext2DRendererである場合canvasRenderingContext2DプロパティでCanvasRenderingContext2Dオブジェクトを取得できます。これのimageSmoothingEnabledプロパティにfalseを設定することでスムージングがOFFになり、ドット絵がぼやけなくなります。上記だと全ての画像描画がスムージングされなくなってしまうのでそれがまずい場合はg.Eを継承してそのrenderメソッドで実行すればそのエンティティの子要素のみスムージングをOFFにできると思います(未検証)。

class NoSmoothing extends g.E {
    // 他省略
    render(renderer, camera) {
        renderer.canvasRenderingContext2D.imageSmoothingEnabled = false;
        super.render(renderer, camera);
        renderer.canvasRenderingContext2D.imageSmoothingEnabled = true;
    }
}

卓球台

さて、お次は卓球台なんですが、実は当初ここで変わったことをやるつもりでした。 それはwasmでソフトウェアレンダラを書いて3D卓球台を表示です。WebGLじゃなくて自前でポリゴン描くやつですね。個人的に今回の開発のお楽しみポイントとして考えてました。

実際途中まで実装してて時間を忘れて楽しんでたんですが、急に冷静になります。待てよ、と。「台だけ3Dにしてるけど後々他のものも結局3Dにしなきゃってなるかもしれない。それでほんとに間に合うのか?駄目なら秋イベに回してもいいと思ってたけどホントにやるか?いや投げるでしょ!」と。流石にイケるかわからないことをここで始めるのはまずいんでレンダラづくりはやめて疑似3Dでやることにしました。危ない危ない・・・。(今回諦めたけどどこかでやりたい・・・!)

とはいえ卓球台の天板は視点移動に合わせて変形させたい。そこで以前から何処かで使おうと思っていたせん断変形で実装しました。

Renderer.setTransform()の第2・第3引数がせん断変形に関わる引数になります。詳しい説明はしませんがこれを使えば疑似的にパースのかかった平面のスクロールを表現できるので面白いです。

See the Pen ground by z0ero (@z0ero) on CodePen.

ただ、これだと今回のような低解像度のドット絵ゲームと若干相性が悪くて、通常気にならないレベルのドットのズレが目立っちゃうんですよね。妥協しても良かったんですが、そもそもこの天板縦が7ピクセルしか無いのでラスタースクロールの要領で1行ずつ7回drawImageをしても全然問題ない。というわけで結局「ソフトウェアレンダラ」から「ラスタースクロール」へとずいぶん素朴な実装になっちゃいました😅

台のドット

30日(日)からは球の実装を始めます。ここもかなり鬼門です。

ひとまず、3D空間上に球を表示する処理を書いてみます。球は100×100のSurfaceを作成し、そこにキャンバスAPIで現在の「見た目の半径」で円を描いて遠近感を表現しています。それを3D→2D変換した座標に描画すれば疑似3D球の出来上がりです。3D座標系から2D座標系への変換は全て疑似的なパースで計算しています。卓球台周辺ではそれなりに正確なように見えますが、ちょっと奥へ行くと球が奇妙なカーブを描いているように見えたりと大分歪んでいます*3。本来先にパースを決めるべきなのに先にドット絵を描いてしまったので、ドット絵からパースを逆算しないといけなかったんですがそれだと難しい計算が必要なのでインチキパースでゴリ押してしまいました。ここは、完全に反省点で球のスピード感なども狂ってしまうのでちゃんとやるべきでしたね・・・*4

表示ができたので次に挙動を実装します。まず、物理的な運動計算はしたくないと思ってました。なぜならAI側の実装や難易度調整が難しくなりそうだから、と、物理挙動でバグられるとどっちに点が入ったか計算がおかしくなりそうだからです。なので、打ったタイミングで着地点を先に決めて軌道はベジェ曲線かCatmull-Romスプライン曲線あたりで適当に補間しちゃおうかなと思っていました。 そうすれば、コリジョン判定も要らないし(ネットにひっかからない前提)、AIの強さ調整も簡単なはずです。もちろん物理挙動じゃないので変な動きではありますが、ニコ生ゲームだし多少はね?

ところが、試験的に重力加速を実装して台の上で球が跳ねるようにして、、とやってる内に意外と物理挙動でも思ったように制御出来そうな感じがしてきました。CPUのサーブも案外ちょうどいい感じになったし球の動きも自然な放物線なのでしっくり来ます。極めつけに座標AからBへ時間tで到達するための初速vを算出する関数というのを実装したんですが、これ一つで相手も自分もいい感じに打ち返しが実装出来るじゃん!ということに気付いたので物理演算で行くことにしました。

class Ball
{
    // 現在地から(x, y, z)へframeフレームで到達するための速度を求める
    targetTo(x, y, z, frame) {
        this.vec.x = (x - this.pos.x) / frame;
        this.vec.z = (z - this.pos.z) / frame;
        // yだけは重力を考慮して計算(積分)
        this.vec.y = (y - this.pos.y) / frame - GRAVITY * 0.5 * frame;
    }
}

上記コードを2次元で可視化↓緑の点と紫の点をドラッグで動かせます。(右下のdesmosってところをクリックすると詳しく見れるよ)

コリジョンは最後まで手を抜こうとしたんですが、結局ネットにかかるかどうかの条件を考えるのが面倒過ぎたので普通に球vsAABBのコリジョン関数を実装し、卓球台とネットと対戦相手の当たり判定を行うことにしました。跳ね返りなども反発係数を設定して計算しているのでほぼ物理エンジンになってしまったわけですが、結果的にはバグ挙動も無く*5リアルな球の動きも得られたので早めの方針転換が功を奏したと思います。

コリジョン(クリックで展開) gist.github.com

法線n、ベクトルiがあった時に反射ベクトルvは以下で求まる。

v = i - 2 * n * dot(i, n)

反発係数eも含めるとこう

v = i - (1 + e) * n * dot(i, n)

今回はAABBだけだったので自前で済ませましたがもうちょっと複雑になってきたら出来合いの物理演算ライブラリを使うのがいいと思います。

公式規格

卓球台や球のサイズはちゃんと規格のサイズに合わせています。台は274×152.5cm、高さは76cmで球の直系は44mmです。卓球詳しい人ならおや、と思うかもしれませんが、競技における球の直系は40mmとされています。44mmはラージボールと呼ばれる規格で初心者やお年寄り向けの言わば軟球テニスのような球です。今回は温泉卓球なのでラージボールのサイズにしています。(謎のこだわり)

ただ、ここまでやっておいてなんですがネットの高さが規格と違います。規格では15.25cmなんですがゲーム内では17.25cmで計算してます。これは規格とか考えずに描いたドット絵に辻褄を合わせるためです。ドットの方を直せばいいじゃんと思われるかもしれませんが、今回はレイアウト至上主義。レイアウトを守るために規格を曲げました。決してめんどかったからではありません(`・ω・´)

操作感

平日はまとまった作業時間は取れないので、ひたすらCPUとラリーして気持ちいい操作になるようにちまちま調整する作業をします。記憶の中の「嗚呼神速の」は確か(ガラケーのテンキーで)456キーのどれかを押した後すぐ123キーのどれかを押すと打ち返せるとかだった気がします。つまりラケットで言えば下から上に振るうイメージになりますね。で4→3と押した場合は思いっきり右に6→1と押した場合は左に打ち返すといった感じで直感的にコースも決めれたと思います。今回はこのイメージに合うように操作感も調整しました。打ち返した瞬間以下のような計算で返球速度を決定します。

// 上に載せたBallクラスのメソッドを呼び出して速度算出
ball.targetTo(
    球のX座標+ラケット速度X×10,                // 打った場所からの相対で横方向の返球位置が決まる
    0,                                        // Yは常に0(=テーブルの高さ)
    テーブルの長さ×(0.75 - ラケット速度Y÷80),   // ラケットを上に振ると、球が奥に飛んでく
    Math.max(20, 40 - ラケット速度));

奥行き方向は打った位置からの相対で決まるわけではないので手前に引きつけて打つほど速い球を返せるようになります。 返球時間はラケット速度で決まるので、速く振れば振るほど短時間で着弾します。 よって、このゲームでスマッシュのような球を打つには左か右に飛んできた球を十分引き付けてから思いっきり反対の奥へ打ち返すことです。

スマッシュのようなと書きましたがそれはこのゲームに明確なスマッシュがないからです。というか球種という概念がありません。ラケット捌きだけでいろいろな返球ができるようになってくるとその技術だけで勝ちたくなってきます。なので、”これをすればスマッシュが打てる”とか、”ゲージを貯めればフィーバーモードになる”とかそういう仕様も考えてたんですが、ゲーム性の純度が下がるなと思ってボツにしました。ただ、そうするとほんとに地味な打ち合いを楽しめる人だけが遊ぶゲームになってしまいかねないのでジレンマポイントですね。

この点に関してちょっと心強かったのは、最近「ビリヤード」というすごく渋いゲームがニコ生で流行ってるんですがこれも特別な技とかは一切無くほぼ技術で攻略するタイプのゲームなんですが、これが流行ってるなら卓球もありでしょと思えたのでそのまま行きました。

キャラデザ

8/1(火)ラリーの調整をする上でダミーでいいから相手の姿が欲しかったのでキャラデザも兼ねて立ち絵のドットを打ちました。ここではどういう思考でキャラデザを決めたのかについて書いていきます。

まずこの時点である程度ゲームの方向性を決めておきます。

  • 操作感の話で書いたように基本的にはラリーの楽しさを活かしたい
  • しかしラリーだけでは地味なので点を入れると高いスコアが入るようにする
  • ただし、点を取るほど相手が強くなっていき、スコアを稼ぐのが難しくなっていく

こういうゲームにしようと考えていました。

そのため「点を取るほど相手が強くなっていく」というのを「絵」でどう表現しようか悩んでいました。最初は弱・中・強の敵キャラ3人くらい出そうかと思いましたがどう考えても作画コストがやばいです。ただでさえ1キャラで何枚描けば足りるかわからないのにキャラを増やすのは絶対にまずい!!「1キャラ」で「初心者プレイ」から「上級プレイ」までを違和感なく演じきれるキャラが欲しい!そこで行き着いたのが、涼宮ハルヒの憂鬱長門有希さんでした。

捕球する長門

素晴らしくないですか?表情一つ変えずに強さを自由自在に変えれて動きも最小限!!作画コスト問題をその身一つで解決してくれました。というわけで、長門に浴衣を着せたようなキャラがここに爆誕したのでした。

「・・・」
ただ、この浴衣のデザイン未だに納得いってないです笑ホントはこれみたいなデザインにしたかったんですよね。

THE温泉浴衣

最初は柄付きでドット打ってみたんですが、如何せん解像度が低すぎて全く表現できない。解像度決める時にそこまで考えてなかった!しょうがないので柄なしにしましたがワンチャン入院服に見えるのが残念でなりません。まぁ半天でも着せれば大分マシになるんですが今回は「夏の」ゲームイベント、暑苦しそうなのでそれもできませんでした。

ちなみにゲームの感想を見ているとやたら綾波レイみたいなやつと云われてました。そっちか~、まぁ似てるっちゃ似てる?

あと関係ないですけど、すごい意外だったのが何気なく描いた谷間の4ピクセルに結構反響があったことですね。もはや手癖で描き込んでるのでお色気とすら認識してなかったんですが思った以上に効果ありましたねw

たまたまラケットで隠れてチラリズムになっていたのも大きかったのかもしれません。

ネットとエフェクト

打ち合いがいい感じになってくると、ギリギリを攻めるので球がネットに引っかかることも増えてきました。しかし、絵がシンプル過ぎてネットに引っかかったかどうかが分かりにくい!そこで、ネットに触れたときだけ専用のSEを流すことにしました。早速いい音が無いか探してみるんですが難しいです。ドンピシャなSEはもちろんないですし、代わりの音を流すにしても「卓球ネットに球があたった音」の代替なんてイメージすらできません。最終的に効果音ラボさんで見つけた「脱いだ服を落とす」*6という音を流しているんですが、さりげなさ過ぎて全然聞こえない!笑

そこで、大分手間は増えますが、球の接地点に煙エフェクトを出すことにしました。

めっちゃさりげないエフェクトですがやること多いです。

まず、パーティクルシステムを作ります。パーティクルの登録機能と、その更新・描画機能があれば良いです。更新時にパーティクルの寿命が来たら自動で削除も行います。描画はちょっとややこしくて卓球台の各パーツと共に奥行きソートして奥から順に描画してます。(球もパーティクルの1つとして処理することにします。)

次に、球がバウンドした時にパーティクルの発生位置と速度を求めます。

  1. 衝突地点の法線から従法線とタンジェントベクトルも求めます。それらを使って球を平面に投影した時の輪郭円上のランダムな位置にパーティクルを発生させます。(図の黒い円上)
  2. 球の反射ベクトル(赤い矢印)を平面上に投影したベクトル(青い矢印)方向に速度を与えます。
  3. これらを時間差空けつつ5個発生させます。色は白からグレーの3階調でランダムに選ばれます。

地味に面倒な作業が多くて疲れましたが、当初の狙い通りネットに引っかかったかどうかがだいぶ分かりやすくなりました。ネットだけでなく台との衝突時にも出すようにしたらいい感じの賑やかしになってゲームっぽさが増したのは想定外の効果でした。いっそ常に球からキラキラエフェクト出したらいいんじゃないの?と思って試したんですが、さすがに邪魔くさかったのでやめました^^;

ビットマップフォント

実は開発序盤からずっと気になっていたのがAndroidでの不具合でした。手持ちのAndroidでプレイするとなぜかフレームレートが10くらいしか出ないのです。怪しそうなところをコメントアウトしていって原因の特定をした結果ダイナミックフォントを使ったラベルがあると激重になるようでした。なので、恐らくビットマップフォントにしてしまえば大丈夫だろうと思いつつフォント画像を作るのがめんどかったので最終日までもつれこんでいました。

シンプルなフォント
後でもっとかっこいいフォントにするつもりでしたが結局モチベが出ず現在もこれのままです。Akashicのビットマップフォントは初めて作るので最初json部分をどう作ればいいか分かりませんでした。どうやら BitmapFontGlyphInfo | Akashic Engineを満たすjsonを定義すれば良さそうと分かったのでjsで適当にスクリプトを書いて生成しました。これ皆さんはどうやってるんですかね?

ともかく、ダイナミックフォントをビットマップフォントで置き換えたことによってAndroidの不具合も直ったようです。原因は未だに分かってません。

モーション

最悪『空中ラケット』になるのを覚悟していたCPUですが、せっかく作画コストの低いキャラデザもしたことですし、何とか動かしてあげたいです。 CPUの手の届く範囲はかなり広いのでそれをカバーしつつ枚数を抑えることを考えると、、、もう思い切って左・真ん中・右の打ち返しモーションだけ作画することにしましょう。長門のように腕だけ動かして3つの差分と待機状態を作画しました。

ところが、実際にこれをゲームに組み込んでみたら動きがキモすぎる問題が発生!w

映像無いのでお見せすることはできませんが、なんか反射的に腕を伸ばすので虫みたいで可愛くない!キモい(´;ω;`)。せっかく描いたのにマジかよ~と思いつつなんとかならないか調整を試みます。その結果、打つ直前に手を伸ばしてその0.5秒後に引っ込めるようにしたら大分自然になりました。ヒヤッとした〜・・・

ちなみに現在は中距離の打ち返しモーションも追加して計5枚の差分になってます。CPUをナーフした時に球を拾える範囲を小さくしたので長距離モーションを廃止して全部中距離モーションで打ち返そうと思ったんですが、遠くの球を追いかける時にこれまで腕を全力で伸ばしていたのが、すごい縮こまったポーズで追いかけるようになってしまってめっちゃ舐めプされてるように見えたので長距離も残した5段階モーションにしました。これもまたキモイというかウザい動きだったので思わず笑ってしまいました。

これだと舐めプに見える

次は足元です。この手の横移動するキャラの作画を減らす手法と言えば古のゲームでよく見る「ちょこちょこステップ」+「カニ歩き」が定番です。浴衣でややサイドステップする足元の絵を描いて8ピクセル移動するたびにノーマル足と交互に表示すればそれっぽくなります。移動が速くなってくるとややキモいですが、そもそもプレイヤーは球に集中してるのであまり気になりません。

あと、かなり地味なんですが伏し目の表情は調整難しかったです。基本的に表情の無いキャラですが球を追いかける視線でわずかに表情を付けることで魅力が上がります。左右の目配せは特に問題なかったんですが伏し目を自然に見せるのがなかなか難しくて意外と時間かかりました。キャラとして自然な振る舞いをさせるのはコストの高い作業なので慣れてないなら覚悟しとかないと駄目ですね。

サウンド

いつも後回しになりがちなサウンド関連。とは言え、ハマる音が見つからないとゲーム作り自体が詰んでしまう事もわかってるので作業の合間合間にちょっとずつ探し続けていました。

効果音はみんな大好き「効果音ラボ」様ですべて揃えました。いつもお世話になってます。大抵の音はサクサク決まったんですが、強打音が見つからない。しょうがないので今回はテニスの音で代用しました。バコッ!という音、よく聞くと完全にテニスです笑。あり得ない音ですが意外とハマったし気持ちいいです。

また小手先テクとして球が跳ねる音や打撃音は視点からの距離によって音量を変えています。ほとんど分からないレベルだと思いますが臨場感につながっていると思いたいです。

BGMはいつもお世話になっている「DOVA-SYNDROME」様から探させていただきました。頭の中ではがんばれゴエモンシリーズっぽい曲がイメージできてるんですが、なかなかそれっぽい曲が出てきません。検索条件を変えながら「和風」で検索した時に1ページ目にこちらがありました。

dova-s.jp

5秒聞いた時点で「キターーーー!!!」って感じでした(分かる人には分かると思う笑)。ループは非対応だけどクオリティ高いし全然OK。このゲームってハイスコアを目指そうとすると結構ストレスゲーになるんですが、それをできるだけ誤魔化してくれるこんな曲を求めてました。山本リョーマさんありがとうございます!

遊び方の説明

怖い部屋3Dでは画像一枚で済ましていた遊び方説明。ニコニコ迷宮では動く指とか出してややパワーアップし、今作では球をスワイプするアニメーションまで付けしました。かなり分かりやすくなったんじゃないでしょうか?いや、なったはず!もう十分だろ・・・!許してくれ!(苦痛)

個人的に1ゲームは全部含めて2分くらいがちょうどいいのかなーと漠然と思ってるんですが、今回は試合部分だけで2分あります。素直に2分30秒くらい確保すればいいんですが、説明画面と終了演出をギチギチに詰めて2分20秒にしてしまいました。おかげで遊び方の表示が(2ページで)6秒+8秒しかありませんでした。ぶっちゃけあの文章量ならもうちょっと長く見せないと読み切れないですね。itch版では遊び方画面をタップでスキップ出来るんですがニコ生版は強制表示です。深い意味はないのでニコ生版も表示時間延ばしつつスキップありにして良かったかもしれないですね。

公開してから

怒涛の9日間を終えてなんとかゲームの体裁が整い、最終日の8/6 18時くらいについに公開しました。今までのゲームは日付変わるあたりまでやってましたが今回は夕方に終わりました!すがすがしいですね🍺

バグ

先に終わったからと言って調子こいて(まだ制作中だった)ふんすけさんの枠で新作を遊んでもらってたら、すぐにバグが発覚w。てばてばもってぃさん(すあまちゃんゲームの作者)の環境で球とキャラのスプライトの描画順が逆になって 球が見えなくなる現象が起きていたらしいです。すぐに自分でも放送枠をとって修正作業に入ります笑。自分の所では起きないので結局根本的な原因はわかりませんでしたが、敵を必ず先に描画するようにして緊急回避。なんとか事なきを得ました。てばてばさんもお忙しいところありがとうございました。後日プレイ動画が上がっていたので見てみると、ネットの後ろに球が行ってるのに手前に描かれてしまっている様子が映っていました。「へ~これかぁ」と思いつつやはり原因はわからなかったので見なかったことにしました*7。とは言え今回それ以外では目立ったバグは無かったようなのでほんとに良かったです。

itch.io版

操作感に関しては個人的に結構いい感じに調整できたとは思いましたが、人によっては慣れるまで時間がかかりそうだなと思っていたので今は無きアツマールの代わりにitch.ioというサイトに練習用温泉卓球を公開しました*8

isobeyaki.itch.io

itch.ioもゲームファイルをzipにしてアップロードするんですが、ランキングゲームであればニコ生版と全く同じzipで大抵動くので簡単に公開できます。最新のitch版はLV10から開始できる本番モードというのも実装していますので是非遊んでみてください。もうサーブからめっちゃ鋭い球が飛んできます笑。ニコ生版の方にはitch版のURLを表示するようにしたんですが、最初はDOMでリンク要素を載せて、ゲーム画面から直接itchに飛べるようしようかと思ってました。実際できることは確認したんですが、なんかイベント規約とかに引っ掛かられても面倒だなと思ったのでいったんやめました。どこまで許されるんですかね?

またitch.ioでもゲームアイコンを設定できるんですが、せっかくなのでアイコンを作り直しました。パネワンの時も反省しましたが、最初に描いたアイコンは既にゲーム内容と乖離していてよろしくありません。せっかくドット打って描いたので思い入れはあったんですが、そこは割り切ってゲーム内のドット素材を組み合わせて新アイコンを作りました。旧アイコンは公開1日で寿命を迎えるというセミの一生のような感じでしたが、コンセプトアートとしての仕事は全うしてくれたので成仏してもらいましょう。🙏

飽きない?

公開してすぐ、ありがたいことに温泉卓球は結構人気なようでした。ただ、イベントは12日からなので今こんな速度で消費しちゃったら始まる前に飽きない?という一抹の不安が・・・

一応何かバージョンアップしようかなとは思ったんですが、今のゲーム性を壊すことなく追加できるコンテンツなんて全く思いつきませんでした。これって技術を磨くゲームであってコンテンツ消費ゲームでも運ゲーでもないので追加できる要素が無いです。しいて言えば女の子のサービスショット追加とか?いやいやいや、(ヾノ・∀・`;)ナイナイ

唯一途中でやったバージョンアップはナーフでした。自分では気づかなかったんですがLV1が結構強くなっちゃってたみたいで、「腕が長くね」「めっちゃ返すじゃん」という意見が出てました。なので結構思い切って弱体化させたんですが・・・あんまり変わらなかったかも?*9

夏イベ結果

ch.nicovideo.jp

なんと!!温泉卓球が夏のゲームイベントで優勝しました!!!🎉

これは予想外です。前回の投稿作「怖い部屋3D」は(個人的にはお気に入りなんですが)、恐らく下位だったでしょう。それを踏まえてある程度はプレイヤー層に寄り添った作りにはしてたんですが、まさかこうなるとは思いませんでした。分析って程じゃないですけど、今回の結果につながったところで自分なりに思い当たる節があるとしたら気持ちよさ世界観あたりですかね?気持ちよさというのは「嗚呼神速の卓球よ」を遊んだ時の感覚を頼りに調整した操作感のことですね。他の卓球ゲームはやらないんで知らないですが、卓球(というか球技?)特有の緩急やスピード感が出せたのが良かったんだと思います。世界観は温泉のまったり感やキャラなどですね。こんな子と温泉行きて~という想いがみんなにも届いたんでしょうか(真顔)。99%技術ゲーでありながら演出的には徹底的にゆるゲーを装ったという点で、パネワンと真逆のことをしてますね。やっぱりぷよぷよパネポンもゆるゲーの振りをして女性ユーザーを獲得できたのが成功の鍵だったんでしょうか・・・?

あと、他のイベント参加作品の中ではメタル落としが気になりました。リズム天国とかメイドインワリオみたいなテンション高いコミカルな演出が珍しいなぁ、上手いなぁと思ってました。こういう個性的な作品が見られると面白いなぁと思うし、ニコ生ゲームを知らないクリエイターの人にも注目されるんじゃないかなぁと思いました(KONAMI感)。

総括

今回のイベントはやたら応募作が多くて最初は落選に怯えていました😨。これで落ちたらもう参加しないかなぁ・・・なんて思ってましたが、今回は運よく(?)通過できましたね。ツクールMVでも作れるようになったし、イベントの知名度も上がってきているだろうしで、きっと今後は更に応募が増えていくと思います。なので普通に落選することも出てくると思いますが、それはそれで通常の作品投稿はできてるわけですからイベント用と気張らずに、たまたま投稿時期がイベント期間だったから運試しにエントリーしといたわ~くらいのノリで作っていくのが大事になってくるかもしれませんね。

長々とここまでお読みいただきありがとうございました。

*1:いや、もちろん自由は自由なんですが・・・

*2:もちろん平日はそんなに時間とれないので実際はもっと短い!

*3:そのカーブがスピンでそうなってるみたいに見えて逆にリアルなのがちょっとおもしろい

*4:ちなみにどう狂うかというと、奥へ行くほど高速に見え、手前に来るほどゆっくりに見えるという感じ。狂ってるは狂ってるけどゲーム演出的にはむしろプラスに働く狂い方だったかもしれない。

*5:途中経過ではしっかりバグってましたが

*6:なんかエッチじゃん

*7:だって自分とこじゃ起きないんだもん

*8:現時点では200プレイ/日以上のペースで遊ばれているようです。

*9:実はこの時CPUが小刻みに左右に動いてしまうのがキモかったのでステップの精度を上げる更新も入れてたんですがこれが思いの外強くしてしまったかも知れません。ナーフを打ち消すレベルの変化じゃなかったことを願ってます・・・

パネワン振り返り

2021年の9月にアツマールで「パネワン・ロワイヤル」というゲームを公開しました。割と好評でアツマールでは多くの方に遊んでもらいました*1。また、たくさんの広告やギフト、コメント等もありがとうございました。itch.ioやニコ生ゲームではまだ遊べますので是非遊んでみてください。今回はアツマールの閉鎖を惜しみつつパネワン開発時のことなどを振り返ってみたいと思います。

isobeyaki.itch.io
itch.ioにシングルプレイのみですが公開中

誕生秘話

そもそも製作のきっかけはむじゅりんのニコ生放送でした。パネポンを自分で作ってみるという枠をやってたのを見てなんとなく自分もやってみようかなと思ったのが始まりです。

なんやかんやあってクローンゲーム自体は数日後に完成します。シングルプレイ&CPU対戦ができるHTML5ゲームです。対人プレイも作ってみたかったんですがサーバー立てるのとかよく分からなかったんで諦めていました。

See the Pen PanePon by z0ero (@z0ero) on CodePen.

CPU戦のデモ。パネワンの上級CPUはこの動きをしている

そして数か月後、またむじゅりん枠を見ていると今度はみんなでジグソーパズルというゲームを開発していました*2。ニコ生で視聴者も参加できるゲームだと言います。実はこの段階ではニコ生ゲームのことはほとんど知らなかったんですが、ここで初めてマルチプレイヤーのゲームを作って投稿できるらしいということを知りました。

これなら諦めてたパネポンマルチもできるかも?ということでさっそく開発環境を整えて初のニコ生ゲーム開発に取り掛かったのでした。

開発

公式サイトの分かりづらさに惑わされながらもなんとか作っていきます。細かい話も書きたいところですが正直もう覚えてないのであんま書くこと無いですね…。

言語

パネワンは全てjavascriptで作られています。ニコ生ゲームはTypeScriptでも作成可能で半分くらいの方はそちらを使っているんじゃないでしょうか?(もしかしてもっと・・・?)TypeScriptの方が型付き言語なので便利だと思います。自分もc++でニコ生ゲームを作るくらいには型がガチガチなのも好きなんですが、それは1万行オーバーのプロジェクトでの話で、パネワンくらいの書き殴りで逃げ切れる規模ならむしろ型ゆるゆるで書けた方が速いのでjsを選びました。それに加えてブラウザネイティブ言語かどうかというのも結構大きいかなとは思いますね。

ちなみにパネワンのソースコードはこんな感じです。

  • ai.js(172行)
  • common.js(421行)
  • effect.js(340行)
  • game.js(1126行)
  • main.js(907行)
  • menu.js(647行)

計6ファイル3613行

うん、jsの型システムで十分ですね。

参考までにニコニコ迷宮のc++ソースコードはこんな感じです。

ファイル数 行数
エンジン側 80 14091
ゲーム側 107 13173
187 27264

2万7000行ありました。こういうのが逃げ切れないってことですね。

描画

描画周りの処理はアカシックに乗せ換えるうえで結構大変だった記憶はあります。アカシックでは基本的に全て画像にしないといけないからです。元のゲームは画像0で全てCanvascssの図形描画などで作ってたのでこれ全部画像に変更するの面倒いなーと。このブログで度々出てくるCanvasのハックはそれを回避するために編み出されたと言っても過言じゃないですね笑

マルチ

そしてもちろんマルチプレイ周りの実装も大変です。アカシックエンジンのマルチの仕様はかなり独特なのでバグ取りが大変でした。その辺の技術的な話はまた別記事にてまとめたいと思います。

ゲームデザイン

パネワンは最大25人対戦が売りということになっていますが、この辺のゲームデザインテトリス99を参考にさせていただきました。確か当時流行ってたと思います(もうちょい前か?)。25という数字は画面の収まりの良さと処理負荷を考慮してなんとなく決めました。正直スマホじゃ動かないかなと思ってたけど案外動くもんなんですね(感心)。見た目的にもサイバー?な感じがテトリス99リスペクトです。パネポン原作といえば少女向け(というか幼年向け?)のような可愛らしい世界観が良いところなんですが、ゲーム自体はテトリスのように非常に競技性が高いと思うんですよね。なので、思い切って可愛さを捨てて無機質化させて、ストイックな感じを演出しました。

www.youtube.com
かわいい

www.youtube.com
かっこいい

パネワン

テトリス99では攻撃ターゲットを4種類くらいから選べるシステムがあり、アツマールでもそのシステムが欲しいという要望は来てたんですが、パネワンではそれは入れませんでした。一応操作が複雑になるのを避けるためという建前はあったんですが、やった方が良かったんですかねー。ちなみにRuたんさんの「パズトリ」ではバージョンアップで大人数プレイできるようになったんですが、テトリスっぽいターゲッティング機能が恐らく実装されてます。シンプルに落とし込んで実装できてるのほんとすごいですね~。

入力

あとは細かい話ですが、入力が苦し紛れな感じでした。元のHTML5ゲームは普通にキーボード操作で実装してたんですが、Akashicでは標準でキーボードが使えない上にスマホのタッチ操作にも対応しなくてはならない。幸いキーボード対応に関してはニコ生ゲームに前例があったのでやれないことはないことがわかります。タッチに関しては仮想十字キーなどで対応という方法もあったかも知れませんが、直感的に入れ替えたいパネルの隙間をタッチするという操作方法にしてみました。自分が手先器用じゃないんでこれ無理だなと思ったんですが、案外その操作の方が速くていいという人もいるようですげぇなと素直に関心してしまいました。この頃はスマホでニコ生ゲームを遊ぶ人が割と多いということを知らなかったので全部PCを想定して作ってました*3

その他

あとは設定画面・入室画面・リザルト画面などのUIとゲームフローを実装してついに完成します。(あんまり書くことないんでさらっと書きましたがかなりめんどかったです・・・)

結局リメイクにかかった期間は1.5か月くらいだったと思います。まぁリリース後もちょこちょこバージョンアップやバグ取りをしてたのでトータルでは2~3か月手をかけていたと言えると思います。また初タイトルということもあり最適化が不十分で一部の環境では結構重いらしいです。このことが後にWebAssemblyに手を出すきっかけにもなってますね。

ちなみにリザルトの時に表示されるトロフィー画像はGIMPで描きました。別に絵が上手いわけでもないのにたまーーに描きたい衝動に駆られてしまうんですよね。下手でも使っちゃう。作る情熱って案外そういうとこで湧いてくる気がする。

デッサンめちゃくちゃだけど色彩感覚は悪くないよね・・・?

公開

公開してからのことはぶっちゃけもうそんなに覚えてないです笑。バグ報告やフィードバックの対応を何度かしてたなーという感じですね。

反響

アツマールでのプレイ数の推移としては10月11月でどんどん落ち込んでいって「あ、終わった」って感じでしたが12月に何故か息を吹き返してたと思います。なんでですかね?

一方ニコ生でどれくらい遊ばれてたかは全然分かりません。そっちでのプレイ数は集計されないしニコ生ゲーム枠見ないしツイッター見回りもしてなかったんで反響とかはほとんど見れてませんでした。今になってツイッター見てみると当時結構パネワンのつぶやきあったんだなと分かりますが、ニコ生ゲームってユーザーの意見を開発者が汲み取りに行かないといけないのがなんだかなーとは思います。あまり開発者が重視されてなさそうです。

アイコン

アイコンは公開した年の12月に一度変更しています。

旧アイコン
新アイコン
最初のはすごい適当です。なんかタイトルとも違うこと書いてるし。公式ゲームが灰色一色なシンプルアイコンが多かったのでそれを参考にしたんですがそれにしてもひどいですね。二代目はとにかくキラキラしてて視認性のあるデザインにしたかったんですが、いい感じに盛れたんじゃないでしょうか?ただ、やっぱりデザイン下手くそだなーと思うのはどっちもゲーム本編のデザインと合ってないんですよね~笑。パッケージ詐欺なんて言葉もありますが、あまり内容を反映してないアイコンはよろしくないので皆さんは気をつけましょう(^-^;ほんとトータルでデザイン考えるの下手すぎる・・・。

アツマールのおわり

2023年の6月末、(どれくらいの歴史か知らないけど)アツマールの長い歴史に幕が下ろされました。それと共に約21か月に渡るパネワンの歴史も幕を閉じました(アツマールのみ)。もともとはただ自分で作ってqiitaでちょっと記事書いて終わりになるはずだったゲームがここまで日の目を見ることができたのはむじゅりんとニコニコと原作達のおかげです。ありがとう。

静かに幕を閉じた後、10日後くらいに冒頭にも貼ったitch.io版を公開しました。こちらは完全にソロプレイ用です。実はアツマールはマルチプレイに対応していたんですが、宣伝が悪いのか運営が全くそれを活かせてなくてマルチで遊んでる人は一度も見たことありませんでした。つまりアツマールでも実質ソロプレイゲーだったのでその点は問題なしですね!*4ただ、ランキング機能がなくなったのはちょっと痛いかもしれません。この辺もWeb知識とかがある人ならありものの無料サービスとか組み合わせてパパっと簡易なランキングとか作っちゃうんでしょうかね?

アツマールのゲーム達はいったいどこに行くんでしょうね。

終わりに

流石にかなり前の話だったので全体的におぼろげな内容になっちゃいました。この記事は何か思い出したら追記していくスタイルで行こうと思います。

*1:アツマール閉鎖時の最終プレイ数は84000+

*2:後の大人気タイトルですね

*3:スマホを重視してないのは今もですが

*4:そもそもアツマールマルチが空振りに終わったのが問題だろ!運営!

WebAssembly(c++)でニコ生ゲームを作ろう その2

その1での予告通り今回はマルチプレイゲームでwasmを実行する方法についてまとめる。

サーバー向けビルド

以前の記事でも書いた通りマルチプレイモードのゲームではサーバー上でもゲームが1つ動いている。 このサーバーは恐らくnodeサーバー(?)なので、サーバー用wasmバイナリというものを別途作成して実行する必要がある。

普通にjavascriptでマルチゲーム作っている時も、DOMやキャンバス、window.addEventListener()などブラウザでしか使えないAPIを使う場合は、サーバー側でこれらが実行されないように処理を分けると思うがそれと同じことである。ただし、wasmの場合はemscriptenのビルド結果のjsファイルにWebGLやキャンバス処理などがエクスポートされるので、サーバー/クライアント兼用のスクリプトにするのが難しい。それでファイルごと分けざるを得ないということだ。

サーバーはブラウザAPIを含んだスクリプトは読めない

サーバー用ビルドをするにはemccのビルドオプションでターゲット環境をshellにする。ただし、ここはかなり怪しい。本来nodeでいいはずだと思うのだがそれだとニコ生で動かなかったのでshellにしてみたらたまたま動いただけである。実はnodeでもうまくいく方法はあるのかも。

// 前回はweb向けにビルドしていたがサーバー向けはshell
emcc -sNO_EXIT_RUNTIME=1 -sENVIRONMENT=shell server.cpp -o server.js

wasm読み込み

サーバー側だと読み込み方法も変わってくる。

前回はwasmをtextアセットに偽装してfetchで読み込むようにしていたが、以前の記事で書いた通り、nodeサーバーからCDNサーバーへはアクセス権が無いのでfetchで読むことができない。そこで、(やや手抜きな気もするが)wasmバイナリをbase64エンコードしてjsに埋め込むことにする。デコードする処理も一緒に埋め込むことで、実質バイナリデータをソースに埋め込んでいることになり、CDNサーバーへの通信なしでwasmの読み込みができる。もちろん、もっといい方法が思いついたらこの方法でなくてもよい。

以下に自分の場合の実現方法を書いておくが、あくまで参考である。各自どのような開発環境を構築するかによってそれぞれ異なるだろう。

  1. 自作のbase64化プログラムを作成
    • 厳密なbase64エンコードではなくややオリジナル
    • テキスト化できれば何でもよい
    • 注意点としてakashic export時のminifyで長すぎる文字列リテラルはエラーとなってしまうので、8KBごとに分割すると良い
      const encoded = '8KBの文字' + '8KBの文字' + ...;のようにすればエラーは出なくなった
    • 実装例(クリックで展開) gist.github.com
  2. makefileのビルドルールにエンコードコマンドを追加
    これはemscriptenで生成した.wasmと.jsの.wasmをbase64エンコードしてもう一つの.jsと結合しscriptフォルダにコピーするビルドルール
    ../script/server_wasm.js: $(BIN_DIR)/server_wasm.wasm $(BIN_DIR)/server_wasm.js
        @call $(WASM2JS) $(BIN_DIR)/server_wasm.wasm tmp.js
        copy /y /b tmp.js+$(BIN_DIR_FOR_WIN)\server_wasm.js ..\script\server_wasm.js
    2行目の`$(WASM2JS)`が1.で書いた自作のbase64化プログラムのパス
  3. base64をUint8Array型にデコードしてModule.wasmBinaryにセットする
    自分の場合は1.のbase64化プログラムでデコード処理も一緒に出力するようにしている*1
    デコードした結果はvar Module = { wasmBinary: decode_bin };というようにModule.wasmBinaryにセットしておくことで、emscriptenがfetchしないで直接このバイナリをロードしてくれるようになる

以上の結果生成されるスクリプト

これ(クリックで展開)

gist.github.com

である*2

デコーダーとwasmバイナリとModuleへの設定が含まれている。このコードをもう一つの生成物であるjsファイルの先頭に結合して読み込むだけでAkashicサーバー上でもwasmを使えるようになる。

ちなみに、バイナリデータをbase64化することでそのサイズは約1.33倍にはなるが、サーバーで実行するwasmというのは描画処理などを含まないため大体数十KB程度に収まるので、その増分はほぼ誤差の範囲である。

まとめ

というわけで今回の記事によって

  • サーバーで実行できるwasmをビルドする方法が分かった
  • Akashicサーバーでwasmを読み込む方法が分かった

後者に関しては各開発者の環境次第で実装方法が変わってくると思うので、どういう結果が得られればよいのかだけ押さえてもらえればいいと思う。以上。

*1:サーバーが恐らくnodeなのでBuffer.from('base64')などが使えると決め打って利用しても良いかも?今回はそこに依存したくないので自前でデコードした。

*2:ニコニコ迷宮の実際のスクリプト