yaneuraoGameScript2000で行こう! |
written by やねうらお
1.ノベルゲームの時間【前編】です
今回は、ノベルゲーム(アドベンチャーゲーム)の制作について書きます。単純なようで、結構、奥が深いので、前編後編と2回に分けて、今回は前編ということで書いていきたいと思います。今回の内容だけで、簡単なノベルは作っていただけるようになっています。
前回までの内容は、私のホームページで参照できますので、今回から参加って人も読んでみてください。すぐに追いつけますから!
さて、今回使用するゲームスクリプト(yaneuraoGameScript2000以下、ygs2k)の最新版(1.64b)は、これです。(右クリックして対象をファイルへ保存してください) あと、今回必要になるインポートライブラリの最新版(1.04)は、これです。インポートライブラリってのは、前々回に少しだけ出てきましたね。ygs2k本体だけでは足りない部分の拡張を行なうためのものです。このインポートライブラリのlibフォルダを、YGS2000.exeのあるフォルダにコピー(移動)させてやれば、このインポートライブラリが使えるようになります。(以下図)
今回は、このインポートライブラリが大活躍するので、インポートライブラリのマニュアルやサンプルプログラムとかも参考にしてくださいね。
2.ノベルゲームとは何か?
ノベルゲームは、ちまたに氾濫していますのでいまさら説明の必要も無いと思いますが、だいたい、イメージとしては、以下の画面のような感じだと思います。
(2001年4月末にWAFFLEから販売になった『蒼き大地』のゲーム画面より。このプログラムは、私が担当しています)
ゲーム画面は、だいたいこんな感じでしょうか。パソコンで読む小説と言った感じですね。特徴としては、
1.メッセージ(文字)が1文字ずつ出てくる
2.メッセージの早送り巻き戻し等の機能がある
でしょうか。その他、ゲーム内では、
3.選択肢があり、それをユーザーが選ぶことによって、異なるストーリーが展開される
だとか、最近では、育成ものと呼ばれるように、複雑なフラグやパラメータが絡むものもあります。
一度に説明するのは大変なので、ひとつずつ順番におさえていきましょう。
3.文字列の処理
ノベルですから、メッセージ(文字列)を表示しないことには、話になりません。
文字列の処理について、私は、いままで、あまり詳しく説明してきませんでした。
はっきり言って、ygs2kの文字列処理は、C言語に似ていて、あまりスマートではないです。
でも、この部分を避けて通るわけにもいかないので、ちょうどいい機会なので詳しく説明します。
たとえば、
str x = "ABC"; void main( ){ TextLayerOn(0,0,0); TextOut(0,x); loop halt; } |
こんなプログラムを書くとします。実行すると、画面には、ABCが表示されます。
strと言うのは、文字列(string)の頭文字からとっています。
このプログラムを見ると、あたかも、strと書けば、文字列の入る変数が用意できて、そこに代入出来るかのように見えます。
たとえば、
str x = "ABC"; str y; void main( ){ TextLayerOn(0,0,0); y = x; TextOut(0,y); loop halt; } |
このプログラムでは、変数xに入っている文字を、y = x;の部分で、文字列をコピーできたかのように見えます。
ところが、これが違うのです。
あらかじめ言っておきますが、これはygs2kの仕様の話でして、他の言語でもこうなっているとは限りません。(むしろなってはいないでしょう)
C言語を少しかじられた方ならば、strはchar*に過ぎないと言えばわかってもらえると思うのですが、C言語をまったくご存知のない方のために、この部分を少し説明をさせていただきたいと思います。
まず、ygs2kには、変数は、整数型しかありません。intもlongもstrも、表記(見かけ)が違うだけで、内部的にはすべて単なる数字を表現するものとして処理されています。
intとlongはともかく、strは文字列を現しているものなのに、どうして数字なのでしょうか?
この部分、簡単なようで、結構複雑なので、ゆっくり理解してくださいね..。
まず、上の例では、変数Xには、数値しか入っていません。たとえば、2345だとか、そういう数値しか入っていません。この数値が何を意味するものかというと、2345番地に、ABCという文字が格納されていますよ、ということなのです。
いま、番地、という言葉を使いましたが、これは、この文字列データが格納されている住所みたいなものです。その場所が、2345なのです。この番地のことを、メモリアドレス、あるいは単にアドレスと呼ぶこともあります。以下でも、アドレスという言葉が何度か出てきますので、覚えておいてくださいね。
いま、この図にあるように、2345番地にABC¥0というデータが格納されています。ところで、この図のABCの最後の¥0というのは何なんでしょうか?これは、ゼロを意味するもので、文字列の最後に付加して、そこで文字列が終了していることを示します。ygs2kでは、" "(ダブルコーテーション)で囲まれた文字列は、格納される際に、自動的に終端に、このゼロを付加します。これにより、終端が明確になるわけです。
つまり、2345番地には、A,B,C,ゼロが格納されています。ひとつの番地には、半角1文字しか入らないので、この場合、2345番地だけで収まらないわけで、2345番地の次の2346番地、2347番地、2348番地も使用しています。
いま一度、さきほどの例に戻りましょう。
str x = "ABC"; str y; void main( ){ TextLayerOn(0,0,0); y = x; TextOut(0,y); loop halt; } |
y = x; の部分で、あたかも、変数xから変数yへ、文字列がコピーされたようですが、そうではなかったのです。文字列の中身は何もコピーされていません。コピーされたのは、格納されている番地を表す、2345という数字のみです。しかし、あたかも中身がコピーされたかのように動作しています。ここに騙されてはいけません。中身は決してコピーされていません。
それでは文字列の中身をコピーするプログラムを紹介しましょう。
str x = "ABC"; void main( ){ TextLayerOn(0,0,0); sprintf(string[0],"%s",x); TextOut(0,string[0]); loop halt; } |
これならば、文字列の中身がコピーされます。スクリプトマニュアルのsprintfの部分の説明、および、string変数の説明も参考にしてみてください。マニュアルから引用しますと、
要は、string[0]からstring[15]までの16個の文字列を扱えるという ことです。それぞれは、512バイト(=全角で256文字)まで代入する ことが出来ます。 |
ということです。事前に、512バイトの領域を確保してあり、そのアドレスがstring[0]に入っています。以下、string[15]まで同様に、それぞれ512バイトの領域が確保されたアドレスが入っています。文字列をコピーするためには、このように文字列を保存するための領域が必要になります。さきほどのプログラム
str x = "ABC"; str y; void main( ){ TextLayerOn(0,0,0); y = x; TextOut(0,y); loop halt; } |
は、そのような領域を確保していませんし、また、その領域のコピーもしていません。単に、アドレスを代入(コピー)しているだけなのです。
これをまず理解してください。まとめると、
1.文字列をコピーするには、それを保存するための領域が必要
2.string[0]〜string[15]には、それぞれ、事前に512バイト確保された、そのメモリのアドレスが入っている
3.文字列のコピーには、代入演算子( = )ではなく、sprintfなどを使う
ですね。
4.一文字ずつ表示する
一文字ずつ表示するためには、その前段階として、文字列を、一文字ずつ切り出さなくてはなりません。文字列を切り出す関数は、インポートライブラリのstringライブラリのほうにいくつかあります。これを使うことにしましょう。
import "lib/string" // インポートライブラリのstringを使えるようにする str sz = "この文字列を一文字ずつ切り出します\nうまく表示されましたか?"; void main( ){ int nLen; // 文字列の長さ int n; // 現在表示している文字数 nLen = StrLen(sz); // szの文字数を返す。 TextLayerOn(0,0,0); for(n = 0; n <= nLen ; n++){ ClearSecondary(); LeftStr(sz,n,string[0]); // szの左からn文字をstring[0]に入れる(最後に\0を付加) TextOut(0,string[0]); halt; } loop halt; } |
このLeftStrというのが、stringライブラリのほうで用意されている関数です。この仕様については、インポートライブラリのマニュアルから引用すると、
LeftStr(str1,n,str2); // str1の左からn文字をstr2に入れる(最後に\0を付加) |
です。この他にも、stringライブラリには文字列操作のための便利な関数がいろいろ用意されているので、こういった文字列操作を行ないたいときには、一度、目を通しておくと良いでしょう。
ところで、この画面表示なのですが、よく見ると、文字の終端に「・」のようなものが見えるかと思います。これは何なのでしょうか?
これが、また、C言語とかに存在する、面倒な文字の問題なのです。
ちょっと復習(?)も兼ねるのですが、半角の1文字というのは1バイトです。1バイトというのは0から255までの数字です。(0から255までの数字を表現できます) つまり1バイトで256種類の文字を表現(識別)できるというわけです。ところが、漢字は数千種類ありますので、1バイトでは表現しきれません。2バイト使います。漢字を表現するには、SHIFT_JIS、EUC、UNICODEなど、いろいろな方式はあるのですが、ygs2kでは、一般的な(?)SHIFT_JISを採用しています。まあ、このへんの事情については知らなくても構いません。ともかく、漢字は2バイトで構成されているということです。
つまり、この画面の最後に表示されている「・」は、漢字の1バイト目だけ(半分だけ)を表示してしまっている状態なのです。これを回避するには、最後が漢字の1バイト目であれば、削ってやる(か、2バイト目を追加してやる)ような処理を加えれば良いのです。
上記のLeftStrの部分を、
LeftStr(sz,n-IsKanji(sz,n-1),string[0]); |
このように変更してやれば良いです。IsKanjiというのは、nバイト目が漢字の1バイト目かを判定するための関数で、stringライブラリのほうで用意されている関数です。漢字の1バイト目であれば、1、そうでなければ0が返ります。そこで、コピーしようとする最後の文字が漢字の1バイト目ならば1文字コピー文字を減らすという処理をしています。他にも、方法はいろいろあります。これが正解というわけではありませんので、他の方法についても考えてみてください。
5.ファイルから読み込む
文字を1文字ずつ表示するところまでは出来ました。BG(背景となるCG)の表示は、いままで何度か使っているLoadBitmap〜BltFastで出来ますよね。このへんは、難しくないはずです。
ところで、シナリオライターがプログラムを書いて、そのプログラムのなかに、シナリオやら、表示するCGやらを記述しないといけないのでしょうか?
これは、一応、No!と言っておきます。普通は、そのような作りにしてはマズイです。
シナリオ(画面に表示するメッセージ)や、BG(背景に表示するCGやCG名)は、データです。この手のデータは、プログラムからは分離されていることが望ましいのです。
どうやって、分離すれば良いのでしょうか?ひとつの方法としてはファイル(テキストファイル)として分離してしまうことです。
ファイルからメッセージを読み込み、その内容を画面に表示する。そういうスタイルにすれば、プログラムのなかに画面に表示する文字を直接打ち込む必要はなくなりますし、シナリオライターがプログラムをメンテナンスする必要もなくなります。
ファイルから読み込みながら、それを表示していく..と聞くと難しそうな印象を受けるかも知れませんが、そんなに難しくありませんし、何かと応用の利く手法ですので、この際に覚えてください。
まずインポートライブラリのfileライブラリを見てください。(インポートライブラリのマニュアル§2−3.ファイル操作のところです)
使いかたは、このマニュアルからでは、少し分かり辛いと思いますが、インポートライブラリのほうについてくるfileのサンプルプログラムのほうなども合わせてご覧になれば、わかっていただけるかと思います。
まず、プログラムの流れとしては
1.シナリオファイルをオープン(開く)
2.そこから1行読み込む
3.シナリオファイルの終端まで達していたならば6.へ
4.それを表示する
5.2.へ戻る
6.シナリオファイルをクローズ(閉じる)
という感じになります。サクっとプログラムを書いてみましょう。
import "lib/string" import "lib/file" void main(){ int nLen; // 文字列の長さ int n; // 現在表示している文字数 int hHandle; // ファイルハンドル hHandle = OpenFile("scn.txt","r"); TextLayerOn(0,0,0); loop { if (ReadLine(hHandle,string[1])!=0) break; nLen = StrLen(string[1]); // 読み込んだ行の文字数を返す。 for(n = 0; n <= nLen ; n++){ ClearSecondary(); LeftStr(string[1],n-IsKanji(string[1],n-1),string[0]); TextOut(0,string[0]); halt; } } loop halt; // ← 最後の画面で停止させるための永久ループ。実際には不要。 CloseFile(hHandle); } |
内容は、さっきと同じなので、難しくはないと思います。ファイルから1行読み込んだものをstring[1]に格納し、そこからn文字切り出してstring[0]にコピーして表示、ということをしています。シナリオファイルとしては、scn.txt というテキストファイルが存在するものとしてプログラムしています。この内容は、何でも良いのですが、仮に、以下のような内容のテキストファイルを用意して実行してみてください。
これがシナリオなのだ! メッセージを表示するのだ!! どんどん表示するのだ!! まだまだ表示するのだ!!! もっともっと表示するのだ!!! 今日は、これくらいにしといてやるのだ.. |
実行すれば、1行ずつこのメッセージがあわただしく表示されたはずです。
意図していた動作とは違うかも知れませんが、ファイルから読み込むという部分については理解していただけたと思います。
それでは、これを拡張していきましょう。
6.シナリオファイルに何もかもを書く
データを完全にプログラムと分離するという理念を貫くならば、シナリオファイルのほうで、BG(背景として表示するCG)や効果音なども指定したいと思うのは自然であり、当然そうあるべきだと思います。
こういうとき、メタタグ(meta tag)と言って、特殊な記号を使い「この直後にあるのはBGのCG名ですよ」ということを示したり、「この直後にあるのは、再生すべきwavファイル名ですよ」というのを示したりします。
特殊な記号と言っても、♀とか♂だとか〒とか、そういうのである必要はありません(笑)
他のものと区別がつけば良いのです。たとえば、# で始まる行は、特殊な意味を持つものとします。
#bg "script/bg1.bmp" これがシナリオなのだ! メッセージを表示するのだ!! #bg "script/bg2.bmp" どんどん表示するのだ!! まだまだ表示するのだ!!! #wav "script/se1.wav" もっともっと表示するのだ!!! 今日は、これくらいにしといてやるのだ.. |
のように、bgと書いたあとに、画像ファイル名を書けば、それがBGとして表示され、wavと書いたあとにwavファイル名を書けば、それがseとして表示されるような仕様にするというのも一つの方法です。実際のゲームでは、立ちキャラや、フラグ関係の処理までこのシナリオファイルで記述できるようにすることが多いです。
ここでは、基本的なフレームだけ理解できれば良いと思うので、とりあえず、上のようなファイルを読み込み、実際に表示するプログラムを書いてみることにしましょう。
まず、#で始まってbgと書かれている行かどうかを判定する必要がありそうです。ここで覚えておいて便利なのは、このようなパターンマッチを手助けするために、インポートライブラリにはpatmatch(パターンマッチ)ライブラリというのが用意されていて、これを利用すれば、意外なほど簡単にこの手のプログラムを書けるということです。
import "lib/string" import "lib/file" import "lib/patmatch" void main(){ int nLen; // 文字列の長さ int n; // 現在表示している文字数 int hHandle; // ファイルハンドル hHandle = OpenFile("script/scn.txt","r"); TextLayerOn(0,30,400); TextSize(0,24); TextColor(0,255,128,128); CreateSurface(0,640,480); loop { if (ReadLine(hHandle,string[1])!=0) break; // BG指定か? if (IsPatMatch(string[1],"#bg%*\"%s\"",string[2])) { LoadBitmap(string[2],0,0); goto LoopEnd; } // WAV再生指定か? if (IsPatMatch(string[1],"#wav%*\"%s\"",string[2])) { LoadWave(string[2],0); PlayWave(0); goto LoopEnd; } nLen = StrLen(string[1]); // szの文字数を返す。 for(n = 0; n <= nLen ; n++){ BltFast(0,0,0); // 画面のクリアの代わり LeftStr(string[1],n-IsKanji(string[1],n-1),string[0]); TextOut(0,string[0]); halt; } // キー入力を待つ loop { BltFast(0,0,0); // 画面のクリアの代わり halt; KeyInput(); if (IsPushSpaceKey()) break; } LoopEnd:; } CloseFile(hHandle); } |
プログラムは、先ほどのものとそれほど変わらないので、見ていただければある程度わかるかと思います。1行表示するごとにキー入力待ちのメッセージを追加したことと、BG、WAVの指定が出来るようにしてあります。
こんなものを用意するとします。ええっと、このスクリプトと、BG、WAVもセットにして用意しましたので、解凍し、YGS2000.exeの存在するフォルダにscriptというフォルダを作り、解凍したファイルを移動させ、YGS2000.exeをさっそく実行してみてください。(YGS2000.exeの存在するフォルダにscriptフォルダが存在し、そのなかにgamestart.cが存在するようにしてください。scriptフォルダのなかにscriptフォルダを作ってしまわないように注意してください)
実行すると、以下のような画面になったかと思います。スペースキーを押せば読み進めます。
ちなみに、これ、私がいま自社で作ってるシューティングゲームのラフ画です^^; (ご存知ない方もおられると思いますが、私ことやねうらおは、ゲーム会社を経営していて、自社でもゲームを制作しています。このゲームは年末ごろ発売予定です。よかったらチェックしてくださいね^^;)
さてさて。
ここで、ファイルから読み込んで、パターンマッチによって処理していく、というのを説明しましたが、これは、シナリオファイルに限ったことではありません。その他の設定ファイルや、画像の座標データ等も、なるべくならプログラムのなかに埋め込むのではなく、なるべくテキストファイルのようなもので外部に出してあるほうが、望ましいと思います。(あまり数が増えてくると読み込みに時間がかかったりしますが)
7.フラグ処理
今回、ノベルとして最低限必要な部分は完成しましたので、あとエフェクトや、文字表示枠等の処理は次回にやるとして、残りの時間で、フラグ処理について説明したいと思うのです。
ノベル系のゲームを作ると、たいていは分岐があったりします。それも、結構複雑な分岐だったりします。そのへんを、いかにして処理するかという考えかたの部分について書きます。
フラグ(flag)、というのは旗のことです。旗だから、立っているか、降りているか。数字で言えば1か0か。あるいは非ゼロかゼロか。true(真)かfalse(偽)か。OnかOffか、というような2値論理を意味するのが普通です。しかし、実際のゲーム制作の現場では、「美絵ちゃんとHした回数」だとか、「志保ちゃんの高感度」だとかそういう整数値もフラグの一種と考えることもあります。ここでも、その立場をとります。
シナリオは、仮に、シナリオナンバーで管理することにしましょう。シナリオナンバーごとにファイルを用意します。ファイルとして複数に分割すると、シナリオ管理が少し面倒っぽいですが、そこは普通はPerl等で簡単なマクロを組んで対応します。Perlというのは、こういうテキスト整形向きのプログラミング言語なのですが、Perlがわからない人は、今回説明したファイルから読み込んで解析する部分を応用して、このようなテキストファイル(シナリオソースファイル)から、
#Scene 1 これがシーンファイル1 になります #Scene 2 これがシーンファイル2 なのです |
#Scene と書かれている部分ごとに複数のファイルに自動的に分割してくれるプログラムを組んでみても良いでしょう。
ともかく、シナリオナンバーで管理して、そのナンバーごとにファイルを用意するところまで出来たとします。
ここからの説明は、やや概念的かつ、専門的になるのですが、参考程度に聞いていただければそれで良いと思います。
まず、ゲームのほうには、ゲームのフェーズを示すパラメータを用意します。要するに、現在のゲーム時間や、進行度を表すパラメータです。次に何番のシナリオが発生するのかは、1.このフェーズカウンタ , 2.フラグ によって決定します。
『蒼き大地』(WAFFLE制作販売)の場合、以下のようにMicrosoft Excel(表計算ソフト)で、表を管理し、それをワークシート関数(IF,VLOOKUP,MATCH等)で数値化して、csv形式のデータとして書き出して使っていました。
別に、このへんは独自のノウハウでも何でもなく、他社でもやっています。表計算ソフトは補助的なもので、使わなければ作れないという種類のものではないですが、これを手作業でやっていたのでは、とてつもない作業になります。
次に発生するシーン(シナリオナンバー)を決定するアルゴリズムは、概念的には、こうです。
int GetEvent( ) { int nEvent; loop { nEvent = 現在のフェーズに合致するイベントを取得( ); フェーズを次のフェーズに( ); if (nEvent != 無し) { return nEvent; } } } |
大雑把ですが、だいたいこんな感じでしょう。フェーズの経過のさせかたは、ゲームにより異なりますが…。
また、実際には、シナリオナンバーだけではなく、選択分岐、エンディング、スタッフロール等も、イベントの一種として扱うと都合が良いのですが、ここではとりあえず、シナリオナンバーだと考えてもらえば良いです。
プログラムとしては、以下のようになります。
// 条件を満たすかチェック int IsQualified(int nSceneNo){ alt { case nSceneNo==14 : return f(1); case nSceneNo==18 : return f(73); case nSceneNo==19 : return f(21); //中略 } // 条件を満たしたときのフラグ更新 void DoQualify(int nSceneNo){ alt { case nSceneNo==1: f(47)=1; return ; case nSceneNo==2: f(52)=1; return ; case nSceneNo==8: f(73)=1; return ; //中略 |
「ある番号のシナリオ発生条件を満たしているか」を調べる IsQualified を何度も呼び出して現在のフラグ条件を満たすイベント(シナリオ)を探します。そして、そのイベントを発生させたあとは、「ある番号のシナリオが発生したときのフラグ更新を行なう」関数、DoQualifyによって、そのイベントに相応するフラグを更新します。この相互作用によって、ゲームが進行して行くというわけです。
プログラム的に難しいところは無いと思いますが、こういうのを作った経験が無いと、IsQualifiedとDoQualifyを分離するという発想が浮かばないかも知れません。
また、実際には、シナリオ担当の人がExcelで作成したフラグ表をCSV形式(カンマ区切りのベタテキスト)に変換して、それをPerlで簡単な文字置換を行なうものを作成し、上のようなcase文を自動生成するようにします。(別に手打ちでも構いませんが)
概念的な説明で少しわかりにくかったかと思いますが、ゲームごとに少しずつ異なる部分ですんで、こういう説明にならざるを得ないのです。参考になりましたら、幸いです。
8.まとめ
今回は、いままで取り上げていなかったインポートライブラリについての説明に重点を置いてみました。インポートライブラリを使用することによって、ygs2kの世界も一段と開けてくると思います。
また、ノベルゲーム上、必要になってくるノウハウについて説明しましたが、まだ少し足りない部分もあります。たとえば画面効果だとか、立ちキャラの管理だとか..。しかし、画面効果は、以前説明したトランジションを使えば出来ますし、立ちキャラについてもBGと同様の処理ですので、難しくは無いはずです。
あと、文字の周囲がガタガタなのに気付かれたでしょうか?いわゆるアンチエイリアスが掛かっていないからこうなるのですが、このへんは、いまのygs2kのほうの限界です。新しいygs2kのほうでは、このへんも改善しようと思っています。
よろしければ、今後ともygs2kの活動にお付き合いください。
インポートライブラリで良きプログラミングライフを!Good
luck!