isobe_yakiのブログ

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

ニコ生ゲームに好きなDOM要素を乗っける

ニコ生ゲームは公式で紹介されている通り配信ページの映像部分にオーバーレイで表示される。DOMで言うと以下のような構造になってる。

<html>
<body>
    <!--視聴ページ-->
    <iframe>
        <!--ゲーム領域-->
        <div>
            <div>
                <!--タッチイベント用レイヤー-->
                <div>
                    <canvas><!--描画内容--></canvas>
                </div>
            </div>
        </div>
    </iframe>
</body>
</html>

で、ここに好きなDOM要素が追加できたら色々できそうではないか?というのが今回の内容の出発点。

例えばtextarea要素を置けば漢字や絵文字などをキーボードで入力できるようになるし、リンク要素を置けば外部リンクやセーブデータのDLリンクなんてのも作れる。

ゲームの上にテキストエリアとボカシ背景を追加した様子。ユーザーは任意の名前を入力できる

ではどのように要素を置けばこれらができるだろうか?普通に考えたらcanvas要素の兄弟(canvasは子を持てないので)として要素を追加したくなるがこれだと問題がある。上記疑似htmlを見ればわかるようにcanvasの親にタッチイベントハンドル用のdiv要素がある。こいつがあるとその向こう側の要素でタッチイベントが発火しないのでここではだめだ(タッチしなくていいなら問題ないけど)。ならそのdiv要素の兄弟にする?確かにそれならタッチイベントも受け取れるのでやりたいことはできる。しかし、ニコニコ側の都合でページの作りが変更されたら、変更の度にゲームを更新しなくてはいけない(めったにないと思うけど)。

そこでめんどいことを考えなくていいようiframe の最前面に要素を追加してしまう。つまりbody要素の末尾に追加すればいいだけだ。

<html>
<body>
    <!--視聴ページ-->
    <iframe>
        <!--ゲーム領域-->
        <div>
            <div>
                <!--タッチイベント用レイヤー-->
                <div>
                    <canvas><!--描画内容--></canvas>
                </div>
            </div>
        </div>
        <!--ここに好きな要素を追加しちゃう-->
    </iframe>
</body>
</html>

ただ追加してもcanvas要素の横や下に行って見えないので、canvasに重なるように、styleでpositionをabsoluteにし座標などを設定する。 canvasg.game.renderers[0].surface.canvasで取得できる。[0]としているところがやや不安要素だがまぁそうそう仕様が変わるところではないだろうと妥協する。 canvasのiframe内での絶対座標取得にはgetBoundingClientRect()を用いる。

// 新規div要素作成
const elem = document.createElement('div');
// 座標を絶対値に設定
elem.style.position = 'absolute';
// 最前面に追加
document.body.appendChild(elem);
// ゲームのキャンバスを取得
const cvs = g.game.renderers[0].surface.canvas;
// キャンバスの絶対位置を取得
const bounds = cvs.getBoundingClientRect();
// divに座標を設定
elem.style.left = bounds.left + 'px';
elem.style.top = bounds.top + 'px';
elem.style.width = bounds.width + 'px';
elem.style.height = bounds.height + 'px';

とりあえずこれで、キャンバスにDOM要素を重ねて表示することができるようになった。

でもこれだけだとちょっとまずくて、ウィンドウサイズが変わった時やフルスクリーンモードに切り替えたときなどにcanvasの位置やスケールが変わるとDOMがずれてしまう。そこで、以下のようにする。

// キャンバスのサイズ変更ハンドラ
const canvasLocation = new EventTarget();
const onWindowResize = () => canvasLocation.dispatchEvent(new Event('changed'));
// キャンバスのサイズとスケールが変化したら発火
new MutationObserver(onWindowResize).observe(cvs, {
    attributes: true,
    attributeFilter: ['width', 'height', 'style'],
});
// ウィンドウサイズが変化したら発火
window.addEventListener('resize', onWindowResize);

// サイズ変更時のハンドラを定義
const resized = () => {
    // キャンバスの絶対位置を取得
    const bounds = cvs.getBoundingClientRect();
    // divに座標を設定
    elem.style.left = bounds.left + 'px';
    elem.style.top = bounds.top + 'px';
    elem.style.width = bounds.width + 'px';
    elem.style.height = bounds.height + 'px';
};
// キャンバスサイズと位置変更を監視して常にキャンバスに合わせる
canvasLocation.addEventListener('changed', resized);

これでとりあえずやりたいことはできた。細かいことを2点付け足すとしたら、まずcanvas上の指定した位置とサイズに配置する方法だが、一応簡単に紹介する。

// サイズ変更時のハンドラを定義
const resized = () => {
    // キャンバスの絶対位置を取得
    const bounds = cvs.getBoundingClientRect();
    // キャンバスのスケールを算出
    const scalex = bounds.width / g.game.width;
    const scaley = bounds.height / g.game.height;
    // divに座標を設定
    elem.style.left = bounds.left + x * scalex + 'px';
    elem.style.top = bounds.top + y * scaley + 'px';
    elem.style.width = width * scalex + 'px';
    elem.style.height = height * scaley + 'px';
};

※これはゲームのアス比がキャンバスと同じ16:9である時の処理であることに注意

次にローカルでのデバッグ時の話なのだが、実は上記のコードだけでakashic serveコマンドのデバッグを行うと電源ボタンみたいなのを押してゲームをリセットしてもwindowへ登録したイベントリスナーは自動で解除されないのでリサイズイベントが二重三重に呼ばれるようになってしまう。そこで使えるのがSceneクラスのonStateChangeイベントで、'before-destroyed'が飛んで来たらwindowのイベントリスナを解除するコードを追加することで快適にデバッグもできるようになる。

scene.onStateChange.add(state => {
    if (state == 'before-destroyed') {
        // うまいことしてリロード前の後処理を入れとく
    }
});

(23/6/6追記)指摘があったので改めて確認したら電源ボタンでイベント飛んで来てなかった(あれ?)。windowオブジェクトに登録したイベントリスナーが二重三重で呼ばれてしまうようなので、無理くりリロードイベントを捕まえて解除するか、定期的にページリロードして蓄積したイベントリスナーを解放してください。