淡々とゲームを作る日記です。ステラのまほう2期を待ち望んでいます。
ワールドの地形データを保存するようにしました
そろそろゲームのプレイデータの永続化をどうするか考えなきゃと思って、ワールドのデータの保存を仮実装しました。シングルプレイはIndexedDBに、マルチプレイではFirebaseにデータを保存します。これで、ブロックを置いたりしたあとで再びゲームを読み込むと、以前の状態が再現されてゲームを再開できます。また、マルチプレイヤーモードを複数のウィンドウで開くと、一方でブロックを置いた時に他方でちゃんと反映されるのが確認できました。オンラインゲームだコレ!
画像は、私がなぜかゲーム内でときどき建立している鳥居です。他のプレイヤーキャラクターの様子はまだ見れませんので、突然にょきっと地形が変わる感じになっちゃってます。そろそろマルチプレイ用のキャラクターを用意したいです。Firebaseはほんとにシンプルで楽ちんです。
Firebaseに配列を保存するときに容量をケチるたったひとつの冴えたやりかた
Firebaseは任意のJSONデータを格納できるものの、配列とはあまり相性がよくありません。詳細はこちらを読むといいと思いますが、要するに配列の要素を挿入したり削除したりすると要素の位置がズレるので、データの対応をとるのが面倒くさいというわけです。それで配列を格納しようとしても内部的には結局オブジェクトのテーブルとして保存されており、配列としての一定の条件が揃った時だけ配列として取り出される、という振る舞いになっています。容量の効率としてはぜんぜん良くないのです。
ところが、本作ではワールドの地形データをUint8Array
で保持しており、それをRealtime Databaseに保存するにあたってデータ転送量をどうケチるか考えていたんですが、いい方法が思いつきました。手順としては、チャンクは長さ16 * 16 * 16 = 4096
のUint8Array
で表現されているのですが、これをfromCharCode
でもう単純に文字列に置き換えます。そしてこれをlz-stringで文字列として圧縮するんです。普通は文字列データを圧縮してバイナリの圧縮ファイルになるものですが、Uint8Array
のバイナリデータを文字列にして圧縮するという斜め上の発想。なぜfirebaseを使っているのかちょっとわからなくなってきました。
var compressed = LZString.compressToUTF16(String.fromCharCode.apply(null, uint8array))
var decompressed = new Uint8Array(LZString.decompressFromUTF16(compressed).split("").map(c => c.charCodeAt(0)));
JavaScriptの文字列はUTF-16で表現されているので文字列化すると8192
バイトほどになる計算ですが、現状の地形生成では同じ種類のデータが連続することが多く、これを圧縮すると130文字≒260バイトにまで小さくなり転送量を大幅に削減できます。firebaseはオブジェクトの一部を変更するとその差分だけ転送してくれるようなのですが、転送量を削減しようと下手にその振る舞いを利用しようとしたのがよくありませんでした。今回の方法だと1ブロック単位の差分は使えずにチャンクをまるごと転送することになりますが、最初にチャンクを読み込んだ時の転送量を考えると、生のオブジェクトを転送するよりも軽くなるのです。なぜ今までこれに気付かなかったのかという感じですが、Firebaseには生のJSONデータを格納するものだという思い込みがありました。今回は文字列にしてから圧縮しましたが、ZIPか何かでバイナリとして圧縮してからbase64にエンコードしてもいいかもしれません。そもそもバイナリデータをRealtime Databaseに保存しようということ自体がかなりのレアケースなんですけども。
ここまで軽くなるなら、ワールド全体をon
で読み続けても大丈夫そうな気もします。1000チャンクが編集済みでも全体は300kbytes程度。超根性のあるプレイヤーでも1000チャンクを編集するのは並大抵ではないでしょう。もちろんブロックの配置のエントロピーが大きくなるにつれて圧縮率は低下しますが、複雑なチャンクを圧縮して10倍の3000バイトになったとしても3メガバイト程度。もしユーザが何万人も押し寄せたら考え直しますが、さすがにそこまでの心配はいらないでしょう。というかそんな事態になったらそもそもfirebaseじゃなくて専用のサーバが必要でしょうし。
初回に距離10チャンクまでのチャンクを読み込むとして、ワールド全体で21 * 21 * 21 * 300bytes ≒ 2.7MBytes
くらい。実際には空中や地中では同じ種類のブロックが連続するのでもっと圧縮できるはずで、もしかしたら1MByte以下かもしれません。それ以降、ひとつブロックを置くごとに300バイトほどのデータが転送されるとして、1秒に一個づつブロックを置き続けるのを1時間続けたとして、300bytes * 60 * 60 ≒ 1MB
ほど。これなら相当の回数プレイしても、そうそう無料枠の転送量10GB/月を使い切ることはないでしょう!リアルタイムデータベースよりもむしろホスティングのほうがアレです。ファイル全体で17MBytesもあるので、あんまりたくさんのアクセスを受けたら使いきってしまうでしょう。それこそニコニコのRPGアツマールにでもホストさせておくのがいいかもしれません。
lz-stringで圧縮した文字列をFirebaseに保存するときのコツとして、compressToUTF16
/decompressFromUTF16
をつかうといいようです。compress
/decompress
だとFirebaseに持ってきた時に一部の文字がエスケープされてしまいちゃんと復元できません。firebaseらしくない使い方ですし、こんなTipsに需要はないと思いますが……。
purescript-domの頭がどうかしている件
ページを開いときにフォーカスがアドレスバーにあたっていることもあります。今はタイトルページで一度画面をクリックするという仕様になっているのでフォーカスを自動的に移す必要はないのですが、以前はいきなりプレイ画面だったので、ページを再読み込みしてから前に歩こうとwキーを押してアドレスバーにwwwwwwwwwwwwwwwwwwって入力されてしまうという事故が何度も起きてました。それでページのレンダリングが完了した時点でイベントリスナを仕掛けている要素にフォーカスを当てるという処理が欲しくなったんですが、でもこれはpurescript-halogen
の仮想DOMだけではできないので、purescript-dom
で生DOMを触ることにしました。JavaScriptでやると次のようなコードです。
document.getElementById("content").focus();
これがです。purescript-dom
で書くとこうなります。
(window >>= document <#> htmlDocumentToNonElementParentNode >>= getElementById (ElementId "content") <#> toMaybe) >>= traverse_ (focus <<< unsafeCoerce)
なんじゃこりゃあ! こんなのパッと書けるわけないだろいいかげんにしろ! しかも結局unsafeCoerce
で無理やり型変換する必要があって、型安全性が台無しです。これだけいろんなデータ型がごちゃまぜにつめ込まれたDOMツリーからクエリで要素を選択し取り出すという考えかた自体がそもそも無理ゲーで、そういう雑なAPIはPureScriptには向かないのです。PureScriptで生DOM触るのはほんとに苦労しかないので、なるべく避けましょう……。
充 実 し た オ プ シ ョ ン
シングルプレイヤーモードとマルチプレイヤーモードの二種類のゲームモードができたので、モード選択画面を仮実装しました。
もっと画面全体に動きを入れて賑やかにしたいですが、デザインはちょっと守備範囲外なのでなかなかアイデアが出てきません。それに、ゲーム全体を通してデザインに統一感がなくて、中核になるデザインコンセプトが欠けているって感じです。とはいえいろいろ実装すればするほど課題が見えてくるので、とにかく作業を進めて後で考えるのは悪くない気がします。
ゲームの設定画面のデザインもいろいろいじろうとしてますが、なかなかコンセプトが見えてきません。うーん。
いま借りている無料素材のBGMに中田ヤスタカさんっぽいテクノポップな曲があって、その雰囲気が気に入りました。サウンドからグラフィックまで含めて、そういうポップ系でゲーム全体を統一してみたい感があります。ストリングスのピチカートとグロッケン?でメロディーをなぞりつつ、ベースやパーカッションはテクノでエレクトロな音源のやつを使って、なんかよくわからないピロピロしたエフェクトが入っていて。あとハンドクラップ?こういうのが自分で作れるといいんですけど。ちょっと私の技術が追いついていないです。