"Game Programming Patterns"を読んで ~ オブザーバパターンを活用してみた
本書について
"Game Programming Patterns"は、ゲームプログラミングに特化したデザインパターンの本です。
Amazonのレビューでも言及されているように、事例がゲームに特化していて分かりやすいのが特徴です。
大抵のデザインパターンの本は、誰でもわかる汎用性のある事例を使いたがる傾向にあり、その結果として学生の管理簿や社員名簿などが事例になりがちです。
しかし、本書の場合は事例がゲームに特化しており、サンプルコードもゲームのものとなっています。
また、サンプルコードはC++で書かれていますが、あくまでもデザインパターンの本でありC++の解説書ではないため、平易な構文が使われています。
そのため、普段C++を使っていない私でも何となく雰囲気をつかむことはできました。
ゲーム製作の経験がある人で、「ここはもう少しうまい実装の仕方がないだろうか?」と日頃モヤモヤを抱えている人であれば、ご一読をおすすめします。
今回は、本書でも紹介されていたデザインパターンの一つであるオブザーバパターンを私が実際に活用した事例を紹介します。
問題意識
シューティングゲームを作っているとします。
弾のクラスbullet
の衝突判定をする関数detectCollision
を作っている際に、弾が衝突したときに爆発音を鳴らしたくなったら、以下のようにコードを書くと思います。
export default class bullet{
constructor(){
...
}
...
detectCollision(target){
//「弾と対象の中心の距離」<「弾と対象の半径の和」ならば衝突していると判定
if(Phaser.Math.Distance.Between(this.x, this.y, target.x, target.y) < this.r + target.r){
scene.sound.play("bomb");//爆発音を鳴らす
}
...
}
...
}
これで完成かと思いきや、シューティングゲームなので弾が当たったら対象のHPを減らさないといけないことを思い出し、以下のようにコードを加えました。
export default class bullet{
constructor(){
...
}
...
detectCollision(target){
//「弾と対象の中心の距離」<「弾と対象の半径の和」ならば衝突していると判定
if(Phaser.Math.Distance.Between(this.x, this.y, target.x, target.y) < this.r + target.r){
scene.sound.play("bomb"); //爆発音を鳴らす
target.addHP(-1); //相手のHPを1減らす
}
...
}
...
}
今度こそ完成!...と思いきや、対象が特殊なオブジェクトだった場合は実績を解除するシステムを付けることになったので、更にコードを変えました。
スペースインベーダーのようなゲームででUFOを倒したときに、「UFOを撃破!」という実績が着くイメージです。
このとき、実績解除システムであるachievementManager
が必要になったので、引数に加えました。
export default class bullet{
constructor(){
...
}
...
detectCollision(target, achievementManager /* achievementManagerを引数に追加 */){
//「弾と対象の中心の距離」<「弾と対象の半径の和」ならば衝突していると判定
if(Phaser.Math.Distance.Between(this.x, this.y, target.x, target.y) < this.r + target.r){
scene.sound.play("bomb"); //爆発音を鳴らす
target.addHP(-1); //相手のHPを1減らす
//もし対象がUFOだった場合、実績を解除する
if(target.type === "UFO"){
achievementManager.unlock("DESTROYED_UFO");
}
}
...
}
...
}
こんな具合にdetectCollision
関数にどんどんコードが増えていくのはよくあることだと思いますが、冷静に考えるとこの関数の本来の役割は衝突を検知することです。
一方で、一連の流れの中で追加されたコードは、音を鳴らしたり実績を解除したりするなど、どれも衝突の検知とは関係の薄いものになっています。
物理演算に関するコードの中にサウンドシステムに関するコードや実績解除システムに関するコードが紛れているとは普通考えないので、他の人や未来の自分からしたら「予期しない依存関係」に他なりません。
この問題に対処する一つの方法が、今回紹介するオブザーバパターンです。
オブザーバパターン
処理の流れ
オブザーバパターンは、通知を監視するオブザーバと、オブザーバからの通知を待つサブジェクトからなります。
処理の流れは以下のようになります。
①サブジェクトとオブザーバを紐づける
例えば、サウンドシステムはオブザーバに「音を鳴らす関数をあげるから、もし衝突の通知があったらこれを実行してね!」と依頼します。
実装としては、observer
クラスのsubscribe
関数の引数に音を鳴らす関数を入れてオブザーバに申し込み(subscribe)をします。
②衝突を検知したらオブザーバに伝える
observer
クラスのnotify
関数を用いて衝突があったことをオブザーバに伝えます。
③オブザーバが各サブジェクトから衝突時に実行を依頼されていた関数を実行する
①で依頼されていた関数をそれぞれ実行します。
実装例
実際に実装しながら確認していきましょう。
まず、オブザーバのクラスobserver
を作成します。
オブザーバには、メッセージを受信するnotify
関数と、サブジェクトを登録するsubscribe
関数を用意します。
export default class observer{
constructor(){
//初期化時にsubjectの配列を定義
this.subjects = new Array();
}
notify(messageType, option){
//messageTypeに応じて処理を分岐させる
switch (messageType) {
case "COLLISION":
//衝突の通知を受けたら、サブジェクトのうち衝突時に実行される関数であるonCollisionを呼び出す
for(let subject of this.subjects){
subject.onCollision(option);
}
break;
default:
break;
}
}
subscribe(onNotify){
//サブジェクトの配列に追加する
this.subjects.push(onNotify);
}
}
次に、サブジェクトとオブザーバを紐づけます。
先ほどの例だと、
- サウンドシステム(爆発音を再生する)
- 敵(HPを減少させる)
- 実績解除システム(実績を解除する)
の3つのサブジェクトがあります。
//サウンドシステム
class soundManager{
...
const onNotify = {
//衝突時に実行される関数
onCollision:function(){
//オブザ―バーに衝突の通知が来たら、爆発音を再生する
scene.sound.play("bomb")
}
}
//オブザーバに上記の関数を渡し、衝突時に実行してもらう
observer.subscribe(onNotify);
...
}
//敵
class enemy{
constructor(id, observer){
this.id = id; //個体を識別するためのID
const onNotify = {
//衝突時に実行される関数
onCollision:function(option){
//オブザ―バーに衝突の通知が来て、もし衝突の対象が自分だったらHPを減らす
if(option.target.id === id){
this.hp -= 1;
}
}
}
//オブザーバに上記の関数を渡し、衝突時に実行してもらう
observer.subscribe(onNotify);
...
}
...
}
//実績解除システム
class achievementManager{
...
const onNotify = {
//衝突時に実行される関数
onCollision:function(option){
//オブザ―バーに衝突の通知が来て、もし衝突の対象がUFOだったら実績を解除する
if(option.target.type === "UFO"){
//実績を解除する
unlock("DESTROYED_UFO");
}
}
}
//オブザーバに上記の関数を渡し、衝突時に実行してもらう
observer.subscribe(onNotify);
...
}
これで、オブザーバに衝突の通知が来たときには、それぞれのonCollision
関数が実行されるようになりました!
最後に、detectCollision
関数は衝突を検知したらオブザーバnotify
関数で衝突したことを伝えます。
オブザーバーに伝えるのは衝突を検知したことだけであり、誰が何をするべきかまでは伝えません。
export default class bullet{
constructor(){
...
}
...
detectCollision(target, observer){
//「弾と対象の中心の距離」<「弾と対象の半径の和」ならば衝突していると判定
if(Phaser.Math.Distance.Between(this.x, this.y, target.x, target.y) < this.r + target.r){
//オブザーバーに衝突したことを通知する
observer.notify("COLLISION", {object:this, target:target});
}
...
}
...
}
observer
のnotify
関数が実行されると、各サブジェクトのonCollision
関数が実行されます。
ここで注目していただきたいのが、先ほど述べたようにオブザーバに伝えるのは衝突を検知したことだけであるため、detectCollision
関数には音声の再生などのロジックが一文も書かれていません。
また、もし衝突を検知したときに動作させたいものが追加されたとしても、新しくsubscribe
すればいいだけなので、detectCollision
関数には何も変更を加える必要はありません。
オブザーバパターンの注意点
忘れず削除する必要がある
例えば、敵が消滅したときを考えましょう。
敵が消滅した後でも、衝突を検知するとその敵に関するonCollision
関数が実行されてしまい、エラーが起きてしまう場合があります。
そのため、unsubscribe
関数を用意して消滅時に実行するなどしてサブジェクトから削除する必要があります。
また、参照が残り続けてしまうとガベージコレクションが行われず、メモリを圧迫してしまうという問題もあるので、その観点からも適切に削除すべきでしょう。
バグの追跡が困難になる場合がある
detectCollision
関数が実績解除システムなどのメソッドを直接呼び出していたときとは異なり、オブザーバを介して通信を行うようになったため、バグの追跡が困難になる場合があります。
最後に
個人でゲームを製作しているとしても、過去の自分は他人のようなものであり、未来の自分が見ても分かりやすいコードを書く必要があります。
オブザーバパターンは、予期しない依存関係を解消する一つの有力な方法だと感じられ、個人開発でも導入するメリットがあると感じられました。
"Game Programming Patterns"には、オブザーバパターン以外にもたくさんの有用なデザインパターンが列挙されていたので、どんどんゲーム開発に取り入れていきたいです。