- GONDIN - Ver0.50 技術資料 ○はじめに 本ドキュメントは、GONDINで使っている主要な技術について概説したものである。 具体的には、以下について述べる。 ・フォントシステム ・Effect Map ・オブジェクト間リンク ・記録 ○フォントシステム ゲーム中で使われている謎フォントは、xpmで用意した画像をEzPutで表示している。 これは「Lightning Force」で実装した数字・アルファベットに限定した機構を、 ひらがなや絵文字対応に発展させたものである。 ファイルfont.h, font.cとして実装されている。 ■基本的な考え方 単純には、各文字のxpm画像を適当な配列変数に入れておいて、 文字列内の書く文字に対応した画像を順に表示すればよい。 低レベルなものとして、指定したIDのフォントを表示するdraw_fontも用意しているが、 通常の文字列表示に近い形で扱えるように、「指定座標に文字列を描画する」関数 draw_fontseq()も用意し、この関数内で自動的に文字列内の各文字フォントを 並べて描くようにしている。 また、今回のようにマルチバイト文字も扱う場合には、以下の問題が生じる。 ・表示処理を高速に行うためには、文字コードと画像の対応をテーブルで持つのが  良いが、マルチバイトの文字コードは値が大きいため、単純に配列にすると  メモリ消費が大きい。 ・1バイト文字ならばchar型で1バイトずつ文字コードを取り出してテーブルを 引けるが、日本語を含む一般の文字列はシングルバイト・マルチバイトが混在する ため、文字種の判定を行い、正しく区切る必要がある。 後者については、EUC, ShiftJIS, UTF-8に対応する必要もあり、 汎用のjstr.h/jstr.cを作成し、本システムでもこのコードを利用することにした。 前者については、文字コード空間中で今回使う範囲が狭いため、 実際に得られた文字コードをそのままインデックスとするかわりに、 1バイト文字・ひらがな・記号・漢字に分類してから各分類内でのオフセット値を 求めることで、より範囲の狭い空間にマッピングしている。 なお、後述するようにVer0.50では絵文字用にフォント名指定を導入することで、 漢字を排除し、マルチバイト文字に必要な空間を大幅に削減した。 ただし、jstrによる文字切り出し処理やマッピング処理により、 通常のEzDrawStringによる描画より大きなオーバヘッドが生じる。 現在の計算機の性能では通常問題にならないと思われるが、 EzGraphでダブルバッファリングをする場合は毎フレームごとに同じ文字列を描く 必要があるため、無駄が大きい。 そこで、通常の文字列の代わりに、font_id_t型の並びによる文字列表現を導入した。 font_id_tはフォントのIDを表す列挙型で、 fi_aが「あ」、fi_kutenが「。」、fi_gondinがごんでぃんの 実の絵文字、などと用意した各フォントに対応している。 本システムでは、この型の値が連続し、最後にfi_endが来るものを フォントシーケンスと呼び、draw_fontなどの引数として扱う。 たとえば font_id_t msg = {fi_ko, fi_n, fi_ni, fi_ti, fi_ha, fi_end}; draw_fontseq(10, 10, msg); などと書くことで、「こんにちは」が用意したフォントで表示される。 フォントシーケンスを扱う処理は、一般的な文字列処理と同様な考え方で、 「fi_endが出現するまで順にフォントIDを処理する」という形で記述できる。 ■フォントシーケンスの生成 上記のように直接フォントシーケンスを書くのは非常に面倒なので、 通常の文字列からフォントシーケンスに変換する関数を用意している。 str2fontseq(char *str, font_id_t fid[]); は、文字列strの各文字を フォントIDに変換してfidに順次格納することで、 strに対応するフォントシーケンスを作り出す。 strは日本語を含むことができるが、フォントが用意された文字以外を含んでは ならない(フォントがない文字があると、いわゆる「豆腐」を表示する)。 また、fidはstrの文字数+fi_end分の大きさを用意しておく必要がある。 文字列リテラルなど書き換えを行わない場合は、 font_id_t *alloc_fontseq(char *str); に文字列を渡すと、内部バッファ上にフォントシーケンスを生成して、 そのポインタを返す。生成したシーケンスを解放する手段がないため 書き換えたり一時的にしか使わないフォントシーケンスには使えないが、 文字列リテラルをこの関数の引数にすることで、 対応する「フォントシーケンス・リテラルっぽいもの」を簡単に作り出せる。 複数のリテラルが欲しい場合は、alloc_fontseq_arrayを用いると、 この種のリテラルの配列を作ってくれる。 フォントシーケンスを作り出した後は、 fontseq_cpy(), fontseq_len(), fontseq_cat()で コピー・文字数の取得・連結ができる。 途中で内容が変化するフォントシーケンスは、必要な大きさの配列を確保し、 文字列の内容が変わるたびにstr2fontseq()で再変換すればよい。 たとえばスコア表示であれば、通常なら char score_str[16]; int score; void add_score(int val){ score += val; sprintf(score_str, "%4dてん", score); } void timerh(void){ EzDrawString(100, 100, score_str); } と書くところを、 char score_str[16]; font_id_t score_fontseq[16]; int score; void add_score(int val){ score += val; sprintf(score_str, "%4dてん", score); str2fontseq(score_str, score_fontseq); } void timerh(void){ draw_fontseq(100, 100, score_fontseq); } とすればよい。 ※EzDrawStringは指定座標が文字列の左下になるが、draw_fontseqは左上になるので、 厳密には上記コードではy座標を修正する必要がある。 タイマ表示など頻繁に値が変わり、sprintfのオーバヘッドが気になる場合は、 uint2fontseq(unsigned int n, int len, font_id_t fid[]) で整数値から固定長のフォントシーケンスへの変換ができ、 inc_fontseq(int len, font_id_t fid[]); dec_fontseq(int len, font_id_t fid[]); によりフォントシーケンスが表す整数値を直接+1/-1できる。 ■フォント名指定記法の導入 Ver0.10では、すべてのフォントに対して以下のように1文字を一対一対応で割り当て、 あるフォントを表示するには対応する文字を渡すようにしていた。 ・英数字・記号については1バイトの該当文字に対応 ・ひらがなはマルチバイトの該当文字に対応 ・星やカーソルキー、各目標アイコンとして使う絵文字などは、 そのフォントを連想させるマルチバイト文字に対応 例: 星「☆」、ごんでぃんの実「実」 マルチバイト文字については、文字コード空間全体でテーブルを作ると巨大すぎるため、 ・ひらがな・記号・漢字ごとに空間を分ける ・各空間に必要なサイズは、その空間内でじっさいにフォントが定義されている文字  の中で最大・最小の文字コードを調べて決定する ようにしている。しかし、ひらがな群や記号群はEUC/ShiftJIS/UTF-8の いずれにおいてもそれぞれ近隣の文字コードが割り当てられているが、 「実」「歩」「飛」といった漢字は空間内に分散しており、 しかも文字コード体系によって配置がまったく異なる。 このため、EUCでは妥当なテーブルに収まったものが UTF-8では巨大なテーブルが必要になる、といった問題が生じていた。 今回は表示対象に漢字を含めていないので、 任意のマルチバイト文字に対応させる必要はない。 そこで、Ver0.50では以下のように変更した。 ・ひらがな・記号は従来通りテーブル化し、直接文字列内に書けるようにする ・絵文字は漢字一文字で表すのをやめて、文字列内にフォント名を書く 具体的には、'[', ']'で囲った連続する英字(大文字・小文字)を フォント名として認識し、テーブルを引いてフォント名からフォントIDに変換する。 この結果、たとえばVer0.10ではプログラム中で "すてーじせんたくで ★がひょうじされます。" のように与えていた文字列を、 "すてーじせんたくで [StarOn]がひょうじされます。" のように与えるようになった。フォント名StarOnはフォントID fi_star_onに 対応しているため、これで従来通りの絵文字入り文字列を表示できる。 現在の実装ではフォント名からIDへの変換がリニアサーチであり、 文字列からfontseqへの変換コストが上昇する。 このため、頻繁に実行するsprintfの書式文字列では フォント指定を用いないことが望ましい。 ■長所と短所 この方式には、以下のようなメリットがある。 ・Windows/Linuxなど実行環境に依存せず、画面の見た目を完全に同一にできる。  環境によりインストールされたフォントが異なる可能性を考慮しなくてよいし、  ポイントではなくピクセル単位で大きさを決められる。 ・プログラム内で実際に使う文字のフォントだけ用意すればよい。 ・絵文字など、特殊な文字を用意し、他の文字と混在させられる。 ・複数の色を使用できる。今回は絵文字以外は単色だが、グラデーション  を掛けたフォントなども作れる。 ・等幅フォントになるため、文字列の内容に依存せずレイアウトが容易である。 一方、以下のデメリットもある。 ・少なくとも使いたい文字のフォントは、すべて用意する必要がある。 ・フォント情報がxpmというメモリ効率のよくない形式で実行ファイルに  埋め込まれるため、大量にフォントを使うと実行ファイルが肥大化する (実行時にファイルからフォント情報を読み込んでメモリ上でxpmデータを 組み立てる手はある)。 ・プロポーショナルフォントどころか、半角・全角すら区別されず、  すべて同じ大きさのフォントになる  (半角やプロポーショナルを実装することも可能だが、手間に引き合うかどうか疑問)。 ・文字列とフォントシーケンスという2種類の表現形式を併用する必要がある。 ・表示の際に文字の色を変えることはできない。 ○Effect Map ■背景 地形のないシューティングゲームであれば、登場するオブジェクト間の相互作用は、 ・自機と敵機、自機と敵機の弾、敵機と自機の弾、のように組み合わせが決まっている。 ・一般的には重なり判定と、その結果としての一方または双方の破壊である。 と単純な形をしている。したがって、同種オブジェクトを配列などで保持して ループを回すことで、簡単に相互作用を処理できる。 しかし、GONDINのような迷路内に様々な性質・挙動のオブジェクトが存在する ゲームでは、 1. 迷路の壁や棘など、いわゆる「地形」は数が多く、また決まった位置に配置したい。 2. 相互作用の条件が単純な「キャラクタ同士の重なり」とは限らない。   たとえば「がっが」のツッコミは本体の左右に発生するし、 壁やリフトは「重ならないように他のキャラを押し返す」作用を持つ。 3. 作用は複数の相手に及ぶ可能性がある一方で、効果範囲は通常、 そのオブジェクトの周囲などに限定される といった複雑さを持つ。1.については、 ・二次元配列で「ステージマップ」を表す。ステージの構造はこの配列内の  データで表す。 ・自機など動くオブジェクトと地形との重なり判定は、マップ配列中で  オブジェクトの現在座標に対応する要素を参照することで行う。  この場合、処理量は動くオブジェクトの個数に比例し、マップの複雑さや  地形オブジェクトの数には依存しない。 といった手法が一般的であるが、 ・マップ構造の単位は判定の単位としては大きすぎることがある。 ・「壁」や「足場」の効果は表現できるが、「リフトに乗っていると  移動に合わせて上のキャラも動く」「発生したツッコミに巻き込まれたので  吹っ飛ぶ」といった動的かつ細かい効果は表現しにくい。 といった欠点を持つ。 前者については、16x16などのチップキャラを敷き詰めてマップを作ることが 多いのに対し、アクションゲームではキャラクタの移動がもっと細かいため、 16x16単位の当たり判定では不十分である。 後者については、シューティングゲームのようにループでオブジェクト間の 直接相互作用として処理する方法もあるが、 ・ステージ内のすべてのリフトについて、すべての生き物が上に乗っているかの判定 ・ステージ内のすべてのがっがについて、すべての生き物がツッコミ圏内かの判定     : のように延々と記述する必要がある。また、位置的に無関係なオブジェクトまで チェック対象になるから、全体としてはかなり効率が悪い。 ■機構の仕組み そこで、以下のような機構を導入した。 ・ステージの構造は、16x16ピクセルを単位とする二次元グリッドで表現する。  ステージ定義ファイル*.stgは、グリッドの各要素を1バイト文字一文字で表す  データと、立札の内容などの補足情報から成る。  これを読み込んだ後はchar型二次元配列としてメモリ上に保持し、  ステージ開始時には配列の各要素をチェックしながら対応するオブジェクトを  生成していく。 ・別途、相互作用判定用の配列を用意する。これをEffect Mapと呼ぶ。  こちらはより細かく、4x4ピクセルを1単位とする。  この大きさにした理由は、細かくしすぎると細かい判定が可能な一方で  必要メモリ量や判定時の処理量が増大すること、 オブジェクト間の「すり抜け」を防ぐには基本的な移動速度を1単位とした方が 良いが、1フレームの移動量が4ピクセルであれば、 ちぃちぃくんの移動速度が体感的に適度であったこと、などである。 ・ゲーム内の座標系はEffect Mapの要素を単位とする。  つまり、内部処理的には画面を4x4ピクセル単位で区切ったグリッド上で動いており、  表示の際に4倍して扱っている。  このグリッド座標系上で、Effect Mapは各グリッドごとに、  「そのグリッドに設定された効果」を表している。  配列の要素はunsigned int型なので、グリッドごとに最大32種類の「効果」  について独立して有無を保持できる。 ・各オブジェクトは、自身の性質に応じてグリッド単位で効果を書き込む。 たとえばリフトであれば、直上の全グリッドに対して eb_block_down(そのグリッドより下への移動をブロック)を設定し、 さらに右方向へ移動しているときはやはり直上全グリッドにeb_push_right (乗っているオブジェクトを右へ移動させようとする)も設定する。 一方、各オブジェクトは自身の周囲のグリッド上の効果をチェックし、 自分に関係あるものが設定されていたら対応する処理を行う。 たとえば、生き物は自身の大きさを表す矩形領域のうち、 一番下の行についてeb_block_downが一つでもあれば、 「足場がある」→「落下しない、ジャンプできる」と判定できる。  同様に、「リフトは乗っている者を一緒に移動させる」挙動は、  「リフトがeb_push_right効果をEffect Mapに書く」  「足元にeb_push_right効果が設定されているオブジェクトは自ら右に強制移動する」  ことで、実現される。  このようにEffect Mapを介した間接的な相互作用を行うことで、  直接相互作用では大雑把に言って オブジェクト数の二乗×チェックする相互作用の数  の計算量が必要なところを、 オブジェクト数×チェックする相互作用の数×読み書きする効果の範囲    程度に抑えられる。また、グリッド単位で細かく「当たり判定」を設定できる。 Effect Mapへの読み書きは、対象グリッドの座標で配列要素を指定し、 さらに注目する効果に対応したビットについてビット演算を行えばよいので、 効率よく処理できる。 同じグリッドに同種の効果が重なったときは上書きされるが、 ほとんどの場合は効果の発生者を問わず同じ処理を施せばよいから、問題ない。 しかし、たとえばリフトの移動に合わせて効果の位置もずらすなど、 一度書き込んだ効果を消去する必要がある。 単純に消すと他のオブジェクトにより同種効果が設定されている場合も 誤って消してしまうことになる。毎フレームごとに空のEffect Mapから作り直せば 消去を考える必要はなくなるが、その場合は処理が重くなる。 さらに、各オブジェクトについて自身の効果設定と自身に及ぶ効果のチェックを 同時に行うと、先に処理されたオブジェクトの効果は後のオブジェクトに作用するが、 逆の作用が起きない、という非対称性の問題も生じる。 そこで、次のようにした。 ・オブジェクトを、以下の3種類に分類する  ・hard_static: 壁など。基本的に変化しない  ・hard_dynamic: リフトなど。変化し、hard_dynamic同士では影響し合うが、 soft_dynamicからの影響は受けない。 ・soft_dynamic: 生き物など。変化し、hard_dynamicや他のsoft_dynamicからの 影響を受ける ・Effect Mapを、静的マップ1個と動的マップ2個に分ける。 ・壁などの変化しない効果は、最初に静的マップに書いておく。  壊れる壁のように、後から消しても他のオブジェクトの(静的)効果を 巻き込まない場合は、やはり静的マップに書ける。 ・動くオブジェクトは、毎フレームごとに自身の効果を動的マップに書く。 ・動的マップはcurrentとnextの2個として、フレームごとに交互に切り替える。 ・毎フレームごとに、以下のようにして処理を行う。 1. hard_dynamicオブジェクトを順に処理 2. 動的マップの切り替え   a. currentとnextを交換 b. 新nextに静的マップの内容をコピー 3. ちぃちぃくんと、その他のsoft_dynamicオブジェクトを順に処理  各オブジェクトは、動的マップのうちcurrentを参照して、nextに設定する。 このため、全員が前フレームでの全員分の設定情報を公平に参照できる。 そのかわり、設定した効果は1フレーム後まで発揮されない。 ただし、切り替えタイミングを2にしているため、hard_dynamicの設定は そのフレームでのsoft_dynamicで参照される。 これにより、リフトに追随して動くといった1フレームの遅れが致命的な 相互作用を早期に解決し、生き物間の攻撃のように常に一定ラグなら 問題ない要素については、公平に1フレーム遅延して処理している。 ■長所と短所 この方式には、以下のようなメリットがある。 ・様々なオブジェクト間相互作用を効率よく処理できる。 ・細かく当たり判定を行える。 一方、以下のデメリットもある。 ・1段階の間接的な相互作用を及ぼすのに1フレーム掛かる。  このため、リフト上にブロックを積み上げてまとめて持ち上げるような  処理は作れない。リフトに直接接しているブロックが押されて上昇した時、  同じフレームでその上のブロックまで作用を波及させて持ち上げないと、  めり込んでしまうからである。 ○オブジェクト間リンク スイッチは対応づけたリフトやバリアに作用し、ON/OFFを切り替える。 また、立札は対応するメッセージを表示する。 こうした作用はEffect Mapでは表現できないので、 オブジェクト間に直接リンク用のポインタメンバを用意し、 このポインタで対応付けられた相手を指すようにした。 ゲーム中は、スイッチの状態が変化した時にこのポインタを参照し、 他のオブジェクトを指している場合は相手を変化させる。 ステージファイルでは、リンク構造を簡単に書けるようにするため、 オブジェクトを表す文字の隣にリンクのラベル(数字かアルファベット)を 書けるようにし、同じラベルを持つオブジェクト同士をリンクするようにした。 Ver0.10では、実質的に一対一接続しかできなかった。 各オブジェクトはリンク先へのポインタを一つしか持たないため、 一つのスイッチで複数のバリアをON/OFFするといった 一対多接続ができない。 一方、複数のスイッチが同じバリアにリンクすることは可能だが、 実際の動作としては各スイッチについてON/OFFが変化した時にバリアに通知され、 それにしたがって(他のスイッチの現状に関わりなく)バリアがON/OFFされるので、 「すべてのスイッチをOFFにするとバリアが消える」といった、 一般的に必要な挙動は実現できない。 このため、Ver0.50では多対多リンクを保持できる「ハブ」オブジェクトを導入した。 ハブは複数のリンク先(以下、出力)へのポインタを持ち、 それらすべてに変化を通知する。 このため、一対多接続が可能である。 また多対一接続についても、自身を指しているオブジェクト(以下、入力)の総数と、 各オブジェクトのON/OFF状態を記録する。 あるオブジェクトから変化が通知されると、以下の動作を行う。 1. そのオブジェクトの記録を更新する。 2. 現在の各オブジェクトの記録に対し指定された論理演算を行って、 自身のON/OFFを決定する。 3. 自身のON/OFF状態が変化したら、リンク先すべてのオブジェクトに対し、 変化を通知する。 論理演算は、以下のものが可能である  ・AND : すべての入力がONのときのみON  ・OR : 入力がひとつでもONならON  ・XOR : ONの入力が奇数個ならON ・MAJORITY : ONの入力が入力総数の半分以上ならON ステージファイルの記述を簡単にするため、ハブはオブジェクト扱いとし、 入出力はリンクのラベルを使って指定する。状態などを保持するため 実際にハブはオブジェクトとして生成されるが、 生成以外の関数ポインタがNULLであるため、表示もされないし、 フレーム毎のアクションも行わない。 ハブが自ら状態を変化させることはないため、ON/OFF状態の伝搬および論理演算は、 他のオブジェクトの状態変化時にリンクをたどる際に処理している。 注意点として、ハブとオブジェクトのリンクもラベルによる間接指定を用いるため、 複雑なリンクが制限される。ハブは複数リンクが可能だが、入力・出力ラベルは それぞれ一つしか使えない。たとえばあるハブの入力・出力ラベルにそれぞれ I, Oを割り当てたとすると、このハブの入力としたいオブジェクトはすべて、 リンクラベルをIとしなければならない。 たとえば、以下のようにスイッチとバリアを接続したペアが2組あるとする。 S1 -> a -> B1 S3 -> c -> B3 ここで新たなスイッチS2を追加し、 S1・S2がONのときB1がON、S2・S3がONのときB3がONにしたいとする。 ハブの機能的には S1 ----------> |H1 | -> B1 +-> |AND | S2 -> |H2|-+ +-> |H3 | S3 ----------> |AND | -> B3 のように繋げばよい。H1, H3はそれぞれ2個のスイッチからの入力をANDして バリアに通知するハブであり、H2はスイッチS2の出力をH1, H3に一対多接続 させるためのものである。 しかし実際の接続はラベルで指定するため、このような接続はできない。 H2からH1, H3に接続することは可能だが、H2の出力ラベルは1個しか指定できないため、 H1, H3の入力ラベルを同一にしなければならなくなる。 そうなると、こんどはS1をH1のみに接続するのが不可能になる。 したがって、次のように接続しなければならない。 S1 -a-----------------------> |H1 | -> B1 +-> |AND | +-->|H4|-a-+ S2 -b-> |H2|-d-+ +-->|H5|-c-+ +-> |H3 | S3 -c-----------------------> |AND | -> B3 H2で分岐させたそれぞれの先にもう一段ハブを接続し、 それらのハブの出力ラベルを本来繋ぎたかった入力のラベルにする。 ○記録 本プログラムでは、ステージごとに記録ファイルを作成し、 そのステージ内の情報だけを保存している。 これは、ステージクリア型というゲームルール上、 ステージ単位で記録すれば十分であること、 このやり方であれば、将来ステージの追加・削除・順序変更があったり、 一部ステージの内容が変わった時も、影響のないステージの記録は 残せること、などの理由による。 ステージ選択画面にクリアステージ数や総得点、取得した星の合計などを 表示しているが、その情報も現在の個別記録ファイルの内容を集計することで 得ている。 また、ユーザレベルからは最高点と達成した目標の情報だけ記録しているように 見えるが、実際にはゲームごとに得点や食べた実の数にとどまらず、 死んだ生き物の数や歩いた歩数など細かく記録し、 ゲームをクリアするたびに各記録項目について、最低値・最高値・累積値を すべて記録している。 後は、必要に応じてこれらの情報を参照すればよい。 たとえばステージの最高得点は、得点の最高値で得られるし、 星の表示で必要な「過去に目標を達成しているか」という情報は、 そのステージの各目標について、最低または最高値と比較すれば判断できる。 このやり方であれば、ステージの目標が変更されたり達成基準値が変わっても、 過去の記録が新しい目標を達成できているかで判断できる。 また、記録ファイルにはプログラムのバージョンと、 ステージファイルのバージョンが記録されている。 これをチェックすることにより、 ・ゲームシステムが変更されて、一部の記録項目について計測方法が変わってしまった ・ステージの内容が変更されて、記録の意味がなくなってしまった ような場合に、記録の無効化ができる。 前者であれば全記録ファイルを破棄、もしくは該当項目のみクリアが妥当であるし、 後者であれば変更されたステージだけを対象にすればよい。 ○更新履歴 Ver0.50 - 絵文字用のフォント名記法の導入について説明を追記。 - ハブの導入に関する説明を追記。 Ver0.10 - 初版