テキストシューティング…それは、魅惑の結晶。 華麗なグラフィックを用意する必要は微塵もなく、その代わりコンソール画面を酷使することで、DirectXやCreateWindowすら用いることなく、最大化までサポートできてしまう手抜きの王道。 ああ、テキストシューティングよ永遠なれ!
これは、二年半前の企画物です。 元ネタは僕が中学生の頃に遡ります…が、思い出話をされても困るでしょうから割愛します。
今回は、もっと簡単にインベーダーモドキを例にして話を進めて行きましょう。 これはAdaで書きました。 Adaだからといって敬遠する必要はありません。 やっていることは、コンソール関係のAPI呼び出しと、至極単純なアルゴリズムだけです。
え?何ですと?テキストシューティングなんてださい?
…いじけてやる。いじけてやる。
ソースはここから。 例によって?ソースコードだけ。各自GNATでビルドして下さい。 Ada05のコンテナライブラリをAda95に持って来たものとWindows API ヘッダーも必要です。
...\akanvader>gnatmake akanvader.adb -O3 -gnatp -gnatn gcc -c -O3 -gnatp -gnatn akanvader.adb gcc -c -O3 -gnatp -gnatn game.ads gcc -c -O3 -gnatp -gnatn game-input.adb gcc -c -O3 -gnatp -gnatn game-screen.adb gcc -c -O3 -gnatp -gnatn game-time.adb gcc -c -O3 -gnatp -gnatn implementation.adb gcc -c -I./ -O3 -gnatp -gnatn -I- .../windows.adb gcc -c -I./ -O3 -gnatp -gnatn -I- .../structure.ads gcc -c -I./ -O3 -gnatp -gnatn -I- .../structure-list.adb gnatbind -x akanvader.ali gnatlink akanvader.ali
では、Akanvaderがビルドできることを確認したら、次へ進みましょう。
Adaのpackage機能は、超強力です。
ObjectPascalの
さて、当然Windows APIを使っていく訳ですが、パッケージでもってそれらをラップして、ゲーム本体のコードはAPIを意識せずに書けるようにしましょう。
with Ada.Calendar; package Game.Time is Step: constant := Duration(1.0 / 60); procedure Wait; procedure Reset; end Game.Time;
Durationというのは間隔を示す数値型です。
1.0 / 60 というのは、要するにゲームを60FPSで動作させる意思表示です。 このpackageを汎用にするのであれば、genericにしてこの数値を外から与えられるようにするべきでしょう。 しかしAdaでテキストシューティングを作るなどという奇特な事を何度もするつもりはないので(←おい)、ここに書いちゃってます。
Resetで覚えておく時刻を初期化して、Waitで前回のWaitまたはResetからStepだけ経過するまで待つ…単純な仕様ですね。
でもって実装は当然、time$が変化するまでfor…nextで空回しして一秒で何回ループできるか計測、以後その値を用いてウェイトループGetTickCountやtimeGetTimeで時刻を計測してSleepするわけです。
これが普通のGUIアプリなら、メッセージ処理を放棄してSleepなどちょっと行儀の悪い行為…つかフックを行う.dllにとっては迷惑千万…なのですが、コンソールアプリというのは、コンソールウィンドウはシステムが管理してくれて、その上で別スレッドとして走っているような状態です。
もちろんイベントもやって来ますが(GUIと異なり)一方的に通知してくるだけで応答義務は無いので、何の遠慮も無くSleepできます。
GUIアプリでは…流石にSetTimerは精度が悪すぎるので、timeSetEventか、MsgWaitForMultipleObjects辺りが妥当でしょう。別スレッドを作るのが一番素直です。 (ですよね?→mkmさん)
…とまあ、そういうことなのですが、Adaには時刻取得のための標準ライブラリもありますし、それどころかSleepに相当する言語機能すらあります(流石スレッドを言語機能として持つ言語!)ので、そっちを使いましょう。
package body Game.Time is Pre_Time: Ada.Calendar.Time; procedure Wait is use type Ada.Calendar.Time; T: Ada.Calendar.Time := Ada.Calendar.Clock; S: Duration := T - Pre_Time; begin if Step * 0.9 > S then delay Step - S; end if; Pre_Time := Pre_Time + Step; end Wait; procedure Reset is begin Pre_Time := Ada.Calendar.Clock; end Reset; end Game.Time;
待つかどうかの判定で0.9をかけていますが、あんまり正確にやってしまうと逆に遅れていってしまうので適当に余裕をみている訳です。 なに、1/60秒のさらに1/10なんて、ちょっと前後しても人間にはわかりません。
Ada.Calenderとかうざいです。この手のうざい表記はこれからも出て来ます。
要するにC++でusing namespace ~を書いていない状態なわけです。
もちろんAdaもその能力はありますが、だらだら書くのがAda風らしいので、やってません(w
*1
C++のusing namespace std;は、Adaではuse std;となります。
(いやもちろんAdaにstdなんて名前のパッケージは無いのですけど)
use typeというのは、ちょっと違って、他のパッケージで宣言された型について演算子を使うぞという宣言です。
これをしないと、代入や関数の引き数に渡すことだけが可能な、持ち運び専用ブラックボックス的扱いになります。
Adaでは、クラスでも何でもない単なる数値型ですら情報隠蔽の対象だったりするのです。
…実は演算子も関数の一種なので、use typeは正確に言えば、演算子を演算子表記法で使うための宣言なのですが。演算子を関数呼び出し風の表記法で呼ぶ時は不要です。
もちろんuse Ada.Calender;してしまえば、use typeとかも一切不要です。
9x系のDOS窓は、DOS窓です。 仮想86モードと呼ばれるCPUの機能の下で、MS-DOSが動いております。 これはWindows3.1エンハンスドモードから(あやふやな記憶なのに信じないように)で、擬似マルチタスクだったWindows3.1も、DOS窓の中身だけは本物のマルチタスクしてました。
そんなわけで、9xでは、command.comなわけです。 文字通りのDOS窓なのです。
NT系では、多分推測ですが、cmd.exeはふつーのexeで、ふつーのウィンドウを出してるだけじゃないでしょうかねえ…。 その上でDOSアプリが起動された時だけ、仮想86モードに切り替えているのかな? あと、フルスクリーン時は、ディスプレイモードをテキストモードに切り替えるとか。 スクロールバーが現れたり消えたりしているのを見てると、そんな風に思えますね。 *2 とにかくNT系では、コマンドプロンプトの画面がスクロールできる!便利!これは事実です。
何が言いたいかといいますと、NT系では、コンソール窓の大きさが不定な訳です。
普通は便利なのですが、ゲームでは、画面サイズを固定しないとなりません。
9x系や、フルスクリーン時を考えると、80×25決め打ちが一番いいでしょう。
*3
というわけで、最初はSCREEN 3: CONSOLE 0,25コンソール窓を80x25に設定します。
そして描画、となるわけですが…
いきなりexeが起動された場合だけを考えるなら、問答無用で黒い画面へ自在に上書きしてしまっていいのですが、コマンドプロンプトが既に開かれていて、その上でexe名がタイプされ起動された時のことを考えてみましょう。
ゲームが終わったら、表示内容が元に戻った方がかっこよくないですか?
"C:\>xxxx" ENTER ! その次の行に、"Loading ...50%" とか表示、数字があがっていく…100%!
おもむろに画面が切り替わりタイトルへ!
ゲームが終了すれば、起動前の画面に戻り、"GOOD BYE"とか表示された次の行に、"C:\>"が…。
…無理に理解してくれなくていいです。ぐすん。
事実として、コンソールアプリは、画面バッファを複数持てて、切り替えて使えます。 バッファをふたつ持って、毎フレーム交換すれば、ダブルバッファリングみたいな真似も可能です。
…ですが、バッファの切り替えよりは、所詮80×25の世界、メモリ上に次フレームの内容を全部用意して、一括で描いてしまった方が高速だったりします。 なので、ここでは、終了時、起動前の画面に復帰するというロマンのためだけに、画面バッファを使います。
with System; with Windows; package Game.Screen is subtype Handle is Windows.HANDLE; Input: constant HANDLE := Windows.GetStdHandle(Windows.STD_INPUT_HANDLE); Output: constant HANDLE := Windows.GetStdHandle(Windows.STD_OUTPUT_HANDLE); procedure Set_Title(Text: in String); procedure Set_Window_Size_80x25; function New_Screen return Handle; procedure Present(Screen: Handle); procedure Dispose(Screen: Handle); -- ... -- 描画、イベント処理は今回は触れない -- ... end Game.Screen;
今、しまった!と思いました。game-screen.adsでは、「メモリ上に次フレームの内容を全部用意して」のためのバッファにScreen_Bufferという名前を使い、Windows APIとして用意されている画面バッファは、単にScreenって名前にしていました。 「三日前のコードは他人のコード」の格言ピタリです。 なので、そういう風に読んでください。お願いします。
で、定義について説明を加えますと、Handleは、このパッケージを使う側がwith Windows;と書かなくても利用できるように、エイリアス定義を行っています。 Windows APIはゲーム本体のルーチンからは隠す方針ですから。
Input, Outputは、標準入出力のハンドルを保存しています。
Set_Titleは、コンソール窓のタイトルです。 Set_Window_Size_80x25は、前述のサイズ変更です。
で、New_Screen, Present, Disposeが本題です。 それぞれ、画面バッファの作成、表示、破棄です。 Adaはオーバーロードが使えますので、いちいちPresent_ScreenとかDispose_Screenなどは普通書きません。それに、タイマーのところで書きましたように、useを書かない限りは、使う方では、Game.Screen.Presentのように書くことになりますから。 New_ScreenだけScreenを付けている理由は、newが予約語だからです。 AdaはC++と違い、返り値型だけのオーバーロードも可能で、きちんと扱ってくれますので、例えばCreateとか他と被りまくる名前でも何の問題もありません。 まあ、Handle型は画面バッファだけではなく、ファイルとかイベントとか色々あるので、明示しとくのが吉とでも思ったのでしょう。三日前の僕は。(覚えて無いもん)
さて、画面バッファの作成と、窓サイズの変更では、窓サイズの変更が先です。 つまり、Set_Window_Size_80x25を、New_Screenより先に実行します。 現在の窓サイズよりも小さい画面バッファを作れません。 よって、ユーザーがコマンドプロンプトを広げていたりする場合(難しいことは何も無く窓の端をドラッグすればすぐできます)、New_Screenを先に実行するとエラーになります。
…なんてことも意識しながら、実装を見てみましょう。
package body Game.Screen is procedure Set_Title(Text: in String) is begin Windows.SetConsoleTitle(Windows.Addr(Text & Windows.Nul)); end; procedure Set_Window_Size_80x25 is Window_Rect: aliased constant Windows.SMALL_RECT := (0, 0, 79, 24); begin Windows.SetConsoleWindowInfo(Output, Windows.BOOL_TRUE, Window_Rect'Unchecked_Access); end; function New_Screen return Windows.HANDLE is use type Windows.COORD; Cursor_Info: aliased constant Windows.CONSOLE_CURSOR_INFO := (Size => 100, Visible => Windows.BOOL_FALSE); Result: Windows.HANDLE; Size: aliased constant Windows.COORD := (X => 80, Y => 25); begin Result := Windows.CreateConsoleScreenBuffer( Windows.GENERIC_READ or Windows.GENERIC_WRITE, 0, null, Windows.CONSOLE_TEXTMODE_BUFFER, System.Null_Address); Windows.SetConsoleCursorInfo(Result, Cursor_Info'Unchecked_Access); Windows.SetConsoleScreenBufferSize(Result, +Size); return Result; end; procedure Present(Screen: Handle) is begin Windows.SetConsoleActiveScreenBuffer(Screen); end; procedure Dispose(Screen: Handle) is begin Windows.CloseHandle(Screen); end; -- ... -- 描画、イベント処理は今回は触れない -- ... end Game.Screen;
SetConsoleCursorInfoでカーソルサイズを0にできないとか、つまらんノウハウも含まれています。
それよりも、Ada風の命名規則(Abc_Def)と、Windows APIの命名規則(定数はABC_DEF、関数はAbcDef)が混じって奇妙なコードになっている方に注目。 Unchecked_Accessとかがやたら使われていたり、AdaとWindows APIは相性が悪いのです。 ゲーム本体のコードからWindows APIを隠したがっている理由がおわかりになりましたでしょうか。
顕著なのは、COORDを関数に渡すところの+でしょう。 COORDは、Windows APIでは珍しく、関数に値渡しされる構造体です。 「僕の」windows.adsを見ていただけたらわかりますが、16ビット整数ふたつの組であるCOORD構造体をひとつの32ビット整数にキャストして渡しています。 キャストを、単に目立たないようにfunction "+"(operator +)にしてるだけです。 詰まるところAdaはrecord(構造体)を値渡しできないのですね…いや、文法上はできます。できますが、GNATの実装では、効率化のため参照渡しになっちゃうようです。 僕以外のAda用Windows APIヘッダーでは、これが考慮されておらず、結果意味不明にバグります。
Delphiのヘルプによると、サイズが32以下のrecordは値渡し、それ以上は参照渡しされるようです。
効率化の観点からは賢い実装ですよね。
レジスタに収まるものをわざわざ参照渡しにすると却って効率が落ちます。
というわけでDelphiはこの点GNATより賢いようです。
わかってます。わかってます。この問題に対する真理はそんなつまらんことではなく、世界中で僕以外誰一人としてAdaを用いてWindows上でコンソールAPIを活用するプログラムを一度も作ったことが無い、という驚愕の事実だということぐらい…。
やった!先駆者だ!(違
…これは何処へ報告すればいいのでしょうか? Ada用Win32 Bindingに報告するとして、Last Updateが1996年のところへ報告して直るものなのでしょうか? でもGNATの開発元へ報告するのは筋違いな気がする。 呼び出し規約がそうなっているだけの話で、少なくともバグじゃないし。
引き続きgame-screenを見ていきます。
テキストゲームでは、カーソルを当てにせず、任意の位置にテキストを描く関数が必要です。 (さもなくば、画面右下に描画した時、自動復帰改行でスクロールされてしまいます)
PC-98なら当然テキストRAMに直書きするところですが、IBM互換機で尚且つWindowsなので、APIを使います。
with System; with Windows; package Game.Screen is -- .... -- 説明済み -- .... procedure Clear(Screen: Handle); procedure Write(Screen: Handle; X, Y: Integer; Text: in String; Attribute: Windows.WORD); type Screen_Buffer is array(0..24, 0..79) of aliased Windows.CHAR_INFO; procedure Clear(Buffer: in out Screen_Buffer); procedure Write(Buffer: in out Screen_Buffer; X, Y: Integer; Text: in String; Attribute: Windows.WORD); procedure Write_All(Screen: Handle; Buffer: Screen_Buffer); -- .... -- また次回 -- .... end Game.Screen;
前回説明しましたように、画面バッファを複数用意してダブルバッファリングを行うよりも、メモリ上に次のシーンを全部描いて全てを一度に書き換える方が高速です。 なので、上ふたつは説明しません。(ダブルバッファリング使用時の名残)
package body Game.Screen is -- .... -- 説明済み -- .... procedure Clear(Screen: Handle) is use type Windows.COORD; Size: constant Windows.COORD := (X => 80, Y => 25); Start: constant Windows.COORD := (X => 0, Y => 0); Image: array(0 .. 80 * 25 - 1) of aliased Windows.CHAR_INFO; R: aliased Windows.SMALL_RECT; begin for I in Image'Range loop Image(I).Char.AsciiChar := Windows.AChar'Val(32); Image(I).Attributes := 0; end loop; R.Left := 0; R.Top := 0; R.Right := 79; R.Bottom := 24; Windows.WriteConsoleOutput(Screen, Image(0)'Unchecked_Access, +Size, +Start, R'Unchecked_Access); end; procedure Write(Screen: Handle; X, Y: Integer; Text: in String; Attribute: Windows.WORD) is use type Windows.UINT; use type Windows.COORD; L: Integer := Integer(Windows.UINT(Text'Length) and 16#ff#); Buffer: aliased array(0 .. 16#ff#) of aliased Windows.CHAR_INFO; Size: aliased Windows.COORD; R: aliased Windows.SMALL_RECT; Zero: constant Windows.COORD := (X | Y => 0); begin for I in 0 .. L - 1 loop Buffer(I).Char.AsciiChar := Windows.AChar(Text(Text'First + I)); Buffer(I).Attributes := Attribute; end loop; Size.X := Windows.SHORT(L); Size.Y := 1; R.Left := Windows.SHORT(X); R.Top := Windows.SHORT(Y); R.Right := Windows.SHORT(X + L - 1); R.Bottom := Windows.SHORT(Y); Windows.WriteConsoleOutput(Screen, Buffer(0)'Unchecked_Access, +Size, +Zero, R'Unchecked_Access); end; procedure Clear(Buffer: in out Screen_Buffer) is White_Space: constant Windows.CHAR_INFO := ( Char => (Kind => Windows.Ascii, AsciiChar => ' '), Attributes => 0); begin for Y in Buffer'Range(1) loop for X in Buffer'Range(2) loop Buffer(Y, X) := White_Space; end loop; end loop; end; procedure Write(Buffer: in out Screen_Buffer; X, Y: Integer; Text: in String; Attribute: Windows.WORD) is use type Windows.BOOL; type T is (Single, Lead, Trail); Pos: T; begin if Y in Buffer'Range(1) then Pos := Single; for I in Buffer'Range(2) loop if Pos = Lead then Pos := Trail; elsif +Windows.IsDBCSLeadByte(Buffer(Y, I).Char.AsciiChar) then Pos := Lead; else Pos := Single; end if; if I in X .. X + Text'Length - 1 then Buffer(Y, I) := ( Char => ( Kind => Windows.Ascii, AsciiChar => Windows.AChar(Text(I - X + Text'First))), Attributes => Attribute); if I = X and Pos = Trail then Buffer(Y, I - 1).Char.AsciiChar := ' '; elsif I = X + Text'Length - 1 and Pos = Lead then Buffer(Y, I + 1).Char.AsciiChar := ' '; end if; end if; end loop; end if; end; procedure Write_All(Screen: Handle; Buffer: Screen_Buffer) is use type Windows.COORD; Size: constant Windows.COORD := (X => 80, Y => 25); Start: constant Windows.COORD := (X => 0, Y => 0); R: aliased Windows.SMALL_RECT := (Left => 0, Top => 0, Right => 79, Bottom => 24); begin Windows.WriteConsoleOutput(Screen, Buffer(0, 0)'Unchecked_Access, +Size, +Start, R'Unchecked_Access); end; -- .... -- また次回 -- .... end Game.Screen;
世の中にはW版APIもありますが、テキスト画面では断然SHIFT-JISです。 可変ピッチのフォントが当たり前なTextOut等では、SHIFT-JISはありがたみが全くなく、逆に文字列の先頭から解析しないと2バイト目を巻き込む問題が発生します。 しかし、SHIFT-JISには、バイト数=文字幅という、他のコード体系には無いテキスト画面上での絶大なメリットがあります。
ただ、断っておきますが、このままのWriteですと、全角文字が半分だけ端からはみ出るような描画をすると化けます。
インベーダーモドキではそのような描画は不要なので対処してませんが、汎用的に使うなら対処しないといけない部分ではあるでしょう。
上の方の、画面バッファに直接描く方のWriteは、APIが良きに計らってくれますので何も考えなくていいです。
ついでに、C++で言うところのunionを、その場で値を作って代入で来てしまうAdaの柔軟な構文にも注目してもらって、今回は手抜き気味におしまい。
さあ、いよいよpackage Game.Screenの最後です。
with System; with Windows; package Game.Screen is -- .... -- 説明済み -- .... subtype Event is Windows.INPUT_RECORD; function Has_Events(Input: in HANDLE) return Boolean; function Get_Event(Input: in HANDLE) return Event; function Is_Lost_Focus(Message: in Event) return Boolean; function Is_Escape_Press(Message: in Event) return Boolean; end Game.Screen;
…正直、package Game.Inputに入れた方が良さげな内容ではあります。 でも、使っているのがコンソールAPIなので…(設計が実装に依存してしまうだめな例)
それぞれ、上から、GUIプログラムのPeekMessageでメッセージが来ているかどうか確認、GetMessageでメッセージを取り出す、WM_ACTIVATE/WA_INACTIVEか判定、WM_KEYDOWN/VK_ESCAPEか判定、です。
(酷い説明ですね)
コンソールプログラムの場合、前述したようにやってくるイベントは全部無視してOKなのですが、フォーカスを失った時にポーズぐらいは窓で動くシューティングゲームの礼儀として実装したいので、一応処理します。 [Esc]キーで再開のつもりです。
package body Game.Screen is -- .... -- 説明済み -- .... function Has_Events(Input: HANDLE) return Boolean is use type Windows.BOOL; use type Windows.DWORD; Count: aliased Windows.DWORD; begin return +Windows.GetNumberOfConsoleInputEvents(Input, Count'Unchecked_Access) and then Count > 0; end; function Get_Event(Input: HANDLE) return Windows.INPUT_RECORD is Dummy: Windows.BOOL; Result: aliased Windows.INPUT_RECORD; Read: aliased Windows.DWORD; begin Dummy := Windows.ReadConsoleInput (Input, Result'Unchecked_Access, 1, Read'Unchecked_Access); return Result; end; function Is_Lost_Focus(Message: in Event) return Boolean is use type Windows.BOOL; use type Windows.WORD; begin return Message.EventType = Windows.FOCUS_EVENT and then not Message.Event.FocusEvent.SetFocus; end; function Is_Escape_Press(Message: in Event) return Boolean is use type Windows.WORD; use type Windows.BOOL; begin return Message.EventType = Windows.KEY_EVENT and then +Message.Event.KeyEvent.KeyDown and then Message.Event.KeyEvent.VirtualKeyCode = Windows.VK_ESCAPE; end; end Game.Screen;
and thenというのは、左の条件式がFalseだったら右を評価せずにFalseを返す演算子です。
後は普通にAPIの薄いラッパーです。 説明するようなことは無いですね。
with Windows; package Game.Input is type Key is (Left, Right, Shoot); type State is array(Key) of Boolean; pragma pack(State); function Get_State return State; end Game.Input;
package body Game.Input is function Get_State return State is use type Windows.WORD; Result: State; begin Result(Left) := (Windows.GetKeyState(Windows.VK_LEFT) and 16#80#) /= 0; Result(Right) := (Windows.GetKeyState(Windows.VK_RIGHT) and 16#80#) /= 0; Result(Shoot) := (Windows.GetKeyState(Windows.AChar'Pos('Z')) and 16#80#) /= 0; return Result; end; end Game.Input;
芸、特に無し。
あえて言うならpragma packで、配列をPascalの集合モドキにできるぐらい。
WM_KEYDOWN(違)だと遅れるのでGetKeyState*4なわけです。
ただ、名前入力のようなシーンでは、イベント処理の方がいいかも。
とりあえずこれで、Windows APIのラッピングは終わりです。
さて、敵や弾の種類が少なければべた書きでいいのですが、種類が多くなってくると、それら全てを統一的に動かしたくなってきます。 そのような時は、当然継承を使えばいいのです。 mkmさんがクラス無しを明言しているのに、僕が継承を使っていいのか?という倫理的問題(←何がだ)もあるのですが、Adaではclassと書かずに構文上はrecordを継承しますから*5という意味不明な言い訳で、継承を使ってしまいましょう。
with Ada.Finalization; with Ada.Unchecked_Deallocation; with Ada.Numerics.Discrete_Random; with Ada.Containers.Doubly_Linked_Lists; -- External with Game.Screen; -- Akanvader package Implementation is type Base is new Ada.Finalization.Limited_Controlled with null record; type Base_Access is access all Base; procedure Dispose is new Ada.Unchecked_Deallocation(Base, Base_Access); type Point is record X, Y: Integer; end record; type Direction is (Left, Right); type Actor is abstract new Base with record Location: Point; Width, Height: Natural; Deleted: Boolean := False; end record; type Actor_Access is access all Actor'Class; package Actor_List is new Ada.Containers.Doubly_Linked_Lists(Actor_Access); procedure Advance(Object: access Actor; List: in out Actor_List.List) is abstract; procedure Draw(Object: in out Actor; Screen: in out Game.Screen.Screen_Buffer) is abstract; procedure Collide(Object: in out Actor; Other: Actor_Access) is abstract; procedure Test_Hit(List: in out Actor_List.List; It: access Actor'Class); -- .... -- 具体的な自機とか敵とかは次回 -- .... end Implementation;
Adaでは、仮想関数も、普通の関数のようにオブジェクトを引数として取る形で書きます。 違和感バリバリなのですが、これも要するに慣れの問題でしかありません。 なお、この構文であれば当然期待されるダブルディスパッチは、残念ながらできません。多態の対象は最初の引数に固定されていて、機能的にはC++の仮想関数と同じです。
Limited_Controlledは、コンストラクタ/デストラクタを自動的に呼んでもらえる特殊な型です。 ここから派生することで、コンストラクタ/デストラクタの自動呼び出しを実現できます。 LimitedじゃないControlled型も存在していて、そちらは代入演算子も定義できます。シューティングゲームで自機や弾をコピー(ポインタでは無く中身まる毎)することはまず無いので、Limitedの方を使います。 なお、Controlled型(の派生)は、一番外側でしか定義できません。関数内で定義できないので、こうやって別途package Implementationなるものを作っているわけです。 (でなければ大した量があるわけでなし、ファイルを分けずに書いてます)
Base、Actorと二回継承しているのも説明が必要ですね。 Unchecked_Deallocationというgeneric(総称、C++で言うtemplate)を実体化することで、delete演算子に相当する関数となるわけですが、genericの引数にabstract型(C++で言う純粋仮想間数を持つクラス)が渡せないのです。 なので、まずabstractじゃないBaseを作り、それに対してUnchecked_DeallocationをDisposeとして実体化、さらにBaseから派生してabstractなActorを作っています。 この制限は正直外して欲しいです。
後は、Actorのクラスワイド型(Actor'Class)へのポインタのリスト、abstractな、1フレーム進めるAdvance、描画するDraw、衝突時に呼ばれるCollide、といったActorのメソッド郡、衝突判定を行うTest_Hitとなっています。
クラスワイド型は、ある型の、多態を行うモードと言いましょうか。 Adaのオブジェクトは、生の型のままでは多態を行わず、クラスワイド型として扱われた時に多態を行います。暗黙的に相互に渡せる他、明示的な型変換もできます。使い分けるのはメンドイです。 で、access all Actor'Classで、Actor(から派生した一連の型)をクラスワイド型として扱うポインタ、となるわけです。
Adaの構文説明にばかりなってしまって申し訳ない。 でも既にAPIの部分は終えてしまいましたから。
あ、そうそう。Deletedの存在理由を説明しておかないといけません。 AとBが衝突ということになったら、それぞれに対してCollideを呼ぶわけですが、最初に呼んだ側(A)に自殺されてしまったら or Otherとして渡したBを解放されてしまったら、Bでアクセス違反が起きてしまいます。 なので、Actorを消す場合は、一旦DeletedをTrueにして、実際の解放は少し後にします。
package body Implementation is procedure Test_Hit(List: in out Actor_List.List; It: access Actor'Class) is use Actor_List; I: Cursor; Other: Actor_Access; begin I := First(List); Enum_Loop: while Has_Element(I) loop Other := Element(I); if not Other.Deleted and Other /= Actor_Access(It) then if Other.Location.X + Other.Width > It.Location.X and Other.Location.X < It.Location.X + It.Width and Other.Location.Y + Other.Height > It.Location.Y and Other.Location.Y < It.Location.Y + It.Height then Collide(It.all, Other); Collide(Other.all, Actor_Access(It)); if It.Deleted then exit Enum_Loop; end if; end if; end if; I := Next(I); end loop Enum_Loop; end Test_Hit; -- .... -- 具体的な自機とか敵とかは次回 -- .... end Implementation;
上で挙げた定義は、ほとんど総称の実体化と純粋仮想関数なので、今回実装はTest_Hitのみです。 *6
コンテナ関連は総称で調達、楽でいいですね。
ソース全掲って大変だなー。
with Ada.Finalization; with Ada.Unchecked_Deallocation; with Ada.Numerics.Discrete_Random; with Ada.Containers.Doubly_Linked_Lists; -- External with Game.Screen; -- Akanvader package Implementation is -- .... -- 済 -- .... type Fighter is new Actor with record Shooting_Interval: Natural; Last_Moved: Direction; end record; procedure Initialize(Object: in out Fighter); procedure Advance(Object: access Fighter; List: in out Actor_List.List); procedure Draw(Object: in out Fighter; Screen: in out Game.Screen.Screen_Buffer); procedure Collide(Object: in out Fighter; Other: Actor_Access); procedure Move(Object: in out Fighter; Way: Direction); function Shooting_Location(Object: in Fighter) return Point; type Fighter_Access is access Fighter; subtype Enemy_Shooting_Probability is Natural range 0..199; package Random is new Ada.Numerics.Discrete_Random(Enemy_Shooting_Probability); Seed: Random.Generator; type Enemy is new Actor with record Way: Direction; Way_Step: Integer; Way_Length: Integer; Interval: Natural; end record; procedure Initialize(Object: in out Enemy); procedure Advance(Object: access Enemy; List: in out Actor_List.List); procedure Draw(Object: in out Enemy; Screen: in out Game.Screen.Screen_Buffer); procedure Collide(Object: in out Enemy; Other: Actor_Access); type Enemy_Access is access Enemy; type Shot is abstract new Actor with record Interval: Natural; end record; procedure Initialize(Object: in out Shot); procedure Draw(Object: in out Shot; Screen: in out Game.Screen.Screen_Buffer); procedure Collide(Object: in out Shot; Other: Actor_Access); type Shot_Access is access Shot'Class; type Fighter_Shot is new Shot with null record; procedure Advance(Object: access Fighter_Shot; List: in out Actor_List.List); type Enemy_Shot is new Shot with null record; procedure Advance(Object: access Enemy_Shot; List: in out Actor_List.List); type Shield is new Actor with record Life: Natural; end record; procedure Initialize(Object: in out Shield); procedure Advance(Object: access Shield; List: in out Actor_List.List); procedure Draw(Object: in out Shield; Screen: in out Game.Screen.Screen_Buffer); procedure Collide(Object: in out Shield; Other: Actor_Access); type Shield_Access is access Shield; end Implementation;
package body Implementation is -- .... -- 済 -- .... -- Fighter procedure Initialize(Object: in out Fighter) is begin Object.Location.X := 39; Object.Location.Y := 22; Object.Width := 2; Object.Height := 1; Object.Shooting_Interval := 0; end; procedure Advance(Object: access Fighter; List: in out Actor_List.List) is begin if Object.Shooting_Interval > 0 then Object.Shooting_Interval := Object.Shooting_Interval - 1; end if; end; procedure Draw(Object: in out Fighter; Screen: in out Game.Screen.Screen_Buffer) is begin Game.Screen.Write(Screen, Object.Location.X, Object.Location.Y, "▲", 9); end; procedure Collide(Object: in out Fighter; Other: Actor_Access) is begin if (Other.all in Enemy'Class) or (Other.all in Enemy_Shot'Class) then Object.Deleted := True; end if; end; procedure Move(Object: in out Fighter; Way: Direction) is begin case Way is when Left => if Object.Location.X > 0 then Object.Location.X := Object.Location.X - 1; end if; when Right => if Object.Location.X < 78 then Object.Location.X := Object.Location.X + 1; end if; end case; Object.Last_Moved := Way; end; function Shooting_Location(Object: in Fighter) return Point is Result: Point; begin Result.X := Object.Location.X; if Object.Last_Moved = Left then Result.X := Result.X + 1; end if; Result.Y := Object.Location.Y; return Result; end; -- Enemy procedure Initialize(Object: in out Enemy) is begin Object.Width := 2; Object.Height := 1; Object.Interval := 0; end; procedure Advance(Object: access Enemy; List: in out Actor_List.List) is begin if Object.Interval > 0 then Object.Interval := Object.Interval - 1; else Object.Interval := 4; if Object.Way_Step = 0 then Object.Way_Step := Object.Way_Length; if Object.Way = Left then Object.Way := Right; else Object.Way := Left; end if; Object.Location.Y := Object.Location.Y + 1; else Object.Way_Step := Object.Way_Step - 1; case Object.Way is when Left => Object.Location.X := Object.Location.X - 1; when Right => Object.Location.X := Object.Location.X + 1; end case; end if; Test_Hit(List, Object); end if; if Random.Random(Seed) = 0 then declare S: Shot_Access := new Enemy_Shot; begin S.Location := Object.Location; if Object.Way = Left then S.Location.X := S.Location.X + 1; end if; Actor_List.Push_Last(List, Actor_Access(S)); end; end if; end; procedure Draw(Object: in out Enemy; Screen: in out Game.Screen.Screen_Buffer) is begin Game.Screen.Write(Screen, Object.Location.X, Object.Location.Y, "■", 10); end; procedure Collide(Object: in out Enemy; Other: Actor_Access) is begin if Other.all in Fighter_Shot'Class then Object.Deleted := True; end if; end; -- Shot procedure Initialize(Object: in out Shot) is begin Object.Width := 1; Object.Height := 1; Object.Interval := 2; end; procedure Draw(Object: in out Shot; Screen: in out Game.Screen.Screen_Buffer) is begin Game.Screen.Write(Screen, Object.Location.X, Object.Location.Y, "|", 15); end; procedure Collide(Object: in out Shot; Other: Actor_Access) is begin if Other.all not in Shot'Class then Object.Deleted := True; end if; end; -- Fighter_Shot procedure Advance(Object: access Fighter_Shot; List: in out Actor_List.List) is begin if Object.Interval > 0 then Object.Interval := Object.Interval - 1; else Object.Interval := 2; Object.Location.Y := Object.Location.Y - 1; if Object.Location.Y < 0 then Object.Deleted := True; end if; Test_Hit(List, Object); end if; end; -- Enemy_Shot procedure Advance(Object: access Enemy_Shot; List: in out Actor_List.List) is begin if Object.Interval > 0 then Object.Interval := Object.Interval - 1; else Object.Interval := 2; Object.Location.Y := Object.Location.Y + 1; if Object.Location.Y > 24 then Object.Deleted := True; end if; Test_Hit(List, Object); end if; end; -- Shield procedure Initialize(Object: in out Shield) is begin Object.Width := 2; Object.Height := 1; Object.Life := 2; end; procedure Advance(Object: access Shield; List: in out Actor_List.List) is begin null; end; procedure Draw(Object: in out Shield; Screen: in out Game.Screen.Screen_Buffer) is S: array(1..2) of String(1..2) := ("◆", " "); begin Game.Screen.Write(Screen, Object.Location.X, Object.Location.Y, S(Object.Life), 16#60#); end; procedure Collide(Object: in out Shield; Other: Actor_Access) is begin Object.Life := Object.Life - 1; if Object.Life = 0 then Object.Deleted := True; end if; end; begin Random.Reset(Seed); end Implementation;
自動連射の時に間隔を空けるテクニックなどは、mkmさんのテキストじゃないシューティングゲームの作り方を参照。こんなものテキストかそうでないかなんて関係無いです。
正直、座標系が荒いだけで、普通のシューティングゲームの作り方と大差無いので、ソースを長々と載せた割には取り立てて説明することは無かったりします。
さあ、いよいよ、今まで作ってきた部品をまとめあげる時です。 …って、説明の都合上ソースファイル別に説明してきましたが、実際にはこのメインルーチンを先に作っているのですけどね。
with Ada.Text_IO; with Game.Time; with Game.Screen; with Game.Input; with Implementation; procedure Akanvader is pragma Linker_Options("-s"); use Implementation; Actors: Actor_List.List; Fighter: Fighter_Access; Game_Screen: Game.Screen.Handle; Back_Buffer: Game.Screen.Screen_Buffer; Paused: Boolean; begin Ada.Text_IO.Put("Akanvader..."); Game.Screen.Set_Title("Akanvader"); Game.Screen.Set_Window_Size_80x25; Game_Screen := Game.Screen.New_Screen; Paused := False; Fighter := new Implementation.Fighter; Actor_List.Append(Actors, Actor_Access(Fighter)); for Y in 0..5 loop for X in 0..7 loop declare Enemy: Enemy_Access := new Implementation.Enemy; begin Enemy.Location.X := X * 4; Enemy.Location.Y := Y * 2; Enemy.Way := Right; Enemy.Way_Length := 80 - 8 * 4 + 2; Enemy.Way_Step := Enemy.Way_Length; Actor_List.Append(Actors, Actor_Access(Enemy)); end; end loop; end loop; for X in 0..3 loop declare BX: constant := (20 - 6) / 2; begin declare S: Shield_Access := new Shield; begin S.Location := (X => X * 20 + BX, Y => 19); Actor_List.Append(Actors, Actor_Access(S)); end; declare S: Shield_Access := new Shield; begin S.Location := (X => X * 20 + BX, Y => 20); Actor_List.Append(Actors, Actor_Access(S)); end; declare S: Shield_Access := new Shield; begin S.Location := (X => X * 20 + BX + 2, Y => 19); Actor_List.Append(Actors, Actor_Access(S)); end; declare S: Shield_Access := new Shield; begin S.Location := (X => X * 20 + BX + 4, Y => 19); Actor_List.Append(Actors, Actor_Access(S)); end; declare S: Shield_Access := new Shield; begin S.Location := (X => X * 20 + BX + 4, Y => 20); Actor_List.Append(Actors, Actor_Access(S)); end; end; end loop; delay 0.5; Ada.Text_IO.Put("Setup!!"); Ada.Text_IO.New_Line; delay 0.5; Game.Screen.Present(Game_Screen); Game.Time.Reset; Game_Loop: loop if Game.Screen.Has_Events(Game.Screen.Input) then declare Message : Game.Screen.Event; begin Message := Game.Screen.Get_Event(Game.Screen.Input); if Game.Screen.Is_Lost_Focus(Message) then Paused := True; elsif Game.Screen.Is_Escape_Press(Message) then Paused := not Paused; Game.Time.Reset; end if; end; elsif not Paused then declare State: Game.Input.State := Game.Input.Get_State; begin if State(Game.Input.Left) then Move(Fighter.all, Left); Test_Hit(Actors, Actor_Access(Fighter)); end if; if State(Game.Input.Right) then Move(Fighter.all, Right); Test_Hit(Actors, Actor_Access(Fighter)); end if; if State(Game.Input.Shoot) then if Fighter.Shooting_Interval = 0 then declare Shot: Shot_Access := new Fighter_Shot; begin Shot.Location := Shooting_Location(Fighter.all); Actor_List.Append(Actors, Actor_Access(Shot)); Fighter.Shooting_Interval := 30; end; end if; else Fighter.Shooting_Interval := 0; end if; end; declare use Actor_List; I: Cursor := First(Actors); begin while Has_Element(I) loop Advance(Element(I), Actors); I := Next(I); end loop; end; Game.Screen.Clear(Back_Buffer); declare use Actor_List; I, Next: Cursor := First(Actors); Actor: Actor_Access; Base: Base_Access; begin while Has_Element(I) loop Next := Actor_List.Next(I); Actor := Element(I); if Actor.Deleted then if Actor.all in Implementation.Fighter'Class then exit Game_Loop; else Delete(Actors, I); Base := Base_Access(Actor); Actor := null; Dispose(Base); end if; else Draw(Actor.all, Back_Buffer); end if; I := Next; end loop; end; Game.Time.Wait; Game.Screen.Write_All(Game_Screen, Back_Buffer); end if; end loop Game_Loop; Game.Screen.Present(Game.Screen.Output); Game.Screen.Dispose(Game_Screen); Ada.Text_IO.Put("Game Over!!"); Ada.Text_IO.New_Line; end Akanvader;
説明は個々のところでしてきましたので、ソースファイルを載せておしまい。
お付き合いいただきありがとうございました。 説明不足のところも多いでしょうから(後半は特に)、質問は遠慮無くどうぞ。 その都度本文に反映させていただきます。
2003-03-23 | 色々準備 |
2003-03-29 | 1 |
2003-03-30 | 2 |
2003-04-01 | 3 |
2003-04-02 | 4 |
2003-04-05 | 5 |
2003-04-05 | 6 |
2003-04-05 | 7 |
2003-04-05 | 8 |
2003-05-25 | Akanvaderと統合 |
2003-05-30 | 一枚のhtmlに統合 |
2004-10-13 | コンテナライブラリをAda05のものに変更 |
*1 当然入力はクリップボード履歴ツールに頼りっきり。CLCL使ってます。
*2 信じないように。また、正しい情報をご存じの方は教えていただければ幸いです。
*3 もちろん9x系なんてものは失われたOSですので、フルスクリーンでも動くようにするため、理由はそれだけでいいです。
*4 GetKeyStateの戻り値はSHORTなので、最上位ビットは16#8000#なのですが、16#80#で動いちゃうのですよね…激しく未定義っぽい。しかし僕は16#80#に慣れてしまったのです。ああ、慣れとは悲劇です。 そんなわけで皆さんは16#8000#をお使いください。
*5 classという予約語は無くても'Classという属性は存在して、使わざるを得ないので、言い訳にすらなっていないのですが。
*6 この衝突判定をmkmさんに訊いてしまったなんてことは恥ずかしい過去です。 脳味噌がどういう状態だったのだろう。