先の予告通り、Scratch の技術話。
Scratch は初心者向けの言語だけど、この話は初心者向けではない。
だって、Scratch の上でプログラムをする話ではなくて、Scratch というプログラムの話だから。
そういうわけで、Cやアセンブラでゲームなどを作ったことがある人を対象にさせてもらう。
Scratch というプログラムの話、といっても、いろいろ実験してその動作から推察しただけだ。
Scratch 処理系のプログラムを読んだわけではないので、間違えていたら申し訳ない。
▼並列動作と垂直同期待ち
Scratch は、多数のプログラムを書いて、それらが並列に実行される。
多数のキャラクターを動かすゲームでも、キャラクターごとのプログラムを個別に作るだけでよい。
これが、ゲーム作成には非常に役立つ。
キャラクターは動き続けるのだから、永久ループ(もしくは、ゲームオーバーになるまでの長期ループ)の中にプログラムを書くだけでいい。
これで、不思議と思い通りに動いてくれる。
たくさんのプログラムが同時に動くだけでなく、よく気を付けてみると、動くプログラムの数に関わらず一定の速度を保っている。
並列動作と、垂直同期待ちが自然な形で言語仕様に組み込まれているのだ。
しかし、永久ループなのにどうやって並列実行し、表示速度を保っているのだろう?
実は、Scratch はイベントドリブンマルチタスクだ。
MacOS 9 以前とか、Windows 3.1 の頃とかがそうだった。
OSによって稼働時間を与えられたプログラムは、ある程度の時間動いたのち、処理をOSに戻さなくてはならない。
処理を戻されたOSは、次のプログラムを呼び出す。これを繰り返せば多くのプログラムが並列動作できる。
「クリックされた」とか「キーが押された」など、特別な事象は「イベント」という形で呼び出される。
通常の時間割り当てと違い、特別なことが起きたので、それに対応するプログラムを書く必要がある。
逆にいえば、通常の動作の中ではこれらを考慮しなくてよくなり、プログラムがすっきり書ける。
Scratch では、「ループの末尾」で処理をシステムに返している。
次に処理の割り当て時間が回ってきたときには、もちろんその続きから実行される。
#2016.6.20 追記:
ループ末尾でシステムに処理を返すのは、「ループ内に画面を変更する処理がある場合」に限られるようです。
詳細はこの日記の末尾に。
メッセージの送信という「イベント」は、内部的にはキューに入れられる。
この操作自体は即座に行われるが、実際のイベント呼び出しは、システムに処理を返した後で行われる。
クローンの生成も、変数領域などのコピーは即座に行われるが、そのクローンに「クローンされた」というイベントを送るのはシステムに処理を返した後だ。
すべてのプログラムを動かし、イベントも処理し終わると、普通の OS なら、また最初からプログラムの実行を開始する。
それが一番時間を無駄にしないからね。
でも、Scratch は「垂直同期待ち」に入る。
全部のキャラが動いた後は、垂直同期待ち。
(実際には垂直同期ではないのだけど、ここではそう呼ばせてもらう)
コンピューターを「計算機」と考えるなら、待ち時間は無駄となる。
でも、ゲームを作ったりアニメを作ったりしたいのなら、この動作は非常に理に適っている。
▼高速化
ゲーム中に、大量のデータ処理が必要でループを回すと、上に書いたように垂直同期待ちを入れられてしまう。
データ処理をゲーム進行と並列に行う…というのでは動作がおかしくなってしまうだろう。
並列実行を前提としたプログラム環境…たとえば、Java なんかでは、一定の操作の中に割り込みを入れないことを保証する記述方法がある。
共有している変数を複雑な方法で書き換えている最中に、別のプログラムが関連する変数にアクセスするようでは困るからだ。
そして、Scratch にも割り込まれないことを保証する記述方法がある。
指定された範囲のプログラムには、ループ末尾で垂直同期が入らなくなる。これで大量のデータ処理が瞬時にできる。
この指定には「ブロックを作る」を使う。
ブロックとは、Scratch の命令のことだ。
自分で命令を作れる。つまり、サブルーチンのことだ。
ブロック作成時にはいろいろなオプションがあるのだけど、「画面を再描画せずに実行する」にチェックを入れる。
再描画とは、つまり垂直同期だ。ブロック中には垂直同期待ちをしない、と指定できる。
以上。方法としては非常に簡単だ。
以降の説明では、この技法を「高速化」と呼ばせてもらう。
注意点として、垂直同期待ちをしない、というのはシステムに処理を戻さない、という意味なので、イベントも発生しない、ということを忘れてはならない。
メッセージを送ったり、クローンを生成することは出来る。
でも、ブロックの中にいる限り、そのメッセージを他のプログラムが受け取ることはないし、「クローンされた」イベントも起こらない。
どういうことか、次に詳細に述べよう。
▼クローン処理
ループ内でクローンを使って多数のキャラクターを作り出していたとしよう。
クローンされたキャラクターを好きな位置に配置するために、座標をグローバル変数で渡していたとする。
「クローンされた」イベントが起きると、クローンは座標をグローバル変数から取り出し、自分自身の位置を変更する。
次々グローバル変数を書き換え、クローンを生成したとしても、ループ末尾ごとにシステムに処理は返され、「クローンされた」イベントが起きるので問題なく動作する。
ここで、多数のキャラクターを作るのに時間がかかるので、高速化したとする。
ループ末尾になってもシステムに処理は帰らず、次々とグローバル変数を書き換えながらクローンを作り出す。
いよいよすべての処理が終わり、ブロックが終了した後に、まとめてすべての「クローンされた」イベントが実行される。
最後のグローバル変数の位置に、作り出したすべてのクローンが移動してしまう。
高速化するためのオプションを入れただけでプログラムは変えていないのに、さっきまでは起きなかったバグが起きる。
単純なゲームを作る程度なら高速化する必要はない。
普通はクローンとループはセットになっているだろうし、何も気にせずに作っても、結構うまく動く。
でも、高速化が必要なくらい複雑なゲームを作りたいのであれば、相応に複雑な Scratch の内部構造を理解しなくてはならない。
▼クローン時の変数
さて、Scratch ではプログラムはスプライトに属するもので、そのスプライト自身の挙動を記述する。
他のスプライトの挙動を制御することは出来ない。
でも、クローンは例外的な命令で、自分自身をクローンするだけでなく、指定したスプライトをクローンすることもできる。
ここで、スプライトを指定してクローンすると、そのクローン後の挙動を制御することが難しい。
出現位置を変えたり、何らかのパラメーターを渡すには、上に書いたようにグローバル変数を介在させる必要があるだろう。
そして、高速化で悩むことになる。
もし「自分自身」のクローンで事足りるのであれば、こちらの方が多くのことを制御できる。
というのも、クローンは、クローン命令時点でのクローン元のローカル変数内容をコピーして引き継ぐからだ。
グローバル変数を介在しない。
「クローンされた」が実行されるまでのタイムラグもない。
命令実行時点でパラメーターを受け渡す、唯一の方法だ。
ただし、自分自身のクローンで何もかも済ますことはできない。
パラメーターを受け渡したい、という唯一の理由で、それ以外のすべてのプログラムも受け継ぎ、全く挙動が違うのに処理を振り分ける…なんていうややこしいことをする必要はない。
全く挙動が違うのであれば、全く違うスプライトにしたほうがいいだろう。
その分パラメーター受け渡しがややこしくなるかもしれないけど、全体が複雑化するよりは簡単だ。
パラメーター受け渡し用に、リストを用意するといいだろう。
「追加」すると、リスト末尾にデータが入る。
「1番目」から取り出した後で「1番目を削除」すれば、FIFO キューが出来上がる。
これで値を受け渡しすれば、多数のクローンが一気に作られても問題は出ない。
#「クローン」命令の発行順と「クローンされた」イベントの起こる順が一緒である保証はないので、種別の違うスプライトをクローンする際には、違うキューを用意しておいた方が無難。
先に書いたように、一つのスプライトですべてを済ませてしまえば、キューが多数になるような複雑さは防げる。
どちらにするか、自分の良いと思うバランスを考えてプログラムするしかない。
▼プログラムテクニック
動作詳細としては、以上だ。
Scratch 、という言語処理系が、ゲーム作成に必要な技術を自然な形で取り込んでいるのがわかると思う。
だから、Scratch はゲームが作りやすい。
とはいえ、やっぱり初心者向けの言語なので、すでにプログラム経験が深い人には使いづらいかもしれない。
たとえば、関数を作れない。
BASIC でいうサブルーチンは作れるのだけど、返り値は戻せないし、ローカル変数も使えない。
一応引数は渡せて、それはローカル扱いになるのだけど、この引数は参照のみで変更禁止。
Javascript Code Golfer がやるような、引数を使ったローカル変数定義テクニックとかは使えない。
オブジェクトは作れる。
Scratch は Smalltalk 由来なので、C++ 的なオブジェクトではなく、Smalltalk 的なオブジェクトだ。
このオブジェクトは、ATARI 用語的なオブジェクトと若干の混同が見られる。
オブジェクトは、必ずスプライトなのだ。もちろん、わざとやっているのだろう。
スプライトだから必ず画面表示用の画像が付くし、暗黙の内に座標などの変数が定義されている。
でも、画面から「隠す」ことは可能だし、いらない機能は無視すればいい。
オブジェクトなのだから、その内部だけで使える変数も定義できる。
サブルーチンではローカル変数が作れないが、オブジェクト内でローカル変数が使えるのだから、良しとしよう。
オブジェクト間ではメッセージを介して通信ができるが、先に書いたようにメッセージ送信は即座に行われるわけではない。
オーバーヘッドが大きいので、「オブジェクトが通信しあいながら協調動作する」という、Smalltalk の理想には程遠い。
まぁ、ゲーム用なので、「次の画面までに解決できれば良い」程度のことは、メッセージで処理できる。
今回コラムスを作ったのだけど、フィールドを一つのオブジェクトとして扱おうと試みて、断念した。
結果として、フィールドは配列(リスト)構造になった。
落ちてくる宝石を操作するオブジェクトでは、このフィールドを覗きながら、積み上がっている宝石に当たらないように操作を行う。
落ちきったら、ゲームの流れを制御するオブジェクトが、フィールドを調べて消える宝石を消す。
宝石が消えたときは、「消えたよ」というメッセージが送信される。
でも、このメッセージは、表示されている宝石すべてが受け取ってしまう。
宝石は、メッセージをきっかけとして、各自が自分に対応するフィールドの中を調べる。
自分のいる位置の宝石が消えていたら、消えるアニメーションを実行して消滅する。
とにかく、「フィールド」の扱いがいろんなオブジェクトに分散していて面倒くさい。
オブジェクト指向の美しさなんて全くない。
これは「Scratch がダメだ」と言っているのではないよ。
泥臭い方法をとったけど、ちゃんと作りたいゲーム作れたもの。
世の中には結構ダメな環境があって、不満が出たら「あきらめる」しか選択肢がない場合もある。
ちなみに、不満が出ない環境というのは存在しない。
Scratch は、泥臭い方法を許容する。
それだけで十分だ。
2016.6.20 追記
Scratch 教室をやっている「ロジックラボ」さんが、このページ内容を子供向けに興味を持てる形に置き換えて、漫画で描いてくださっていました。
そこで指摘されるまで気づいていませんでしたが、画面描画に関与するような変更がない限り、ループ末尾でシステムに処理を返すようなことはしない、とのこと。
確かに、実験してみるとその通りでした。
「リスト」の処理が微妙で、ループ中にリストをいじった時、デバッグ用にリストを表示していると、表示更新のために垂直同期待ちします。
しかし、デバッグ時でないと垂直同期待ちしません。
この日記を書く前、ゲーム作成時にどうも同期タイミングが理解できず、「デバッグのために」リスト表示したりしていたので、ループ末尾は常に処理が戻るのだと勘違いした模様。
#そもそも僕は Scratch の動作をあまり知らないので、調べて整理したかった、という動機です。
プログラム経験は長いけど、Scratch に関してはまだまだ素人。申し訳ない。
ちなみに、変数は画面に表示していても、垂直同期を引き起こさないようです。
一貫性がないけど、変数は頻繁に書き換えるものなのでいちいち待ち時間を発生しないほうが良い、という判断なのでしょう。
2016.6.27 さらに追記
もう一本ゲームを作ってみてます。
近いうちに公開できると思うけど、システムに処理を返すタイミングがよくわからなくなって困っています。
状況次第で、画面描画しないループでも、システムに処理を返すことがあります。
「高速化」オプションをつけているブロックの途中で、システムに処理を返してしまうこともあります。
今作っているゲームでは、面クリアして特定の面に差し掛かったところで、後者のバグ(?)が出ることが多い。
必ずバグが出るわけではないけど、ある程度再現性があるので、特定条件でバグが出るのだと疑っています。
また、一連の無限ループスクリプト内でブロックを呼び出している場合、繰り返し呼び出されるブロックが、常に高速かオプションが無視されます。
ということは、このバグは「ブロック呼び出し」に付随するのではなく、そのブロックを呼び出すスクリプトのスレッドに付随するようです。
#面クリア時にスレッドを終了し、次の面で再度スレッドを起動する、というつくり方をしている。
バグが出た面は、ずっとスレッド内で呼び出されるブロックの高速化オプションが無視される。
(ゲームにならなくなってしまうので、クリアして次の面でどうなるかは不明)
スクラッチのタスクスイッチはまだまだ謎が多いです。
同じテーマの日記(最近の一覧)
関連ページ
別年同日の日記
申し訳ありませんが、現在意見投稿をできない状態にしています。 |