見出し画像

Retro-gaming and so on

12歳からはじめる ゼロからの Racketゲームプログラミング教室(大嘘) その2

さて、勇者を動かす前に以下の事をちょっと説明しよう。
まずはRacket構造体、から。

構造体と聞くとちょっと厳しいが、これは基本的にはC言語の構造体やPascalのレコード型と同じである。要するに「要素に名前をつけられる配列」、Racketで言うと「要素に名前をつけられるベクタ」の事である。
以前、C言語による二分木の話を書いたが、そこではRacketではわざわざ構造体を使わなかった。
しかし、クソ真面目にC言語からの移植を考えるなら、二分木のノードはRacketの構造体を使って次のように定義出来る。

(struct node (val left right))

そして、実際に名前と値を与えてノードを形成出来るのだ。

(define *n2* (node '+ (node 1 #f #f) (node 2 #f #f)))
(define *n1* (node '* (node 3 #f #f) *n2*))

実行してみるとこうなる。

> *n1*
#<node>
> (node-val *n1*)
'*
> (node-left *n1*)
#<node>
> (node-right *n1*)
#<node>
> (node-val (node-left (node-right *n1*)))
1
> (node-val (node-right (node-right *n1*)))
2
>

ノードを手繰っていけば値が現れるがノードとノードが繰り込みで定義されてるので、まさしく二分木が形成されている。
いずれにせよ、構造体はこのように「名前をつけて」使用して、そしてPythonのクラスと同じように新しく「ユーザー定義型」を形成している。

> (node? *n1*)
#t
> (node? *n2*)
#t
>

上の例だと、ノード型と言う新しい型が定義されている。

基本的にはLisp使いはオブジェクト指向をそんなに使わない。Lispのオブジェクト指向(特にANSI Common LispのCLOS)は強力すぎる程強力だが、誰もそこまでの強力さは通常欲しがらない、と言って良い。

個人的には、オブジェクト指向抽象化が必要だと思ったことは一度もない。 Common Lispは恐ろしいほど強力なオブジェクトシステムを持っているが、 私は一度もそれを使ったことがないんだ。ハッシュテーブルにクロージャを詰め込む とか、弱い言語だったらオブジェクト指向テクニックが必要だっただろうと 思えることはたくさんやってきたけれど、CLOSを使う必要に迫られたことは無かった。 


しかし、反面、Common Lisperは構造体をよく使うらしい。
また、Racketの構造体structは単一継承機能を持ってるので、いわば半オブジェクト指向でプログラムを書くことが可能で、やっぱRacketのオブジェクト指向機能を使うより構造体のお世話になる事の方が多いのではないか。

まぁ、構造体を「使う」と言う事に関してはおいおい使いながら慣れてもらう事として・・・っつーか使ってみないと慣れないし、構造体以上にLispではリストのお世話になりっぱなしなので、組み込みデータ型と言おうとLispでリストの「外」に出るのは憂鬱、って言えば憂鬱なのだ(ベクタを使う事でさえ最初は億劫である!)。
取り敢えず2htdp/universeと言うライブラリでは、ゲームに現れる「要素」、つまり、登場キャラクタの座標であるとか、あるいはゲーム中のフラグとかを全部纏めて構造体worldとして扱う、と言う話を書いた。
今回の場合、コード的には前回書いた通り、

(struct world (key brave flag))

としてこのゲームの「世界」の構成要素を全部worldという構造体として定義している・・・・・・。
とか言ってもちと不思議じゃないだろうか。
確かにここではworldと言う構造体を定義はしている。でも使ってないんじゃないか、と。
勘のイイ人は気づくだろう。構造体を定義しても「名前を付けて」データとして保存せんとその構造体は使えないのでは、と。
その通り、である。正解である。
実際は確かに使ってるんだが、ちと分かりづらい。

ここで定義されてる構造体worldが実際使われてるのは前回の最後に使ったbig-bang内だ。
コードをもう一回再録しよう。

;; main プログラム(第一版)
(big-bang (world (d-pair->posn '(8 . 1)) ;;キーの位置
        (d-pair->posn '(1 . 0)) ;; 勇者の初期位置
        #f)        ;; キーのフラグ
 (to-draw place-world)   ;; world の描画
 (name "Dungeon & Racket") ;; ウィンドウのタイトル指定
)

ここでbig-bangの第一引数でworld構造体を生成してる。
生成はしてるが名前がない・・・と言う事は事実上、何だか分からんがこの書き方をした時にworld構造体の名前は局所変数名として暗黙に定義されている、と言う事だ。
暗黙に定義する、ってのは穏やかではないのだが、こういう事はLispのマクロではお馴染みである。
そう、前回はbig-bangを関数として紹介したが、実際はbig-bangはRacket組み込みのマクロである。マクロである以上、暗黙に色々定義する、なんぞはお手の物である。
ここでbig-bangマクロの書式を一回見てみよう。

(big-bang world構造体 更新情報0 更新情報1 ......)

何度も言うが、第一引数はworld構造体、つまりゲームの構成情報を取る。ここに現時点でのゲームの「状態」が保管されているわけで、当然ゲームの初期条件をここに構造体として渡すわけだ。
そして、平たく言うとそのworld構造体の「状態」をどう更新していくか、と言うのが更新情報0...以降の「引数」の役割であり、専用のローカルマクロがいくつか用意されている(例えばto-drawworldに対応する描画指令関数を使って実際に描画するローカルマクロであり、nameはウィンドウにタイトルを付けるローカルマクロである)。
言い換えると、big-bangマクロはSchemeで言う名前付きletの亜種である。あるいは、他言語で言うwhileの仲間だと思って良い。

;; 名前付き let の書式
(let ⟨variable⟩ ⟨bindings⟩ ⟨body⟩) 

big-bangマクロでは名前付きletでの<bindings>に当たる部分にworld構造体が「暗黙の名前を付けて」定義され、あとは「何が起こるか」が<body>に記述されていってる、ようなモノだ。当然<variable>は存在しない。
もう一点、名前付きletと違うトコは

  • 通常のルーピングマクロと違い、前提は「無限ループを起こす為の」マクロである。
もちろん、ゲームなので終わらないと困る。確かに困るが、GUI系のソフトウェアの場合、画像表示プロセス等は基本が無限ループじゃないと逆に頂けないのだ(要するに、単純に言えば「画像を延々と表示させる為」である)。
そして<body>にハメる関数陣の一部の重要な役割は、どうやってそのworld情報を「更新」するか、である。world情報には自キャラ等の「表示位置情報」が含まれてるのはこれまで見た通りだ。
言い換えると「どう新しいworld情報を作って返すか」と言うのが肝であり、また、通常のGUIプログラムのように破壊的変更をどうやら伴わないらしい。常に新しいworld情報を作っては提供し、それをbig-bangマクロが「受け入れる」システムとして設計されている模様。
これは関数型言語プログラマにはありがたく、かつ分かりやすいが、通常の言語ユーザーに取っては「?」となる事請け合いだろう。実際は関数型言語プログラマにとっても「?」なのだが、それは機能の問題じゃなくって書かれてるドキュメントがワヤクチャなのが原因である。

今の問題は「主人公」である勇者をまずは動かす事、である。つまりworld構造体

(struct world (key brave flag))

braveで管理されている勇者の位置情報を更新した、新しいworldを返す関数を書くのが議題である。
取り敢えずbig-bangに定義されているon-keyローカルマクロを最終的には利用する。マニュアルには簡単な「キーによる操作関数(イベントハンドラと呼称してるが)」の例示が載っている。

(define (change w a-key)
  (cond
    [(key=? a-key "left")  (world-go w -DELTA)]
    [(key=? a-key "right") (world-go w +DELTA)]
    [(= (string-length a-key) 1) w] ; order-free checking
    [(key=? a-key "up")    (world-go w -DELTA)]
    [(key=? a-key "down")  (world-go w +DELTA)]
    [else w]))

これは単純に「位置情報の更新」だけを示している。僕らが書かなければならないのは、位置情報の更新だけ、ではなくてそれを含んだworld構造体の更新である。従って、返り値は「位置情報」ではなくてworld構造体でないとならない。
しかし、このサンプルが示唆する重要な事は、打鍵情報を直接受け取り、その「意味」を定義出来る、と言う事である。これはCLIプログラムと違い、Enterを要しない直接的な入力情報を2htdp/universeライブラリを介して制御が出来る、と言う事を意味する。
最初に、このサンプルを改造して勇者を動かせるようにしてみよう。繰り返すが返り値はworld構造体でなければならないが、Racketと言うか、Lispではあまりアタマを使わないでも簡単に改造する事が出来る。

;; キー入力によるイベントハンドラ(第一版)

(define (change w a-key)
 (let ((dir (posn->d-pair (world-brave w))))
  (let ((x (car dir)) (y (cdr dir)))
   (world
    (world-key w)
    (cond
     ((key=? a-key "left") (d-pair->posn (cons (- x 1) y)))
     ((key=? a-key "right") (d-pair->posn (cons (+ x 1) y)))
     ((= (string-length a-key) 1) (world-brave w))
     ((key=? a-key "up") (d-pair->posn (cons x (- y 1))))
     ((key=? a-key "down") (d-pair->posn (cons x (+ y 1))))
     (else (world-brave w)))

    (world-flag w)))))

要するに、単純には、サンプルで書かれたキー入力制御の部分をworld構造体で包めば良いだけだ。そうすれば打鍵されただけで新しくworld構造体が定義され、結果描画情報も更新される。
つまり、引数wとしてworld構造体を受け取ったら、それを利用して新しいworld構造体を返すわけだ。それをbig-bangが受け取って画像情報を更新してくれる、と。
そして内部的には「主人公キャラの位置情報」は、*d-pairs*のカタチ、つまり*map-data*上の「どこにいるか」と言うモノで計算してる。当然一歩づつ移動するわけで、carcdrのどっちかを±1で計算し、improper listにしてからまた画像の位置情報に戻してるわけだ。
多分この方式の方が紛れがないだろう(じゃないと最悪のケースでは「一歩のピクセル量がxxだから・・・」と不明瞭な計算を行わないとならないだろう)。

なお、原作ではマウスでボタンをクリックする仕様になっていたが、それはこの版では削除した。ぶっちゃけ、僕はこの手のゲームでのマウスドリヴンな仕様が大っ嫌いなのだ(笑)。このテのゲームではキー入力による操作の方が快適だと思っている。だから洋ゲーのマウスクリックでのキャラ移動と言うアタマの悪いゲームデザインはハッキリ言えば嫌悪してるクチで、それが故にPython原作から描画範囲だけの画面の大きさを抽出したのである。

さて、このキーによる入力を制御するイベントハンドラ、changeを作り終えたらbig-bangを次のように書き換えてみる。

;; main プログラム(第二版)

(big-bang (world (d-pair->posn '(8 . 1))
       (d-pair->posn '(1 . 0))
       #f)
 (to-draw place-world)
 (on-key change) ; ここを付け足す
 (name "Dungeon & Racket"))

それで実行してみればマップ上を打鍵によって主人公キャラが動き回るゲーム「らしき」ものが出来てる事がわかるだろう。


しかし、今のトコ、壁の中でも動き回れるし、ウィンドウの外に出てエラーを起こすし、落ちてるキーに関しても処理していない。
しかし取り敢えず主人公が打鍵によって動き回れる事にはなったので、問題を一つ一つ解決していこう。

まず、主人公の移動可能部分を定義する。
単純に考えると次の二つの条件を満たさない事が現時点でのネックになっている。

  1. 「勇者」は壁の中は歩けない
  2. 「勇者」はウィンドウの描画範囲以外には出られない。
1番に関して言うと、単純には、移動先を計算した時*map-data*を参照して、その要素が1だった場合、「現時点での位置を保留する」と言う約束事を導入して移動範囲を制限すれば良いだけ、である。
2番に関して言うと、これも*map-data*を参照して、勇者の座標情報がどちらとも負の整数にならない事、また*map-data*のサイズを超えない事、と言う事が条件になる。
ただし、このゲームの場合、もうちょっと条件を緩くしてもよい。と言うのもマップが壁に囲まれてる、ってのが前提だからだ。従って1番が効いてきて、結局危うい状況を招くのは、入り口を逆行してy座標が-1になってしまう事さえ避ければ大丈夫だ、と言う事だ。
取り敢えず、最初にマップの検索関数を定義してみる。

;; 移動用関数のユーティリティ

(define (map-ref x y)
 (list-ref (list-ref *map-data* y) x))

一々list-refの重ね書きをするのもメンドイので、二つの引数で直接*map-data*から要素を引っ張ってくる関数を定義する。
次に移動可能な場所なのかどうか調べる述語、movable?を定義しよう。

(define (movable? x y)
 (not (or (negative? y) (= (map-ref x y) 1))))

これはある意味どう書いても良い関数だ。
と言うのも条件的には

  • y座標が正の整数である事、かつ、*map-data*の要素が1じゃない事
なので、直球勝負的には

(define (movable? x y)
 (and (positive? y) (not (= (map-ref x y) 1))))

でも構わない。
ただ、「綺麗にコードを書きたい」時、そういう場合はド・モルガンの法則を適用すればシンプルなコードになる場合がある。
個人的な観点では、最初に提示したコードの方がシンプルに見えるんだけど、皆様は如何だろうか。

さて、関数movable?を利用して先程定義したキー入力によるイベントハンドラを改造すると次のようになる。

;; キー入力によるイベントハンドラ(第二版)

(define (change w a-key)
 (let ((dir (posn->d-pair (world-brave w))))
  (let ((x (car dir)) (y (cdr dir)))
   (world
    (world-key w)
    (cond
     ((key=? a-key "left") (let ((x (- x 1)))
               (if (movable? x y)
                (d-pair->posn (cons x y))
                (world-brave w))))
     ((key=? a-key "right") (let ((x (+ x 1)))
                (if (movable? x y)
                 (d-pair->posn (cons x y))
                 (world-brave w))))
     ((= (string-length a-key) 1) (world-brave w))
     ((key=? a-key "up") (let ((y (- y 1)))
               (if (movable? x y)
                (d-pair->posn (cons x y))
                (world-brave w))))
     ((key=? a-key "down") (let ((y (+ y 1)))
               (if (movable? x y)
                (d-pair->posn (cons x y))
                (world-brave w))))
     (else (world-brave w)))
    (world-flag w)))))

要するに、一旦、先読み的に座標を計算しておいて、その座標がmovable?の条件を満たした時、画像位置情報へと変換し、そうじゃない場合は現時点での「勇者」の画像位置情報を維持する、と。
改造はそれだけ、である。
これでプログラムを走らせれば、「勇者」はウィンドウの外には出られないし(従ってエラーは起きない)、壁の中を歩く、なんつー事にもならんだろう。


さて、あとは「勇者」の移動に関しては鍵を「取る」事だけだ。
これは「勇者」の画像位置情報が「鍵」の位置と重なった時、world構造体のフラグを変更する事で実装出来る。
つまり、単純には、(x, y)が勇者の*map-data*上における位置情報だとして、

(if (= (map-ref x y) 3) #t (world-flag w))

とすれば済む話である。
繰り返すと

  • 勇者の位置情報が鍵の位置情報と同じになった場合、フラグを真に変更せよ。そうじゃなければ今のフラグを維持せよ。
と言う事である。
これをどこに仕込むか、と言うと、例によって打鍵によるイベントハンドラ、change関数で返すworld構造体の第三引数に、である。

;; キー入力によるイベントハンドラ(第三版)

(define (change w a-key)
 (let ((dir (posn->d-pair (world-brave w))))
  (let ((x (car dir)) (y (cdr dir)))
   (world
    (world-key w)
    (cond
     ((key=? a-key "left") (let ((x (- x 1)))
               (if (movable? x y)
                (d-pair->posn (cons x y))
                (world-brave w))))
     ((key=? a-key "right") (let ((x (+ x 1)))
                (if (movable? x y)
                 (d-pair->posn (cons x y))
                 (world-brave w))))
     ((= (string-length a-key) 1) (world-brave w))
     ((key=? a-key "up") (let ((y (- y 1)))
               (if (movable? x y)
                (d-pair->posn (cons x y))
                (world-brave w))))
     ((key=? a-key "down") (let ((y (+ y 1)))
                (if (movable? x y)
                 (d-pair->posn (cons x y))
                 (world-brave w))))
     (else (world-brave w)))
    (if (= (map-ref x y) 3) #t (world-flag w)))))) ;; ここを変更する

これだけ、だ。
画像情報自体は全然弄らないで構わない。と言うのも、既にplace-key関数を定義した際にフラグの状態によって鍵の画像を表示したりしなかったりするようにしてるから、だ。
またRacketを実行して、勇者が鍵画像に重なった後は鍵が消失する事に気づくだろう。


そして「勇者」が鍵画像に重なった以降はworld構造体の第三引数はずーっとそのまま#tを維持する。これは、「勇者」が「鍵を入手してる」と言う事を意味する。
ここまで書ければ、あとは「どうやってゲームを終了するか」だけである。
単純にはゴールに到達すればゲームクリア、なのだが、その時、「勇者」は鍵を持ってないとならない。鍵が無ければ「ゴール」とは認められないのだ。
この条件をもってgame-ends?述語を定義する。

;; エンディング表示

(define (game-ends? w)
 (let ((dir (posn->d-pair (world-brave w))))
  (let ((x (car dir)) (y (cdr dir)))
   (and (world-flag w) (= (map-ref x y) 2)))))

もう一回繰り返すが

  • ゲーム終了条件は、world構造体のflag#tになっている事と、勇者の座標情報が*map-data*上で2になっている事
である。

ついでにゲームが終了した時、Python原作に従ってメッセージ表記をしよう。
メッセージと言えば通常は単なる文字列だが、この場合、「画像」として描画範囲にメッセージを表示しないとならない。
こういう場合、基本的には2htdp/imageライブラリのtext関数を用いる。
そうすれば2htdp/imageライブラリが文字列情報を画像としてレンダリングしてくれる。

(define (ending w)
 (place-image (text "ゴールおめでとう。

だが、君の戦いはまだ始まったばかりだ。

           ......つづく?" 15 "white")
     300
     200
     (empty-scene *width* *height* "black")))

2htdp/imagetext関数は次の書式を持っている。

(text 表示したい文字列 文字サイズ 文字の色)

Python原作ではフォント指定もしているがここではしていない。
と言うのも僕がLinuxを使ってる為、Windowsと同じフォントを指定出来ないからだ。
もし、フォントも指定したい場合はtext関数の代わりにtext/font関数を用いれば良い。
また、empty-scene関数は第三引数でベースカラーを指定する事が出来る。
今回は表示する文字が白なので、バックグラウンドとしては黒を指定してる。

一旦、ending関数をリスナーで走らせてみよう。


上手く表示されてるようだ。
ところで、ending関数は引数にworld構造体を取るような設計に見える。
しかしこれはbig-bangマクロの要請によってるだけで、関数定義を見れば分かるが実際は使われていない。と言う事はどんな引数を与えても上の画像のような実行結果になる筈だ。
そして、単に画像情報を返してるだけ、なのだ。今までのworld構造体の情報が何だったのか、と言うのは、取り敢えずここでは関係がなくなっている。

さて、最後にbig-bangマクロにゲームの終了状況を付け足そう。次のように書く。

;; main プログラム
(big-bang (world (d-pair->posn '(8 . 1))
        (d-pair->posn '(1 . 0))
        #f)
 (to-draw place-world)
 (on-key change)
 (stop-when game-ends? ending) ;; ここを付け足す
 (name "Dungeon & Racket"))

big-bangマクロでの終了を決定するローカルマクロはstop-whenである。
stop-whenローカルマクロの書式は以下の通り。

(stop-when 述語 エンディングで表示する画像を生成する関数)

これでゲームの実装は終了だ。鍵無しでゴールに到達してもゲームは終わらず、鍵を取れば鍵画像はなくなり、鍵ありでゴールに到達すればゲーム終了メッセージが表示されるだろう。





以上である。
非常に簡単な例ではあるが、基礎的なRacketでの「ゲームの書き方」は分かったのではないだろうか・・・まぁ、公式ドキュメントがもっと分かりやすく例示を含めて書けよ、って話ではあるんだが(笑)。

ちなみに、本当の事を言うと、画像は1ピクセル毎に移動する事が可能で、と言う事は本来はアニメーションも可能ではある。
が、今回はPython原作に合わせた簡単な仕様にしてる。
あとは各々が2htdp/universeを用いた実験をしてほしい。
ドキュメンテーションがワヤクチャで困ったのは事実であるが、一旦基礎的な使い方さえ分かれば「関数型プログラミング」でゲーム設計が可能なので、かなり使いやすいライブラリだとは言えるだろう。

参考: 今回の(写真以外の)全コード
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事