お手伝いしているゲーム作成での話。
いわゆるソーシャルゲームで、スマホ端末を使って Javascript で画面などを動かしている。
ゲームエンジン自体はサーバー側で持っていて、端末側は結果をそれらしく見せるだけ。
Javascript 側では、「それらしく見せる」ために、jsdeferred を使っている。
最近の流行というか、普通に使われる書き方ね。
next(function(){
処理 A
return wait(1.0)
}).
next(function(){
処理 B
return wait(1.0)
}).
next(function(){
処理 C
return wait(1.0)
});
とか書くと、処理 A の 1 秒後に処理 B を、さらに1秒後に処理 C を行ってくれる。
なんでこんな書き方するのか、については言及しない。
上のような処理を Javascript で普通に書いていると、非常に面倒くさいことになるのだ、とだけ言っておこう。
僕ではない誰かが作ったアニメのファイルを改造してほしい、と頼まれた。
特定の状況で、今までにはない別のアニメを割り込ませる。
上に書いたように、next で処理をつなぐ書き方なら、割り込みは難しくなさそう、に思える。
でも、実際にはそうではない。
かなりややこしい呼出し手順…古い言い方なら「スパゲティプログラム」になっていた。
function 処理A {
~~~
処理B();
}
function 処理B {
~~~
処理C();
}
function 処理C {
~~~
処理D();
}
まず、概略としてはこんな感じになっている。
~~~で示してある部分は、いろんな処理が入っていると思って。もちろん next をつないである。
そして、最後の next の中で、次の処理を呼び出している。
なんでひとつながりの next にしないで、ある程度のまとまりで関数化してあるかというと、アニメは「タップで飛ばす」ことができるからだ。
処理 A の最中にタップされると、処理 A の部分のアニメの残り部分は飛ばされて、処理 B の部分から始まる。
その処理のために、別の関数が用意してある。
function タップ {
switch(シーン番号){
case 0:
処理A の終了処理
処理B();
break;
case 1:
処理B の終了処理
処理C();
break;
case 2:
処理C の終了処理
処理D();
break;
}
}
あぁ、なるほどなるほど。タップしたらスキップするのだから、そういうことになるわな。
…でも、このプログラムは実はおかしい。
ここで「終了処理」と書いてあるのは、アニメの最後の状態に持っていく、というだけ。
処理 A の終了処理であれば、強制的に処理 A のアニメの最後の状態にする。
でも、処理 A のプログラムは動き続ける。next を使っていると、一定時間後には次の部分が動いてしまうから。
一応、元のプログラムを作った人も考えたようで、 wait で指定した「待ち時間」をキャンセルする処理を入れていた。
これ、すぐに次の next に移る、というだけで、プログラムはやっぱり止まらない。
だから、プログラム中にすごい数の…しつこいくらいに、現在のシーン番号を確認して、致命的な処理をしないためのプログラムが入っていた。
致命的でなければ…つまりは、アニメがおかしくならなければ、無駄な処理をしてもかまわない、という書き方。
本当に無駄なだけなら構わないけど、やっぱりいつバグが出るかわからない、恐ろしいプログラム。
さて、本当に恐ろしいのはここから。
上の例はなんとなく「流れ」を読んでほしかったので、処理は A B C D …と進むように書いてある。
でも、本当はそうではない。状況によって間が飛ばされる。
次への呼び出しはこんな感じ。
function 処理A {
~~~
if(フラグ1){
処理B();
}else if(フラグ2){
処理C();
}else{
処理D();
}
}
「フラグ」と簡単に書いたところは、複合条件のこともある。
そして、類似した内容が処理ごとの最後に繰り返し書かれているし、タップした際のスキップ処理ごとにも入っている。
条件で「間を飛ばす」だけではなく、排他的に呼び出したりもする。
とにかく、多数のややこしい処理の条件分岐を、多数の場所に同じように書いてあって、でも少しづつ記述が違う。
そして、それらが全部同じように動くことが期待されている。
スパゲティプログラム、っていうのは、goto 文が当たり前だった時代の、混乱した流れを持つプログラムの蔑称だ。
でも、goto 文があるからスパゲティプログラムができるんじゃない。
現代にだってスパゲティを生み出すプログラマは存在する。
1つ弁護しておくと、このプログラムは一人の手によるものではない。
最初は素直な流れだったのだけど、ソーシャルゲームでは毎週のように「新機能」が追加されるので、拡張を繰り返すうちにおかしなことになったのだ。
そして、今手伝っている仕事では、「困った事態」になると僕が呼び出される。
もう、誰の手にも負えない状態になったプログラムに、さらに新しい機能を突っ込む必要が出て、何とかならないかと相談されたのだった。
さて、換骨奪胎。
プログラムの一番「中心となる部分」…つまり、処理を呼び出す流れの部分を総入れ替えして、周辺の部分はそのまま使いまわそう。
処理の流れを整えるための関数を、一つ用意する。
各シーンの最後は、「次のシーンへ」飛ぶ代わりに、この関数を呼び出すようにする。
また、画面タップで処理を飛ばす際も、引数付きでこの関数を呼び出す。
var sceneNum=0;
nextScene(){
switch(sceneNum)
// シーンの終了処理(スキップした時用)
}
sceneNum++;
//条件によりシーンを飛ばす処理
if(sceneNum==1 && フラグ1)sceneNum++;
switch(sceneNum){
// シーンの呼び出し処理
}
}
これで、複雑怪奇なスパゲティ部分をなくすことができる。
next の中で使われる wait に関しては、sWait という関数を使って置き換える。
sWait の中身は後で書くことにして、先に呼び出し側を示そう。
function 処理A {
next(function(){
処理
return wait(1.0)
});
}
というプログラムなら、次のように変える
function 処理A {
var scene = sceneNum;
next(function(){
waitHandle = sWait(1.0,scene)
処理 A
return waitHandle;
});
}
変わった点は2つ。
現在の sceneNum を処理の冒頭でコピーしておき、sWait に渡すこと。
そして、return で直接 wait を書くのではなく、処理の前に wait の値を得る処理を行い、最後にその値を return すること。
なんでそんな風にするのかは、 sWait の中身を示せばわかる。
function sWait(sec,scene){
if(scene!=sceneNum)throw "force end";
return wait(sec);
}
scene にコピーされた値が、sceneNum と変わった場合…つまり、タップされてシーンが変わったら、throw 命令で例外を発生する。
そうでなければ、通常の wait と同じように振る舞う。
throw で例外が起きると、jsdeferred は next でつながれた処理を、全部打ち切ってしまう。
だから、シーンが変わったらこれですべての処理を打ち切ることができる。
また、処理の「前」に sWait 呼び出しを書くのは、前回の wait で示した時間待っている間にタップされ、シーンが変わった際には、「処理」を行ってはならないからだ。
これで、「シーンが変わったのに、前のシーンの処理が実行されておかしくなる」ことは無くなる。
そうした事態に備えて入れられていた、小さな条件分岐は全部なくしてしまって構わない。
ごちゃごちゃとしたプログラムが、すっきりとしたものになる。
処理をまとめた関数の冒頭で scene を定義する。
wait を全部書き換える。
最後の「次の呼び出し処理」を全部書き換える。
シーン番号を確認する条件分岐を全部消す…
というのは、結構な作業量ではある。でも、機械的な作業だから難しくはない。
そして、これによりスパゲティとおさらばすれば、新たなシーンの追加も難しくなくなる。
「処理」と「処理の流れ」は別物なのだけど、これをしっかり分離して考えるのは、プログラムにかなり慣れた人だけだと思う。
ゲームなんか作っている人は理解しているかもしれない。
ゲームって、条件分岐の塊だから、その分岐をうまく分離しておかないと無用な混乱を引き起こす。
jsdeferred は、javascript が苦手とする「小さな流れ」を作り出すためのツールで、結構有用だ。
でも、それはあくまでも小さな流れ。条件分岐を必要としない流れのみ。
条件分岐を必要とする大きな流れは、別の個所でまとめて処理して、流れを「整流」してやる必要がある。
小さな流れの中に条件分岐をごちゃごちゃと書き込むと、バグの元だ。
上に書かれている程度なら、ここに書いたようなややこしい手法を使わないでも、力技でも乗り切れる。
でも、力技で乗り切っていれば、いつかバグが出る。
実際に任されたプログラムは、他の理由による処理の混乱も混ざっていて、本当に「ちょっといじればバグが出る」状況になっていた。
ここでは jsdeferred の話として書いたけど、こういう仕事は昔から時々やっている。
自分のプログラムが拡張に従ってややこしくなって…という時もあるけど、大抵は他人のプログラムの整理だな。
その時々で、どう書き直せばスマートになるかは違うので、一概に方法を示すことはできないのだけど、とにかく「処理」と「処理の流れ」を分離することが重要。
この考え方は、いつだって役に立つ。
同じテーマの日記(最近の一覧)
関連ページ
ServiceWorker と indexedDB【日記 19/05/03】
別年同日の日記
申し訳ありませんが、現在意見投稿をできない状態にしています。 |