皆さんこんにちは。この記事では、TwitterのPlayer cardを用いてツイートにスーパー正男のプレイ動画**(?)**を埋め込めるようにした話をしようと思います。
この記事はスーパー正男Re同盟 年越しカレンダー2019-2020 の10日目の記事です。
ツイートに載せるには このプレイログもなかなかインパクトがあるな(ぇ
— 🈚️うひょ🤪 (@uhyo_) January 1, 2020
裏技ステージなので理解が難しいのが難点だが(ぇ
https://t.co/ilSE3SZJHA
↑プレイ動画(?)を埋め込んだツイートの例。Web版Twitterならツイートのなかでプレイ動画(?)を再生できます。Qiitaに埋め込んだツイート経由でもそのままプレイ動画(?)を再生できて大変よいですね。
背景
スーパー正男は福田直人さんによって開発された同人ゲームで、ジャンルは2Dアクションです。当初はJavaアプレットとして開発され、その後JavaScriptに移植されました。現在スーパー正男として知られているのは「まさおコンストラクション」というバージョンで、これは誰でも自分で作ったステージを自分のウェブサイト上で公開できるのが特徴です。ニコニコ大百科の「スーパー正男」の記事によれば、これまでに作られた正男のステージ数は30000を超えるようです。その全盛期はいわゆる「Flash黄金時代」と重なるところがあるらしく、Flashゲームのリンク集に正男ステージへのリンクが混ざっている(Javaなのに)という光景は日常茶飯事でした。
自分が制作した正男のステージを公開する手段として、自分のウェブサイトに載せる以外にも正男投稿サービスを使用するという手があります。筆者によるmasao.spaceもそのひとつです。これは2015年9月10日に公開されたサービスで、顕著な特徴としてユーザーが「プレイログ」を投稿できる機能があります。
masao.spaceに投稿された正男をプレイしたユーザーは、コメントの投稿時にプレイログを添付することができます。プレイログはそのユーザーのプレイのリプレイデータであり、これを通して他のユーザーとプレイを共有することができます。プレイログは、クリアの証拠としたりスーパープレイを自慢したりするという使い道があります。
今回、masao.spaceの新機能としてプレイログをツイートに埋め込む機能を追加しました。この機に、プレイログをツイートに埋め込めるようになるまでに軌跡をこの記事にまとめます。
実装は主に2段階に分かれます。まず、第一段階としてmasao.spaceのサービス上でプレイログを保存・再生できる機能が実装されました。これは実は何年か前に実装済みです。第二段階として、ツイートでプレイログ共有用のURLをツイートするとツイートにプレイ動画が埋め込まれ、Webの場合は埋め込まれた状態で再生可能という機能を実装しました。これが昨年末に作ったものです。
それでは、順番に見ていきましょう。この機能の実装に際してどのようなことを考えたのかが伝われば幸いです。
プレイログの保存・再生
リプレイを保存・再生可能にするための仕組みはすぐ思いつくものが2つほどありますね。その一つは動画で保存するものです。ユーザーがプレイした様子を録画・アップロードしておけば、リプレイを再生したいときはその動画を再生するだけでOKです。
しかし、今回その方針は取りませんでした。その理由は、masao.spaceのサービスが月1000円のVPSで動いていることです。今流行りのクラウドを使わない主な理由は従量課金が怖いからです。MongoDB, Redis, アプリケーションサーバー(node.js製)、あとh2oが全部乗っている上に、アップロードされたファイルのストレージもこのサーバーです。もっと言えば、筆者が他に個人的に作っているサービスも全部同じVPSに乗っており、node.jsアプリケーションが常時4個ほど立ってMongoDBやMySQLのデーモン共々1GBのメモリを奪いあっています。ということで、アップロードされた動画を保存・配信するなどという富豪的なことをしていてはすぐに様々なリソースが足りなくなってしまいます。
代わりに取った方針は、ユーザーの入力データを記録し、リプレイの再生時はゲームエンジンをその入力で動作させるというものです。あとで詳説しますが、ユーザーがキーを1つ押すのと1つ離すのがそれぞれ1バイトのデータになります。典型的なリプレイではユーザーの入力回数はせいぜい数千回程度であると考えられ、データ量でいうとせいぜい数KBですから、動画を保存するのに比べてたいへん安上がりですね。
逆に言えば、無闇にキーを押しまくるプレイログは容量が大きくなってしまうということです。こんなことを明かしてしまうと今から攻撃されるのが恐ろしいですね。
データ保存形式の利点と欠点
ユーザーの入力をデータ化したほうが動画よりもデータ量が少ないのは当然です。グラフィックスとか正男や敵の動きといった情報は、動画の場合はそれ自身のデータに含まれていることになりますが、入力データの場合はそれらはゲームエンジン側に移譲されており、本質的にデータに含まれる情報が少ないからです。
一方で、動画方式に比べて明らかな欠点もあります。それは、必ず前から順番に再生する必要がある点です。特に、巻き戻しや途中再生の実装は入力データの方式では不可能です。途中からの再生をなんとか実装したい場合は、ゲーム開始からその地点までの入力データを実際に処理する必要があり、処理時間やCPU負荷の観点から現実的ではありません。一応、途中のメモリ状態のスナップショットを取ることでそこから再開できるようにする方法もありますが、何箇所もスナップショットを取るとメモリ消費がえらいことになります。
また、ゲームエンジンの挙動が変更されたりステージが修正されたりしたらリプレイが正しくできなくなるという問題もあります。実際、スマブラにアップデートが入ると過去のリプレイデータが再生できなくなりますが、これも同じ事情であると思われます(スマブラの場合は本当の動画に変換しておくことで保存可能です)。
今回はこの利点・欠点を天秤にかけて、データ量の少なさを重視して入力データ方式を取ったことになります。
ランダム性の対処
ところで、入力のデータを用いてリプレイを再生する方式では、ランダム性をどう扱うかが問題になります。リプレイのためにはユーザーが同じ入力をすれば毎回同じ挙動をする必要がありますが、ゲームにランダム要素がある場合はその妨げになります。実際、正男にもチコリンのはっぱカッターの軌道など、ランダムに決められる部分があります。何よりも先に、まずこの問題に対処する必要がありました。
このためにやったことは、正男エンジンの乱数生成部分をXorshiftに書き換えることです。当初はMath.random
を用いて実装されていましたが、これを独自の実装に変えることでseedを与えることができるようになりました。seedとは擬似乱数生成のために最初に必要となるデータであり、同じseedからは同じ乱数が生成されます。
これにより、リプレイデータにユーザーの入力に加えて乱数のseedを保存しておくことで、乱数生成の結果も再現することができるようになります。具体的なアルゴリズムとしてXorshiftを選んだ意味は特に深いものがあるわけではなく、実装が簡単で計算の負荷が低そう、それでいて擬似乱数としての性能も悪くなさそうであることから選択しました。
入力データのフォーマット
では、ユーザー入力はどのようなフォーマットで保存されているのでしょうか。仕様はGitHubのwikiにメモしてありますが、ここでも少し解説します。メタデータやヘッダー部(乱数seedもここです)などは省略して、ボディ部のフォーマットを見てみます。
ボディ部は実際の入力データの羅列となっています。1つのデータは1バイト(8ビット)であり、その内訳は下の表のようになっています。
f | x | x | x | x | k | k | k |
0: キー入力 1: キー解放 |
前回のデータとの間隔 | キー番号 |
最上位のビットf
が0
ならばキーの入力、1
ならばキーの解放を表します。
次のxxxx
は4ビットのデータで、前回のデータからの経過時間を表します。この単位はフレームです。つまり、これが前回のデータから1フレーム後の操作を表すデータならば、xxxx
は0001
となります。同フレーム内で複数の操作が行われた場合は0000
となることもありえます。
次のkkk
はキー番号です。3ビットあることから8通りのキーをここで表せるようになっています。割り当ては以下の通りです。
キー番号 | 意味 |
---|---|
000 | nop |
001 | 左 |
010 | 上 |
011 | 右 |
100 | 下 |
101 | TR1 (スペースまたはZ) |
110 | X |
111 | その他 |
fxxxxkkk
というビット数の割り当てについては、まずキー番号に何ビット必要か考えて、さらに1ビットをf
に割り当て、残りがx
というように決めました。
キー番号の割り当て
キー番号の割り当てにはいくつか工夫の色が見えます。まず000
に割り当てられたnop
ですが、これは実際のキー入力ではありません。実際nopを示す入力データが流れてきても何も行いません。では、これはなんのために用意されたのかといえば、長期間の空白を表すためです。
前回のデータからの間隔を表すxxxx
が4ビットであることから、16フレーム以上の空白を表すことができません。そのような場合は15フレームごとにnop
のデータを挟むことによって表現します。これは無駄が多いように思えますが、実際のゲームプレイでは16フレーム以上の間無入力という状態はあまり多くありません。また、無入力が長く続いた場合も、(正男のデフォルトフレームレートが15fps弱ということを鑑みて)1秒に1バイト程度の消費は許容範囲と判断しました。
残りの上下左右、TR1、Xというのが正男の操作に使う典型的なキーです。111
に割り当てられているその他というのは他のキー入力に対応するための値で、この値が使われた場合はその次の1バイトをキーコードとして用います。通常のゲームプレイではその他は出てこない想定ですが、特殊な正男で使われる場合に備えて記録可能になっています。あまり出てこない想定なので贅沢に2バイトを使うようになっています。
TR1というのは、スペースキーまたはZキーのことで、正男ではこの2つのキーはどちらもジャンプを表します。実はプレイログデータのヘッダー部に、TR1がスペースキーなのかZキーなのかを表すフラグが存在しています。TR1でないもう片方のキーが入力された場合はその他扱いとなります。
厄介なことに、スペースキーとZキーは同じジャンプを表しますがわずかに挙動が違うらしく、データ上どちらかに統一することはできません。ただ、両方にキーコードを与えるとkkk
を3ビットに抑えることができなかったので、一工夫加えてこのような形になっています。これは、大抵のプレイヤーはスペースキーとZキーのどちらかのみを用いるという推測に基づいています。
JavaScriptにおけるプレイログのデコード
この記事のタグにJavaScriptを入れて人目を引きたいので、プレイログをJavaScriptで扱う方法について解説します。関連コードはGitHubのmc_canvasレポジトリ内にあります。
プレイログはバイナリデータなので、典型的にはArrayBufferとして取得できます。ただ、このオブジェクトからは直接データを取得することができません。
データを得る一つの方法は、バッファを「型」(TypedArray)にはめることです。例えばUint8Arrayを用いることで、メモリを符号なし1バイト整数の配列として扱うことができます。上述のコードでも、ボディ部が1バイトのデータの列となっていることからこのUint8Arrayを用いて扱われています。他にも、4バイトの数値が並んでいる場合はUint32Arrayを使うほうが扱いやすいなど、場合によってベストな手段は異なります。
今回のプレイログに関しては、ボディ部は1バイト整数の連続である一方、ヘッダ部などには4バイト整数なども出てきます。そのようにデータ長が混在している場合に取れる選択肢はいくつかあります。一つは、全部Uint8Arrayを使って読んで、ビット演算などを駆使して手動で組み合わせて複数バイトの数値を取り出す方法です。もう一つは場合ごとに適切なTypedArray (Uint8ArrayやUint32Arrayなどの総称です)を用いる方法、そして最後はDataViewを使う方法です。
DataViewは最も手軽で汎用性の高い方法です。Uint8Arrayなどはあくまでメモリを配列として見なすものなので、目的の位置のデータを得るにはインデックスアクセスをすることになりました。一方、DataViewはデータを取得するためのメソッドを提供しています。例えばgetUint32
メソッドは指定した位置から4バイト分のデータが表す符号なし整数を取得できます。実際のコードには次のような例があります。
var v = new DataView(this.inputdata),
head_idx = this.head_idx;
var ran_seed = v.getUint32(head_idx, false);
この例では、this.inputdata
というArrayBufferの、head_idx
という位置から4バイト(32ビット)整数を読み取っています。getUint32
の第2引数のfalse
はエンディアンの指定で、false
はビッグエンディアンを表します。
様々なサイズが混在したデータを扱う状況では、一つのDataView
を作れば全て対応できるのでこの方法が便利です。
ちなみに、V8では約1年半くらい前までDataViewが遅かったらしく、パフォーマンスが重要なアプリケーションではDataViewをあえて避けて、Uint8Arrayなどを使って頑張っていたようです。今となってはもう心配ありませんが。
結局、プレイログのデコードでは、ヘッダ部にはDataViewを、ボディ部にはUint8Arrayを使用しています。
余談ですが、DataView
には8バイトをまとめて64ビット整数として読んでくれるメソッドは今の所(ES2019には)ありません。これは、JavaScriptは64ビット整数を正確に表せなかったからです。
ES2020ではBigIntがJavaScriptに追加されて64ビット整数が正確に扱えるようになり、それに伴ってTypedArrayの仲間としてBigInt64ArrayとBigUint64Arratが追加されることになっています。また、DataViewにもそれに合わせてgetBigInt64などのメソッドが追加されています。
プレイログの保存
ユーザーがアップロードしたプレイログはサーバーに保存されるわけですが、今回はバイナリデータのままMongoDBに保存しています。しかも、念には念を入れてgzip圧縮して容量の節約を試みています。このデータを要求したHTTPリクエストでAccept-Encoding
にgzip
が含まれていた場合は圧縮したまま送ることができるのでたいへんお得ですね。
以上が第一段階、プレイログの実装についての話でした、
Player cardによるツイートへの埋め込み
Twitterでは、URLをツイートすると画像付きの大きなリンクが表示されることがあります。これはTwitter Cardsという機能であり、巷では他のSNS対応とまとめて「OGP対応」とか呼ばれることもあるものです。
その一種であるPlayer Cardは、動画プレイヤーのようなものをツイートに埋め込めるようになる機能です。Web版の場合、こちらが用意したプレイヤーをiframe
でツイートに埋め込むことができます。ただし、最初は画像に再生マークがついたカードが表示され、それをユーザーがクリックするとiframe
が用意されるという流れになります。
モバイルOS用のネイティブアプリの場合は残念ながらiframe
の埋め込みはできず、タップするとプレイヤーのページがブラウザで開くという挙動になります。生の動画ファイルのURLを渡すことでツイート内での再生ができるらしいのですが、今回は前述の通り動画ではないのでこの方法は使用不可能です。
Player cardsのためのmetaタグの指定
Twitterの公式に書いてありますからいちいちこの記事で解説するまでもないのですが、一応masao.spaceから具体例を提示しておきます(このツイートの場合)。
<meta name="twitter:card" content="player">
<meta name="twitter:site" content="@masaospace">
<meta name="twitter:player" content="https://masao.space/playlog/8b41d9666309e0f5899324fa873cb217cae58fd2e650dae1f358443bcd228a49">
<meta name="twitter:player:width" content="512">
<meta name="twitter:player:height" content="320">
<meta name="twitter:title" value="隘路をゴリ押し | masao.space">
<meta name="twitter:image" value="https://masao.space/static/title.gif">
<meta name="twitter:description" value="クリアした残り時間が、あなたが迎えた年です。
「大晦日のスーパー正男24時間一本勝負 2019」参加作品
お題「ゴリ押し」
http://bouningenlupus.web.fc2.com/24h2019.html">
このように、twitter:card
でplayer
を指定します。ドキュメントによれば、このとき上記のタグのうちtwitter:description
以外は全て必須となります。特に重要なのがtwitter:player
で、iframe
のなかに表示してほしいページのURLを指定します。また、twitter:player:width
とtwitter:player:height
でiframe
のサイズを指定できます。
また、twitter:image
も重要で、iframe
が設置される前に表示される一枚絵を指定します。ドキュメントによればこの画像は「68,600ピクセル以上」のサイズを持っていて、かつ5MB未満である必要があるようです。
これだけでPlayer Cardの実装ができてしまいました。じつに簡単ですね。
プレイヤーページの注意点
ドキュメントには、プレイヤーについてもいくつか注意点が書いてあります。実装面では、「全てのリソースがHTTPSで提供されなければいけない」とか「プレイヤーは与えられたビューポートに最大化してフィットしなければいけない」といったものがあります。
また、ポリシー面でも注意事項があります。要約すると「提供されるのはただの音声/動画プレイヤーでなければいけない」ということです。まず、音声/動画以外の余計なコンテンツを提供するのは禁止されています。プレイ可能なゲームやその他のインタラクティブなコンテンツは禁止です。「プレイヤー内に購入ボタンなどを付けるのもだめ」という注意もあります。ただし、プレイヤー内で直接そのような行動ができるのがまずいのであり、外部サイトに誘導するリンクを設置するのは認められているようです(ドキュメントに、可能な行為として“or enhance your Player Card content with links to your website or mobile application.”との記載があります)。
他にも、プレイヤーとして動作するように「再生・停止ができるようにする」ことや「自動再生するコンテンツは最初は音声をオフにする」といったことが挙げられています。
プレイヤーページの実装
さて、今回の実装では、iframe内のコンテンツが読み込まれても自動的に再生しないようにしています。これは、効果音がある正男の場合にユーザーのインタラクション無しに音声を再生するのが厳しそう、というのが第一の理由です。また、画面をタップすると再生・停止できる機能を持たせています。この再生・停止はゲームのメインループを止めたり動かしたりすることで実現しています。
前述の通り、今回の方式では巻き戻し・早送りなどができないという欠点があるのですが、幸いにして上述のポリシーではこのような機能は必須のものとして要求されていないようです。たいへん助かりましたね。
プレイヤーにはそれ以外のボタンとして、「最初から再生」ボタンとサウンドのON/OFFボタン、あとはゲームページへのリンクだけというシンプルな構成です。
まとめ
この記事では、masao.spaceに投稿されたプレイログをツイートにPlayer cardとして埋め込める機能を開発した一部始終をまとめました。PC用をはじめとするWeb版では実際にツイート内でプレイログを再生できてありがたいですね。記事中で説明した通り、プレイ動画**(?)**というのは実際の動画ではなくその場でゲームエンジンを動かしていることを意味しています。
筆者によるいつもの記事に比べるとあまり技術に深入りしていない内容になった気もしますが、お正月特番ということでご容赦ください(この記事は2020年1月2日公開です)。
最近はゲームを実際に遊ばずにプレイ動画をみて満足する人もいると聞きますが、最近すっかり知名度が下がってしまったスーパー正男の復興のためにはまずはプレイ動画だけでも目に触れるようにするのが第一歩です。この機能がその一助にでもなれば幸いですね。記事冒頭に貼った正男のプレイ動画などはなかなか圧巻ですから(裏技がテーマの正男なのでやや変な動きが多いですが)、ぜひ見てみてください。
ツイートに載せるには このプレイログもなかなかインパクトがあるな(ぇ
— 🈚️うひょ🤪 (@uhyo_) January 1, 2020
裏技ステージなので理解が難しいのが難点だが(ぇ
https://t.co/ilSE3SZJHA
この記事はスーパー正男Re同盟 年越しカレンダー2019-2020 の10日目の記事でした。このカレンダーにも正男がいくつか投稿されていますから、懐かしいという方や興味を持ったという方はぜひ遊んでみましょう。もちろん、masao.spaceでも正男を遊べますよ。