5. 画面デザインをしよう
まずは画面デザインだ!・・・・・・と意気込んで見るモノの、これがゲームエンジンを除きいっちゃんメンド臭い(笑)。そもそも画像配置とか特に楽しい作業ではないのだ(笑)。
実際この辺はこの辺で単調作業のクセにやたら時間がかかる。
Python原作のソースコードを見ながら位置を調整したり、あるいは同時にPython版とRacket版を走らせてみて二つの画面を比較しながら調整していったり・・・・・・とにかく作業的には地味なのだ(苦笑)。クソ。
よってここで扱う画像の位置情報がベストかどうかは知らん。気に喰わなかったらパラメータ類は適当に弄って欲しい。
それしか言いようがないのだ。
さて、big-bangにおける画像描画の仕組みはここに書いてある。
よっていきなり始めよう。
(require 2htdp/universe 2htdp/image)
最初に、ウィンドウサイズの決定と最低辺のレイヤーを作成しようと思う。
;;; 画像サイズ、位置情報
(define *width* 900)
(define *height* 460)
(define *window* (empty-scene *width* *height* "Medium Gray"))
今回のアドベンチャーゲームの画面サイズは900x460とする。これはPython原作で指定されたサイズであり、また、背景画像のサイズでもある。
そしてempty-sceneを使って作成したウィンドウのベースカラーはMedium Grayとする。その理由は、Python版のゲーム立ち上げ時のカラーベースが灰色だったから、だ。
ゲーム立ち上げ画面
次は背景画像を表示するレイヤを作る為こういう関数を書く。
;;; バックグラウンドの設計
(define (place-back back scene)
(if back
(place-image (bitmap/file back)
(/ *width* 2)
(/ *height* 2)
scene)
scene))
place-image関数の使い方は覚えてるだろうか。書式は
(place-image 画像 x座標 y座標 下に来るレイヤー)
である。そして画像の位置指定は「画像の真ん中がどこに来るか」である。
さて、ここで使われてる新顔の関数はbitmap/file関数だ。
前に作った迷路ゲームではいきなりRacketのエディタに画像を直接読み込む、と言う離れ業を行ったが、今回は、割に、そう、フツーに画像をフォルダから引っ張ってきている。と言うのもシナリオで画像の相対パスを指定してるから、である。
だとすると、bitmap/file関数が何をするのか、は想像が付くだろう。単純にパスを指定して画像を読み込む関数である。書式は以下の通りだ。
(bitmap/file 画像がある相対パス)
実際例えば次のようにしてplace-back関数を実行するとRacketのリスナーに画像が表示されるだろう。
(place-back "img8/chap8-back1.png" *window*)
次はキャラクタ表示用の関数を作る。
まずは基本となる一人表示用の関数から。
(define (place-character char-info scene)
(place-image
(bitmap/file (car char-info))
(case (cadr char-info)
((L) 200)
((R) 700)
(else 450))
160scene))
シナリオによるとキャラクタの表示情報、char-info(前回のworld-go関数でのputCharにおけるtail)は例えば次のような形式である。
("img8/chap8-chara1.png" C)
従って、動作テストは次のような形式で行う。
(place-character '("img8/chap8-chara1.png" C) *window*)
引数のリストにあるCをLとかRとかに変えればキャラクタが表示される位置が変わる。
次はこのplace-character関数を使って複数のキャラクタを表示するplace-characters関数を再帰的に定義する。
(define (place-characters characters scene)
(if (null? characters)
scene
(place-characters (cdr characters)
(place-character
(car characters)
scene))))
この関数の第一引数、charactersはworld構造体のcharacterスロットを指す。
従って、ここにハマるデータは例えば次のような形式になっている。
'(("img8/chap8-chara1.png" C) ("img8/chap8-chara2.png" R))
これを用いて動作テストをしてみよう。
指定したキャラクタ画像が指定した位置に表示されてるのが分かるだろう。
なお、world構造体のcharacterスロットが空リストの場合は、下部レイヤーをそのまま表示する事となる。
次はメッセージ関連の表示関数を実装する。
まずはメッセージを表示する「領域」を表示する関数だ。
(define (message-area scene)
(place-image
(rectangle 840 100 "solid" "white")
(/ *width* 2)
(* (/ *height* 4) 3)
scene))
ここで使われてるRacketの新顔の関数は長方形を描画する関数、rectangleだ。
様式は以下の通り。
(rectangle 幅 高さ アウトラインモードか否か 色)
幅と高さはPython原作と色々と比較しながら決めた筈だ(そもそも、tkinterではメッセージエリアの大きさは原則フォントサイズから導き出されるようになってる模様・※1)。
アウトラインモード、と言うのは外枠だけ書く事で、当然それじゃ困っちゃう。結果、黒いフォントを映えさせる為、メッセージエリアは白で塗りつぶさないとならない。
これは特にWorld構造体を引数に取らないので、テストは簡単だろう。
(message-area *window*)
次は実際にメッセージを描く関数、place-messageを作る。
実はメッセージ表示位置を調整しなければならない為、キャラクタ配置関数よりも実験が手間だった関数である。
(define (place-message message scene)
(place-image/align
(text (string-replace message "~%" "\n") 17 "black")
60
294
"left" "top"scene))
ここでの新顔関数はplace-image/alignである。
この関数は基本的にはplace-imageと同じ機能だが、アラインメント、つまり、画像を左寄せであるとか上寄せであるとか指定出来る関数だ。
これにより、テキスト画像をどの辺から表示しはじめるのか指定しているわけだ(結果左寄せの上寄せを設定してる)。
また、text関数にworld構造体のmessage情報を手渡す際に改行文字をLisp型("~%")からデファクトスタンダード("\n")に置き換えている。
動作試験は例えば次のようなカンジだ。
(place-message "テストメッセージ" *window*)
最後は選択肢表示だ。
今まで使ってきたRacketの2htdp/imageのビルトイン関数の合わせ技のような関数である。
まずは基本から。
(define (place-branch branch color scene)
(let ((num (car branch)))
(place-image (text (second branch) 20 "black")
(/ *width* 2)
(+ 70 (* num 70))
(place-image
(rectangle 300 25 "solid"
(if (= num color) "white" "Medium gray"))
(/ *width* 2)
(+ 70 (* num 70))
scene))))
まず、branchに含まれる数値は、何度か示唆してきたが、選択肢のy座標の基本値に掛ける係数の役目を果たしている。
つまり、例えば基本y座標が70だった場合、1を含む選択肢のy座標は70+1 × 70で140、そして2を含む選択肢のy座標は70 + 2 × 70で210となり、二つの選択肢があった場合、絶対に表示位置は重複しない。
もう一つのポイントはここで初めてworld構造体のcolorスロットが出てくる。
偶然にも選択肢の1、とか2、と言う数値は選択肢の「番号」にもなっている。
だとすると、選択肢が「選択されています」と言う状態になった時、どうすればいいのか。
単純に良くあるテとしては「選択肢が選択されてる状態」ではその選択肢の色が変われば良い、と言う事になる。
つまり、colorが選択肢番号に一致した場合にはその選択肢のカラーが白に、そうじゃない時には灰色となれば良い。その調整の為にcolorスロットが存在してるのだ。
百聞は一見に如かず、だ。
ちょっとテストしてみよう。
例えばbranchのtailは次のようになっている。
'(1 "遊びに行く" asobu "y")
これを利用するとテストコードは次のようになる。
(place-branch '(1 "遊びに行く" asobu "y") 1 *window*)
これはbranchに含まれる整数データとcolorの数値が一致した場合である。
一方、違った場合はどうなるのか。
テストコードは以下のようなものだ。
(place-branch '(1 "遊びに行く" asobu "y") 2 *window*)
今度は選択肢が灰色になり、如何にも「選ばれてません」と言うカンジになる。
実際、ゲームに実装されてる「ボタン」ではこのようなカラーリングのトリックが使われてる事が多い、と言うのは周知の事実だろう。
ではこの関数を利用して複数の選択肢を表示する関数を書こう。
例によって末尾再帰関数になる。
(define (place-branches branches color scene)
(if (null? branches)
scene
(place-branches (cdr branches) color
(place-branch
(car branches) color scene))))
world構造体のbranchesスロットが空リストの場合は下部レイヤーであるsceneをそのまま映すが、そうじゃない場合は再帰的にplace-branches関数を呼び出し、place-branch関数が作成する選択肢を下部レイヤーとする、と言うのがその意図である。
では動作テストだ。
world構造体のbranchesスロットは例えば次のようなカンジである。
'((2 "帰る" kaeru "n") (1 "遊びに行く" asobu "y"))
従って、動作テストその1は次のようなカンジだ。
(place-branches '((2 "帰る" kaeru "n") (1 "遊びに行く" asobu "y")) 1 *window*)
color引数に2を与えた場合はどうなるだろう。
(place-branches '((2 "帰る" kaeru "n") (1 "遊びに行く" asobu "y")) 2 *window*)
そしてbranchesがnullだった場合は?
(place-branches '() 1 *window*)
以上である。
これで各レイヤーの設計は終わった。
あとはこれらをまとめるだけ、である。
基本ウィンドウ -> 背景画像 -> キャラクター -> メッセージエリア -> メッセージ -> 選択肢の順で重ねていく。
;;; ウィンドゥ表示
(define (place-world w)
(place-branches
(world-branches w)
(world-color w)
(place-message
(world-message w)
(message-area
(place-characters
(world-characters w)
(place-back
(world-back w)
*window*))))))
例えばworld構造体を次のように設定してテストしてみよう。
(define w (world "img8/chap8-back1.png" '((1 "遊びに行く" asobu "y") (2 "帰る" kaeru "n")) '(("img8/chap8-chara1.png" C) ("img8/chap8-chara3.png" L) ("img8/chap8-chara2.png" R)) 1 "【リリー】~%おう。またな。~%(それで、私は行くことになってるのか......?)" '()))
(place-world w)
これでレイヤーを通した画面設計は完成である。
6. 入力機構を作ろう
Python原作はまたもやマウスドリヴンなゲームであるが、例によって僕はそういうスタイルが大っ嫌いなので、キーボード入力式のゲームとして改造している。
大体、ノベルゲーム、とか言いながらこの世に存在するそのテのゲームの殆どはエロゲだ。
まぁ、今後、Racketでエロゲを作る人が現れるかどうかは知んない。知らんが、ほぼノベルゲーム≒エロゲと言う状況だと、ユーザーインターフェースに関して熟慮しておかなければならないだろう。
言ってる事分かる?
そう、エロゲしてる間って、皆、右利きの人は右手でポコチン握ってるだろ(笑)?
右手でジョイスティック握ってるのに、利き手じゃない方でマウスを扱うとか、やりづらくてしゃーないだろ、って話なのだ(笑)。
この辺、事情は左利きの人も同じだろう。
つまり、自分で自分のジョイスティックを握ってる状態なのに利き手じゃない方の手でマウスを扱う、ってのは無理ゲーだろ、って思うわけだ。しかもマウスは利き手じゃない方で使い出すと、途端にコントロール不能な凶悪な入力デバイスへと変化するのだ。
とてもじゃないけどジョイスティック握りながらではエンジョイ出来なくなるだろう(※2)。
と言うわけでキー入力でゲームを進める前提とする。
入力受付をするキーは次の8種類のキーのみ、としよう。
- Return/Enter
- Space
- 1
- 2
- y
- n
- ↑カーソル
- ↓カーソル
これらを使って、Racketドキュメンテーションでの命名に従ってキー入力を受け取るchange関数を作る。
とは言っても今回は簡単過ぎてバカみたいな関数になっている。
;; 入力制御
(define (change w a-key)
(cond ((key=? a-key "\r") (world-go w "\r"))
((key=? a-key " ") (world-go w " "))
((key=? a-key "1") (world-go w "1"))
((key=? a-key "2") (world-go w "2"))
((key=? a-key "y") (world-go w "y"))
((key=? a-key "n") (world-go w "n"))
((key=? a-key "up") (world-go w "up"))
((key=? a-key "down") (world-go w "down"))
(else w)))
単に受け取ったキー入力の「名称」を特定の文字列と等価判定し、そのままその名称をworld-go関数に流すだけ、である。なんつーことも無い。
なお、気づいた人は気づいただろうが、グラフィカルアドベンチャーゲーム版のworld-go関数の引数順序がCLI版のそれと逆になっている。
理由はCLI版は「インタプリタの典型的な実装法」に合わせていて、こっちのヴァージョンはRacketのドキュメンテーション例示に合わせてるから、である。
どっちにせよCLI版のADVエンジンをグラフィカルアドベンチャーゲーム向けに若干改造しなきゃなんない。
引数順序の交換、なんぞ大した手間でもないのである。
7. CLI版ADVエンジンをグラフィカルアドベンチャーゲーム向けに改造しよう
改造、たぁ言ってもCLI版でだいぶ苦労しながらエンジンを作り上げた。
よって、ここでの改造はホント大した手間ではない。
まずは改造の方針とチェックポイントをいくつか挙げておこう。
- CLI版とグラフィカルアドベンチャー版のworld-goは受け取る引数の順序が違う。
- シナリオを進めたり「決定」したりするにはReturn/Enterキーの他にスペースキーも使えるようにする。これはそれこそエロゲに良くある仕様である。
- CLI版は何だかんだ言って画像を扱わなかったので実装しなかったが、登場キャラクタの更新タイミングをいつにするのか、と言う問題があった。つまり、Python原作上、登場する最大人数は3人なのだが、この3人が2人に減る瞬間がある。これを実装上いつにするのか、と言う問題は先送りしてた。ここでは背景画像が更新されるタイミングで「既存のキャラを全て消す」と言う実装にしてみる(※3)。
- 選択肢を「選ぶ」行為はカーソルキーの上下を用いる。この行為でworld-go構造体のcolorスロットの値が変わる。ただし、選択の「決定」自体は依然Return/Enterキー、及びスペースキーだ。
- CLI版は入力機構から返ってくる値は「文字」を想定してたが、今回、change関数が返してくる値は「文字列」である。よって、選択肢から値を検索してくる連想リストのキーはデータ型が変わっているのでその調整が必要である。
と言うあたりで、グラフィカルアドベンチャー版world-goはこんなカンジになる。
;; シナリオ制御
(define (world-go w delta)
(let loop ((back (world-back w))
(branches (world-branches w))
(characters (world-characters w))
(color (world-color w))
(message (world-message w))
(scenario (world-scenario w)))
(let ((s-expression (car scenario)))
(let ((head (car s-expression)) (tail (cdr s-expression)))
; ;; デバッグ用
; (display
; (format "branches: ~a\ncolor: ~a
;instruction: ~a\ntail: ~a\ninput: ~a\n"
; branches color head tail delta))
(case head
((back) (loop (car tail) branches '() ;; キャラクタ画像更新
color message (cdr scenario)))
((branch) (loop back (cons tail branches) characters
color message (cdr scenario)))
((break) (cond ((string=? delta "up") ;; 選択肢(上)を選ぶ
(world back branches characters
1 message scenario))
((string=? delta "down") ;; 選択肢(下)を選ぶ
(world back branches characters
2 message scenario))
((or (string=? delta "1")
(string=? delta "2"))
(world-go
(world back '() characters
color message
(find-label
(third (assv;; 文字列->数値変換になる
(string->number delta)
branches))
scenario)) "\r"))
((or (string=? delta "y")
(string=? delta "n"))
(world-go
(world back '() characters
color message
(find-label
(second (assoc delta
(map reverse branches)))
scenario)) "\r"))
;; 選択肢が「選択」された時の「決定」((or (string=? "\r")
(string=? " "))
(world-go
(world back '() characters
color message
(find-label
(third;; world構造体のカラースロットが;; 連想リストのキーとなる
(assv color branches))
scenario))
"\r"))
(else
(world back branches characters
color message scenario))))
((end) (world back branches characters
color *end-message* scenario))
((jump) (world-go
(world back branches characters
color message (find-label
(car tail) scenario))
"\r"))
((label) (world back branches characters color message
(cons '(break) scenario)))
((msg) (world back branches characters
color (car tail);; 今回は入力情報を明確化する
(if (or (string=? delta " ")
(string=? delta "\r"))
(cdr scenario)
scenario)))
((putChar) (loop back branches
(if (member tail characters)
characters
(cons tail characters))
color message (cdr scenario)))
(else (error "Can't do " head)))))))
改造ポイントは既に書いたが、敢えてより重要な改造ポイントを繰り返すと、
- Return/Enterキー及びスペースキーの役割をより明確化した
- 選択肢の「選択」と言う行為を実装し、world構造体のcolorスロットの役割を明確化した
と言う事である。
特に2番。これによりカーソルキーの上下を叩くと選択肢の色が動くように見えるわけだ。
あと、CLI版では選択肢はy、nキー及び1、2キーで選択していた。
もちろんこの版でもその仕様は継承してるが、カーソルキーで選択、Return/Enterキーを叩いて「決定」と言う良くあるシステムを実装してる。
ポイントは、カーソルキーによる「選択」はworld構造体のcolorスロットの値を変える、と言うことだ。上を叩けば1になり、下を叩けば2となる。
これは先にも見た通り、place-branch関数で描画してる「選択肢」の色を変える。そしてbranchesスロットと言う連想リストから結果を引っ張ってくるキーの役目も果たしてるわけだ。
従って選択肢表示画面でEnter/Returnキーを叩けばcolorをキーとしてassv及びfind-labelが実行される。
いずれにせよ、CLI版をかなりしっかり作ったので、改造は最小限の労力で済んでる筈である。
8. イベントループを実装しよう
残りはイベントループ実装なんだが、CLI版だとREPLを実際に組んだが、グラフィカルアドベンチャーゲーム版はそれは必要ない。単にbig-bangを流用すりゃあエエから、だ。
ここまで来ればあとは消化試合である。
;; イベントループ
(big-bang (world #f '() '() 1 *init-message* *scenario*); world構造体初期状態
(on-key change)
(to-draw place-world)
(stop-when game-ends? place-world)
(name "よろしくアドベンチャー")
;; デバッグ用
; (state world)
)
っつーかbig-bangそのものには複雑な使用法はない。やるこたぁ毎度お馴染みのワンパターンなのである(むしろ外部との連携をどう書くか、と言うのが難しいのである)。
あとは*init-message*やら*end-message*やら、ゲームの終了条件をどう書くか、って話なんだが、これらは殆どCLI版からのソースを流用すればいいだろう。と言うか流用出来るようにしてきた。
敢えて言うと*init-message*をちょっと手直しする程度である。
;;; メッセージの初期状態
(define *init-message* "Enter/Spaceでスタート")
なんせスペースキーもシナリオを進めたり決定するのに使えるようになったから、である。
以上である。
グラフィカルアドベンチャー版の全ソースコードはここに置いておく。
なお、最初予告してた通り、ここではアドベンチャーゲームエンジン及びグラフィカルアドベンチャー作成を通じて、画像切り替え等のテクニックを解説してきた。・・・・・・気づかんかった(笑)?実はしてたんだよ(笑)。
単純にはworld構造体に画像用のスロットを作り、それを管理すれば良い、って事だ。今回はbackスロット、branchesスロット、charactersスロットの3つは基本的に、全部画像用スロットである。
以前扱った迷路ゲームなんかは単一背景画像しか扱ってなかったけど、今回みたいにworld構造体に画像管理用のスロットを追加しておけば、背景画像を切り替えたり、キャラ画像を追加したり、と表現に幅が出てくる。明らかに出来る事が増えたのだ。
というわけで、「Racketでグラフィカルアドベンチャーゲームを作ってみる」の回、終了である。
お疲れ様。
※1: そもそもPythonのtkinter及びtcl/tkはゲームを書く為のツールではなく、あくまでGUIソフトウェアを作る為のツールである。
従って、ボタンやテキスト表示エリアなんかのウィジェットは当然フォントがハマるように設計されてて、そのサイズもフォントの大きさとx文字分、と言う指定に従って決定される。
反面、Racketの2htdp/imageはウィジェット配置の為のツールではなく、根っからの「描画ツール」なのだ。
従って、フォントに対して・・・と言う指定に関して言えば本来はhtdp/imageの範疇外であり、苦手な分野なのだ。
※2: こんな事書いてるけど、僕自身はエロゲ遍歴、って言う程実はエロゲをプレイした事がないのだ。
一つの理由として、最初に買ったパソコンがApple Macintoshだったせいで、全くエロゲ、ってものを楽しめるような環境ではなかったのだ。
10数年前、初めてエロゲなるものをプレイしたが、
「これって全然ゲーム性ないやん・・・・・・・」
と呆れ返ったのが初体験だった。そもそも「選択肢」でさえ滅多に出てこない。うっそーん、とか思ったのが現代エロゲとの邂逅だったのだ。
後にネットで、「エロゲやってみたけどゲーム性が全くねぇ」とか怒りまくってたら、とある時にある人から、
「エロゲユーザーってさ。エロゲにゲーム性とか求めてないんだよ。」
とか諭されたわけだ。目から鱗が落ちたね。実はエロゲはゲームじゃなかったのだ。
その後、PC-9801系のレトロエロゲをプレイするようになったが、昔のエロゲの方がゲーム性があって面白く、現代エロゲよりそっちの方にハマってしまった、と言う事なのだ。
マジでレトロエロゲの方が面白い。
ただし、残念ながら、ゲームとして面白いのでポコチン握ってる暇が無いのである(爆
※3: 実はこの辺の実装がPython原作のバグではないか、と思っている。
一回Python原作をプレイしてみてほしいのだが、登場人物のうち、「ハル」と名乗る人物は本来なら別れを告げたあと「消えなければならない」筈なのだ。
ところが彼女は背景画像が切り替わるまでずーっとそこに居座ってる。
で、原作のシナリオを調べてみると## commonと名付けられたラベル付近は次のようになっている(43〜50行目)。
【マコ】\nハルさんまたね〜。テスト勉強がんばってくださいね!
#jump common
## common
#putChar img8/chap8-chara1.png C
#putChar img8/chap8-chara2.png R
【ノア】\nさて、どこに行こっか!
【マコ】\n右に行くと神社があって、左に行くと駅がありますよ!
【リリー】\n(どっちでもいいな)ノアとマコが好きな方でいいよ。
ここ(46行〜47行目)で唐突にキャラクタ画像のロードが指定されている。既にキャラクタは映ってるのに。
恐らく、元々「キャラクタ画像の再ロード」に、今映ってるキャラクタを一旦全部消去するコマンドの役目も兼任させるつもりだったのではないか。
そうすればこのタイミングで「ハル」と言うキャラクタは消える筈だったのだ。
しかし、それは実現出来なかった。要するにバグである。
どっちにせよ、不可思議なシナリオ上の指示であり、少なくともPythonでの実装ではシナリオの指示は何も反映されていない。
このRacket版では敢えてPython版の挙動に完全に合わせてある。
ただし、このバグっぽい挙動が気になる層は、world-goのcharacterスロットの更新時に、memberで既存画像が既にあるかどうか調べてるが、存在を確認した時に、既存のbranchをそのまま使うのではなく、新たに空リストにconsするようにすれば問題は解決する筈である。