isobe_yakiのブログ

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

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

マルチゲームで画像データなどをやり取りするために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個だったら二度と出ないからな!頼むぞ!(いや、普通に次回以降出ない気がするけど・・・)

*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:ニコニコ迷宮の実際のスクリプト

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

WebAssemblyの仕組みや、利点などに関してはネットで検索してもらえばいくらでも出てくるのでこの記事ではスパっと端折らせていただく。一応リンクを1つ載せておくのでどういうものか掴んでおいて欲しい。

developer.mozilla.org

小難しい説明は全て飛ばして、今回はランキングゲームで手っ取り早くwasmを使う方法を解説していく。

emscripten

タイトルにもある通り、言語はc++を使う。c++からwasmを生成するにはemscriptenというツールを使う。

インストール

Download and install — Emscripten 3.1.44-git (dev) documentation

チュートリアル

ついでに↓チュートリアルもクリアしておこう。

Emscripten Tutorial — Emscripten 3.1.44-git (dev) documentation

実装

インストールができたら実際にゲームから毎フレーム呼び出すようなサンプルを作成してみよう。説明は後でするのでひとまず以下の手順を実行してほしい。

手順1

コマンドプロンプトを開いてゲームを作りたいフォルダへ移動し、akashic initを実行。幅や高さは何でもいいので適当にエンター押して完了する。

手順2

次にゲームフォルダにc++フォルダを作成し、中にgame.cppとprefix.jsを作成する。

こうなってるはず
├─audio
│      se.aac
│      se.ogg
│
├─c++
│      game.cpp
│      prefix.js
│
├─image
│      player.png
│      shot.png
│
└─script
        main.js

game.cppに以下をコピペ

#include <emscripten.h>
#include <iostream>

int count;

extern "C" EMSCRIPTEN_KEEPALIVE void frame()
{
    printf("%d\n", count++);
}

int main()
{
    return 0;
}

prefix.jsに以下をコピペ

var Module = {
    locateFile: path => g.game._assetManager.configuration.wasm.path
};
module.exports = Module;
// 余計なrequireを発生させないため
const process = undefined;
const Buffer = undefined;
const setImmediate = undefined;

手順3

次にemscriptenをインストールしたフォルダにあるemcmdprompt.batを実行。(emsdkにパスが通っていればemcmdpromptと打ってエンターで起動すると思う)

c++フォルダへ移動し、ビルドコマンドを実行。

cd 今作ったc++フォルダのパス
emcc -sNO_EXIT_RUNTIME=1 -sENVIRONMENT=web game.cpp -o game.js

成功すればc++フォルダにgame.jsとgame.wasmファイルが生成されているはず。更に以下のコマンドを実行。

copy /y /b prefix.js+game.js ..\script\game.js
copy /y /b game.wasm ..\script\game.wasm

手順4

game.jsonのassetsに以下を追加。

       "game": {
            "type": "script",
            "path": "script/game.js",
            "global": true
        },
        "wasm": {
            "type": "text",
            "path": "script/game.wasm"
        }

手順5

script/main.jsにコードを追加する。(コメント強調した箇所)

function main(param) {
    var scene = new g.Scene({
        game: g.game,
        // このシーンで利用するアセットのIDを列挙し、シーンに通知します
        assetIds: ["player", "shot", "se"]
    });

    ///////////////////////////////////////
    // wasm読み込み
    ///////////////////////////////////////
    const wasm = require('./game');

    scene.onLoad.add(function () {
        // ここからゲーム内容を記述します
        // 各アセットオブジェクトを取得します
        var playerImageAsset = scene.asset.getImageById("player");
        var shotImageAsset = scene.asset.getImageById("shot");
        var seAudioAsset = scene.asset.getAudioById("se");
        // プレイヤーを生成します
        var player = new g.Sprite({
            scene: scene,
            src: playerImageAsset,
            width: playerImageAsset.width,
            height: playerImageAsset.height
        });
        // プレイヤーの初期座標を、画面の中心に設定します
        player.x = (g.game.width - player.width) / 2;
        player.y = (g.game.height - player.height) / 2;
        player.onUpdate.add(function () {
            // 毎フレームでY座標を再計算し、プレイヤーの飛んでいる動きを表現します
            // ここではMath.sinを利用して、時間経過によって増加するg.game.ageと組み合わせて
            player.y = (g.game.height - player.height) / 2 + Math.sin(g.game.age % (g.game.fps * 10) / 4) * 10;
            // プレイヤーの座標に変更があった場合、 modified() を実行して変更をゲームに通知します
            player.modified();
            ///////////////////////////////////////
            // c++のframe()関数を呼び出し
            ///////////////////////////////////////
            if(wasm.calledRun) {
                wasm._frame();
            }
        });
        // 画面をタッチしたとき、SEを鳴らします
        scene.onPointDownCapture.add(function () {
            seAudioAsset.play();
            // プレイヤーが発射する弾を生成します
            var shot = new g.Sprite({
                scene: scene,
                src: shotImageAsset,
                width: shotImageAsset.width,
                height: shotImageAsset.height
            });
            // 弾の初期座標を、プレイヤーの少し右に設定します
            shot.x = player.x + player.width;
            shot.y = player.y;
            shot.onUpdate.add(function () {
                // 毎フレームで座標を確認し、画面外に出ていたら弾をシーンから取り除きます
                if (shot.x > g.game.width)
                    shot.destroy();
                // 弾を右に動かし、弾の動きを表現します
                shot.x += 10;
                // 変更をゲームに通知します
                shot.modified();
            });
            scene.append(shot);
        });
        scene.append(player);
        // ここまでゲーム内容を記述します
    });
    g.game.pushScene(scene);
}
module.exports = main;

完成!

これでakashic-sandboxコマンドでゲームを起動し、デバッグコンソールにインクリメントされた数字がドバ―っと出力されれば成功だ!お疲れさん

解説

手順2でコピペしてもらったソースコードについて

#include <emscripten.h>    // EMSCRIPTEN_KEEPALIVE のために必要なインクルード
#include <iostream>

int count;

// extern "C"はコンパイラによって関数名が自動割り当ての名前に変更されるのを防ぐ
// EMSCRIPTEN_KEEPALIVE はemscriptenコンパイラにjsから呼び出す関数であることを伝える
extern "C" EMSCRIPTEN_KEEPALIVE void frame()
{
    // グローバル変数countをインクリメントしてコンソールへ出力している(フラッシュのために改行が重要)
    printf("%d\n", count++); 
}

// メイン関数は一応実行されるが何もしない
int main()
{
    return 0;
}
// このファイルはemscriptenが出力したjsファイルの直前に連結される

// emscriptenがexportするModuleオブジェクトに予め機能を定義しておくことでオーバーライドができる
var Module = {
    // 今回は.wasmファイルのパスをオーバーライドする
    locateFile: path => g.game._assetManager.configuration.wasm.path
};
module.exports = Module;
// これらは主にサーバー実行時用だったはず・・・(無いと読み込みエラーを起こす)
const process = undefined;
const Buffer = undefined;
const setImmediate = undefined;

c++はともかくjsはややトリッキーだ。通常emscriptenから出力されたjsファイルはそのままrequireすれば使えるのだが、AkashicEngineは動作が特殊なため上記のようなコードを付け足さないと動かない。というのもニコ生上ではwasmファイルの名前が出力時と異なってしまうからだ。以前の記事でも書いたが、ゲームをエクスポートするとアセットファイルの名前が全てハッシュ値に変更されてしまう。

isobe-yaki.hateblo.jp

もしオーバーライドしないとデフォルトではビルドオプションの出力名.wasmをfetchするようなコードになっている。ローカルで実行する分にはそれでも動くが、このオーバーライドによってニコ生上でも動くようになる。

末尾3行の定数定義はサーバー側で実行する際に必要で、正直「これを書いたらエラーが出なくなった」程度のおまじないでよく理解していない。

手順3で実行したビルドコマンドについて

emcc -sNO_EXIT_RUNTIME=1 -sENVIRONMENT=web game.cpp -o game.js

emccがコンパイルとリンクを行うコマンドである。今回は最小限の引数で実行してみた。

まず、-sNO_EXIT_RUNTIME=1だが、これはc++のmain関数を抜けてもプログラムを終了しないというフラグだ。emscriptenでwasmを読み込むと自動的にmain関数が実行され、その後グローバル変数の破棄処理が実行されてしまう。フレーム更新をする前に変数が破棄されてしまっては困るのでこのフラグを付けている。例えばグローバル変数std::string hoge;を用意して、main()でhoge = "hoge";、frame()でprintf("%s\n", hoge.c_str())とした場合にこのフラグを付けずにビルドしたら恐らくエラーになると思う。(もしくは空文字扱い)

次に-sENVIRONMENT=webだが、これは実行環境を明示するフラグである。wasmはいろいろな環境で実行できるが、環境によって使えるAPIが異なるのでその差を吸収するためのコードも仕込まれるのだが、ビルド時に予め想定する実行環境を明示することによって無駄な判定処理を省けるようにしている。今回はブラウザで動くコードなのでwebを指定した。

手順5で追加したコードについて

main.jsに追加したコードは2か所。

const wasm = require('./game');

まずwasmの読み込み部分だが、これは普通にgame.jsをrequireすることで実現できるので、特に説明することはない。ただし、requireを実行した時点で読み込まれているのはgame.jsだけでgame.wasmはまだ読み込み中なため、関数呼び出しはできないことに注意。

if(wasm.calledRun) {
    wasm._frame();
}

次に毎フレーム呼び出しの個所だが、ここではwasmの読み込み完了をポーリングチェックしている。wasm.postRunにコールバックを代入することで非同期読込完了のタイミングを知ることもできる。 また、c++ではframeという名前の関数だったが、jsからはアンダースコアがついた名前で呼び出すことになっているので注意だ。引数に文字列や配列を渡す関数の呼び出し方法はまた別の書き方になるが、今回はそこまでやらない。

まとめ

今回やったこと

  • c++をwasmにコンパイルできるようになった
  • ランキングゲームでwasmを実行できた

最初にも書いた通り今回の記事ではマルチプレイまではカバーしていない。そのためakashic serveコマンドで実行するとサーバーがエラーを吐いて止まるだろう。

というわけで次回は「マルチプレイでもwasmを使いたい!」をやろうと思う。内容は軽いはず…。重くなるのはその3「実践編」になってくると思う。(いつになるやら・・・)

書記素分割(Unicode14)

皆さんはUnicodeの闇をどこまで知っているだろうか・・・

自分もそんなには知らないが、今回は思った以上に沼だった『書記素分割』について紹介していこうと思う。もはやうろ覚えだが詳細は他の人の記事にお任せする👻

そもそもUnicodeとは

まずUnicodeのざっくりした紹介からしていこうと思う。と言いつついきなりリンクをぺとり。

www.buildinsider.net

元々コンピューター上の文字はアルファベットだけで始まったが、コンピューターが世界中に普及して、異なる言語文化圏同士でのやり取りも増えていく中で後付け仕様が膨らんでいた当時の文字コードに限界を感じた人々が文字コードの世界統一規格を作ろうと立ち上がったのが始まりということだと思う。多分

3つのエンコード

Unicodeの最小単位ともいえるのがコードポイントである。コードポイントは21bitの数値で1つの文字に対応している。つまり端的に言ってしまえばUnicodeとは「1文字が21bitな文字コード」ということなのだが、そんな中途半端なビット数をどう格納しているのだろうか?

Unicodeには3つのエンコード方式が存在する。UTF-32,UTF-16,UTF-8だ。それぞれ後ろについている数字がエンコード後のビット数を表す。

UTF-32は21bitをそのまま32bitに拡張するだけのエンコード方式だ。一番単純だが1文字4バイトなので無駄が多く、通常は保存するデータに使用されることはない。

UTF-16は2バイト単位で格納する。当初のUnicodeとはUTF-16のことだったのだが、全ての文字が16bitに収まらなかったためエンコード方式が3種類に増えてしまったらしい。ある意味一番存在意義が無いエンコード方式と言える。UTF-16では16bitからあふれた文字をサロゲートペアと呼ばれるニコイチ表現で定義しているため、1文字が2バイトもしくは4バイトとなる。

UTF-8はちょっと複雑だが一番実用性が高い。1つの文字を1~4バイトで表現するのだが以下のように1バイト目の値で何バイト文字か分かるようになっている。

  • 192未満なら1byteで1コードポイント
  • 224未満なら2byteで1コードポイント
  • 240未満なら3byteで1コードポイント
  • それ以外なら4byteで1コードポイント

ASCIIコードの範囲の文字しか使わなければ1文字1バイトで済むのでかなり効率的である。

書記素クラスタ

世界中の文字をエンコードするのにこれだけのルールで済むなら悪くないだろう。しかし、そうは問屋が卸さない。なんとコードポイント同士も合体することがあるのだ。それが書記素クラスタと呼ばれるものである。

https://re.buildinsider.net/language/csharpunicode/01/05.gif
書記素クラスターの例 こちらから引用

書記素クラスターの利点は、限られたコードポイント空間の節約だと思われる。例えば肌トーンと呼ばれる絵文字があるが、これは「肌を含む絵文字」の直後に配置することによって直前の絵文字と合体し肌の色を変更するという機能がある。これによって「肌を含む絵文字」×「肌色バリエーション」個の組み合わせだけ必要な絵文字を「肌を含む絵文字」+「肌色バリエーション」個に抑えることができる。「肌を含む絵文字」も「肌色バリエーション」も今後増えることを考えると書記素クラスターの合体仕様は必須に思える。一方で、「ひらがな+濁点=濁点付き文字」になる仕様や「ハングル文字をパーツ連結で表現」する仕様においては疑問がある。なぜならこれらはバラバラのパーツも合体後の文字もコードポイントが割り当てられているので、むしろコードポイントを無駄に浪費しているとしか思えないからだ。目的がコードポイント空間の節約でないならそれでも良いとは思うが、なんというか「文字と文字を合体させて別の文字が作れる」という仕様で遊んでるだけなんじゃないのと言いたくなるような文字がちらほらある。

――この辺にややイラっとしたのは次で紹介する書記素分割の処理が非常に面倒だったからである。実装しながら何度も「これ要る?」と思わされたのだ。

書記素分割

ではここから本題の書記素分割を実装していく。エンコードUTF-8とする。

UTF-8からコードポイントへのデコードに関しては1バイトずつデータを見て変換するだけなので実装上問題ない。しかし、書記素クラスターの連結ルールに関してはそんなスマートな仕様は無く、ユニコードコンソーシアムが公開しているデータベースを元にハードコードで実装しなくてはならない。

それがこれである。

https://www.unicode.org/Public/15.0.0/ucd/auxiliary/GraphemeBreakProperty.txt

デデドン!!(絶望)

なんだこれは、たまげたなぁ・・・

なんと書記素を正確に取り出すにはDB検索をして力技で判定を行う必要があるらしい。しかもこのルール、Unicodeのバージョンによってどんどん更新されており新バージョンが出るたびに実装し直しが必要なのだ。この辺のことを記事にした先人の方がいらっしゃるのでぺとり。

ufcpp.net

上記ではUnicode12.0.0の実装が行われていたが、自分が実装した時は既にUnicode14.0.0が出ていたので上記を参考にしつつ14.0.0で同様の実装を行った。しかし、12.0.0と14.0.0では結構ルールもデータベーステキストの形式も異なっており公式の英語ドキュメントを読み漁る必要があったので大分しんどかった・・・。

先人の実装を参考に作ってみた結果がこちら↓

c++版(クリックで展開)

gist.github.com

gist.github.com

凄まじく長い判定関数・・・。えぐすぎるだろ!もちろん手書きではなく公式のDBを元に自動生成している。

c++版はc++20のコルーチンを利用して連結判定のステートマシンを実装した。公式でもステートマシンで実装するといい感じヨと書いていたので、めったにないc++コルーチンの使いどころやんけ!とウッキウキで実装した*1。ちなみにこれ↑このままじゃコンパイル通らないので使いたい場合は足りないヘッダや名前空間の付与など各自でお願いします。

c#版(クリックで展開)

gist.github.com

gist.github.com

コード生成はC#で書いたのでついでにC#実装も載せる。こちらもコルーチンでステートマシンを実装。

これで正しく文字描画が出来るぞ👍🏻

結局なんでこんな大変な思いをして書記素分割を実装してきたかというとゲームでユーザー入力の文字に含まれる絵文字などを正しく表示するためである。実はアカシックエンジンのラベルは書記素分割をやっていないので一部の文字で表示が崩れてしまう。

ゲーム画面のスクショ
このスクショは「黒い肌の人差し指」をアカシックエンジンのラベルと自前のラベルで比較したものである。左上がアカシックで右下が自前のもの。アカシックはそもそも色が付いていないという話もあるが、重要なのはそこではなく、2文字に分離してしまっていることである。この絵文字はコードポイント的には2文字で構成されており、アカシックはコードポイント単位でしか文字を認識しないためこのような描画になってしまうのだ。これを正しく描画するには2文字まとめてキャンバス2DのfillText()に渡す必要がある。

ちなみになぜアカシックエンジンがわざわざ1文字ずつfillText()しているかというと、DynamicFontのテクスチャアトラスにレンダリング結果をキャッシュするためだ。UnityのTextMeshProなんかでもそのようにしている。

youtu.be
TextMeshProで動的に文字がテクスチャ化される様子

文字のレンダリングというのは結構重い。数十点~数百点の制御点からなるベクター画像のレンダリングに他ならないからだ。そんな重い処理を頻繁に行っていてはゲームの邪魔をしてしまう。そこで、巨大なテクスチャにレンダリング結果を詰め込んでおいて、次回以降はそのテクスチャの部分描画によって文字の表示をすることでかなりの軽量化になる。まぁその辺の実装めんどいので毎回文字をレンダリングしてるゲームも多いだろうが、アカシックは割とちゃんとしてるので1文字ずつキャッシュしている。しかし、書記素分割まではやってないために絵文字の分離という問題が起きてしまったのだ。*2


ゲームにおける動的な文字描画に関しては別記事でまとめるつもりだが、今回はそれを実現する上で書記素分割まで実装しているとより見た目が良くなるよというお話でした。

*1:ちなみにコルーチンはこう見えて結構メモリアロケートするのでnew/delete演算子をオーバーライドしていい感じにアロケータをカスタマイズすることをお勧めする。当方のゲームではもちろんメモリを使い回している。

*2:というか書記素分割くらいブラウザAPIとして提供してくれよとは思う・・・ブラウザはやってるんだからさ