モジュールを使ってみよう
前回は、「プログラムを見やすくするためにモジュール化してみよう!」という話をしました。
今回はこの話を発展させて、どのようにモジュール化を進めていくか、具体的な設計の話をします。
まずはモジュール化を用いた一番シンプルなプログラム構成を見てみましょう。
メインのプログラムを一つ用意して大部分の処理はメインプログラムで行います。
そして、他ファイルに書いたモジュールを必要に応じてimportしていきます。簡単な使い方ですね。
モジュールは一つだけではなく、複数用意することもできます。
大きなプログラムを役割ごとにモジュールとして分割していくと、コードが読みやすくなります。
このように「メインプログラムから常に全部のモジュールを読み込んでいく方法」はシンプルである一方で、この書き方を続けていくとメインプログラムはどうしても大きくなってしまいます。
これは、メインプログラムが持つ役割が大きすぎることが原因です。
そこで、メインプログラムを役割ごとに分割していくことを考えます。
さて、どのようにしてメインプログラムをモジュール化していくかを考えていきましょう。
この設計論に銀の弾丸はなく、状況によって最適解は変わってくるものですが、
ここは先人の知識を参考にしてみましょう。
今回は、MVPパターン呼ばれる設計についてソースコードとともに説明します。
MVPパターンとは、モジュールを大きく「View(入力と画面表示)」「Model(純粋な計算部分)」「Presenter(Viewから入力を受けて、Modelを用いつつ処理を書いていく中核部分)」にわけて書いていく設計方法です。
ここからはMVPパターンの役割を説明します。後ほど具体的なプログラムをお渡しするので、ここでは概念をふわっと理解しながら流してください。
MVPパターンの処理流れ解説
MVPパターンでは、メインの処理をすべてPresenterが行います。
そして、Presenterにほぼすべての副作用を集めます。副作用を大まかに説明すると「変動する値を持ち、その値によって処理を変更すること」です。
ブロック崩しで具体的に説明すると、ボールの座標やバーの座標、現在残っているブロックの座標は毎回変動するので、この値を管理するモジュールは副作用を持つモジュールと呼びます。
この値の管理はすべてPresenterが行うようにします。
ViewやModelはこのような変動する値は持たないようにします。
ただ、Viewだけは画面描画の都合でどうしても変動する値を持ってしまうので、そこは最小限に留めるよう処理を可能な限り減らす努力します。
とにかくPresenterが処理の中枢です。Viewは入力と画面描画のみを行い、Modelは副作用のない純粋な関数にして難しいロジックを解くために使います。
具体的にいうと、「2点の座標を引数にしてその距離を返す関数」や「四角形2つを引数にしてそれが衝突しているか返す関数」はModelで表します。
Modelは引数から一意に値が決まるようにするため、正しいコードかテストをすることが容易です。色々な引数を入れて、その返り値が正しいか確認すればよいので。
処理の流れはこんな感じです。
これだけ見るとポカンとしてしまうかと思うので、あとでソースコードを見ながら実際に説明していきますね。とりあえず大まかな流れだけ説明します。
Viewで入力イベントを受け取って、Presenterで処理を書きます。
Presenterは処理の流れだけ書いて、難しいロジックは全部Modelに投げます。
最後にPresenterで処理した結果をViewに投げて、適切な画面表示を行います。
さて、次からはこのMVPパターンを用いたブロック崩しプログラムのソースコードをお渡しします。実際に見てみましょう。
MVPパターンでブロック崩しゲームを作ってみた
具体例を見た方がよいので、ここからはソースコードを交えて説明します。
ソースコードはgithubにあげています。
https://github.com/hothukurou/mvp_breakout
githubをはじめて触る方のために、ダウンロードの方法を説明します。
以下画像のように、codeボタンを押して、download zipをクリックしてください。
github使ったことある方は普通にforkして自由にいじってください。
さて、次にプログラムを実行するための準備をします。
index.htmlをクリックすればブロック崩しがはじまるのですが、import文はサーバー上でないと動作しないので、ローカルサーバーを立ち上げてください。
やり方がわからない方は以下の記事を見てください。
index.htmlをローカルサーバー上で確認してください。こんな感じのブロック崩しが遊べると思います。
ちなみに、このブロック崩しプログラムは以下の記事で作り方を説明しています。画面の描画にはライブラリを使わずに、HTMLを操作するDOM操作を用いています。
この記事では1ファイルにまとめてブロック崩しを作っていましたが、今回お渡ししたプログラムではコードをMVPパターンでモジュール化して分割しています。
さて、起動して遊べることを確認したら、このプログラムの構造を確認していきます。
ブロック崩しプログラムの構造
さて、今回お渡ししたプログラムのファイル間のつながりはこのようになります。
ちなみにファイル名とその中で宣言しているクラスは基本的に一致するようにしています。
具体的にいうと、breakout_presenter.jsにはBreakoutPresenterクラスが書かれています。
container.jsという新参者が現れていますね。この中で定義しているContainerクラスはオブジェクト間の繋がりを解決する役割を担っています。
ここで、一つ用語の説明をさせてください。
他のモジュールをインポートすることを「依存する」と呼びます。
依存するということは、依存先のモジュールがないと動作しないよ!といことです。
モジュール間の依存は一方的な関係でないといけません。2つのモジュールがお互いに依存しないようにしてください。
今回の例では「ViewerとDataStoreとModelは他モジュールに依存しない」ように心がけています。
その代わりBreakoutPresenterがたくさんの依存関係を持っていることがわかりますね。
この依存関係を整理しているのがcontainer.jsに書かれているContainterクラスです。
実際のコードを見てみましょう。container.jsを見てください。
Containerクラスでは必要なクラスを全部オブジェクトにして、依存する関係を整理しています。
コードを見ると、breakoutPresenterクラスはインスタンス化する時にdataStoreオブジェクトとbreakoutViewerを代入していることが見てわかるかと思います。
BreakoutPresenterクラスはBreakoutViewerクラスとDataStoreクラスを呼び出すので、実行時にこのインスタンスを代入しているのです。
このような書き方を依存性の注入(Dependency injection)と呼びます。
この構造の説明ってかなり初見だと意味不明かと思いますので、ちょっとわかり易い言葉でまとめます。
・依存性の注入とは
あるクラスAから別のクラスBを呼び出す時に、クラスA宣言時の引数にクラスBのインスタンスを代入すること。
ちなみにModelは関数しかないので、インスタンスを代入する必要はありません。関数には状態がなく、常に引数から一意の答えをだす「副作用のない純粋な関数」だからです。
副作用がない関数がいかにすばらしいかわかってもらえたかと思います。しがらみがなく、自由に使うことができるのです。
Viewからの入力イベント
今回のブロック崩しでは、入力処理は5つだけです。
この5つの入力イベントをViewでは監視します。
入力イベント一覧
右ボタンを押した時・離した時のイベント
左ボタンを押した時・離した時のイベント
ディスプレイをクリックした時のイベント
この5つの入力イベントを受信する部分をBreakoutViewerで設定します。
そして、この5つに対応するBreakoutPresenterにあるメソッドを実行していく流れになります。
ここから、依存性を片方だけにするためにちょっと混乱する話をします。
普通に書くと、BreakoutViewerがBreakoutPresenterのメソッドを実行するため、BreakoutViewerがBreakoutPresenterをインポートする必要が出てきます。
コールバック関数を用いた「依存しない仕組み」
それを避けるためにコールバック関数を登録して、BreakoutViewerはどこにも依存させない仕組みにしています。
図にしてみましたが、慣れていないと混乱しそうな話です。これで理解できそうでしょうか・・・。
このイベント発火の流れが、コールバックを用いている関係でプログラム初心者の方にはわかりずらくなっているかと思いますので、順を追って説明します。
まず、BreakoutViewerのコンストラクタで各種イベントを登録します。
// コールバック関数
this.pressLeftButton = undefined;
this.releaseLeftButton = undefined;
this.pressRightButton = undefined;
this.releaseRightButton = undefined;
this.releaseDisplay = undefined;
// マウス用イベント
this.leftButtonDom.addEventListener("mousedown", () => { this.pressLeftButton(); }, false);
this.leftButtonDom.addEventListener("mouseup", () => { this.releaseLeftButton(); }, false);
this.leftButtonDom.addEventListener("mouseout", () => { this.releaseLeftButton(); }, false);
this.rightButtonDom.addEventListener("mousedown", () => { this.pressRightButton(); }, false);
this.rightButtonDom.addEventListener("mouseup", () => { this.releaseRightButton(); }, false);
this.rightButtonDom.addEventListener("mouseout", () => { this.releaseRightButton(); }, false);
this.displayDom.addEventListener("mouseup", () => { this.releaseDisplay(); }, false);
// スマホ用イベント
this.leftButtonDom.addEventListener("touchstart", () => { this.pressLeftButton(); }, false);
this.leftButtonDom.addEventListener("touchend", () => { this.releaseLeftButton(); }, false);
this.rightButtonDom.addEventListener("touchstart", () => { this.pressRightButton(); }, false);
this.rightButtonDom.addEventListener("touchend", () => { this.releaseRightButton(); }, false);
this.displayDom.addEventListener("touchend", () => { this.releaseDisplay(); }, false);
次にここで定義したコールバック関数を登録するメソッドを定義しています。
このコールバック関数の中身はbreakoutPresenter内のメソッドになる予定です。そのメソッドの代入をbreakoutPresenterに任せることで「breakoutViewerがbreakoutPresenterに依存しない」ように書くことができます。
setReleaseDisplayButtonCallback(func) {
this.releaseDisplay = func;
}
setPressLeftButtonCallback(func) {
this.pressLeftButton = func;
}
setReleaseLeftButtonCallback(func) {
this.releaseLeftButton = func;
}
setPressRightButtonCallback(func) {
this.pressRightButton = func;
}
setReleaseRightButtonCallback(func) {
this.releaseRightButton = func;
}
このようなコールバック関数を用いた書き方にすると、BreakoutViewerがBreakoutPresenterに依存しない書き方ができます。
混乱するかと思いますが、理解できましたか・・・?
PresenterでDataStoreの値を更新する
このViewから入力をもとに、Presenterでは変動する値を更新しています。
具体的には、入力イベントを元に「バーの移動値」や「画面クリックでゲーム開始状態へ移動」という状態を更新します。
今回は変動する値をすべてDataStoreクラスにまとめています。
このDataStoreクラス内にバーの現在座標やブロックの座標や、ボールの座標、ゲーム開始しているかなど、あらゆる状態を保存しています。
このDataStoreの値があればブロック崩しの全状態を表現できるようにしています。
BreakoutPresenter内で毎フレームごとに画面を描画する処理を書いているのですが、その時はこのDataStoreの値を用いて画面表示をしています。
Presenterの毎フレーム処理
BreakoutPresenter内のonenterframeメソッドにて、毎フレームごとにViewへの画面表示の指示を行っています。
ブロック崩しでは、毎フレームでボール(ball)の位置とバー(bar)の位置、ブロック群の位置(blocks)が変化します。その描画をこのフレームで行っています。
/**
* 毎フレームごとに実行する関数(initで実行したら永遠実行し続ける)
*/
onenterframe() {
// バーの動作処理やボールの処理を書いたり、
// ブロックの当たり判定処理を書く
// 次フレームで再度onenterframeメソッドを呼び出す
window.requestAnimationFrame(() => {
this.onenterframe();
});
}
画面表示はViewerで行います。onenterframeではViewerにある「ボールを座標指定して表示するメソッド」や「ブロックを座標指定して表示するメソッド」を叩いています。
Viewにある以下の関数を実行すると、画面にボールやブロックが表示されます。Viewでは処理を極力書かないで与えられた表示処理だけこなすようにしましょう。
// テキストを表示する
changeTextContent(text) {
DomHelper.changeDomTextContent(this.explainLabelDom, text);
}
// ブロック設置
setBlocks(blocksPos) {
// 画面にブロックが残っていれば、全部削除してblockDomsを空にする
while (this.blockDoms.length > 0) {
DomHelper.removeChildDom(this.displayDom, this.blockDoms.pop());
}
//ブロック生成 (w:20px h:10px)
for (const blockPos of blocksPos) {
const blockDom = DomHelper.createDom("block");
DomHelper.setDomPosition(blockDom, { x: blockPos.x, y: blockPos.y });
DomHelper.addChildDom(this.displayDom, blockDom);
this.blockDoms.push(blockDom);
}
}
setBallPos(pos) {
DomHelper.setDomPosition(this.ballDom, pos);
}
setBarPos(pos) {
DomHelper.setDomPosition(this.barDom, pos);
}
モデルについて
モデルでは、引数から返り値が一意に決まる難しい計算処理を書きます。
math2d.jsに書いていますが、実際に処理をみてみましょう。
「2点の座標を引数にしてその距離を返す関数(calcLength)」や「四角形2つを引数にしてそれが衝突しているか調べる関数(checkCollision)」をModel層で書いてみました。
変動する値がない関数はわかりやすいですし、扱いやすいです。
このような関数を副作用のない関数、純粋関数と呼びます。
このような関数モジュールは「本当に正しく動作しているか」を容易に検証することができます。この工程をユニットテストと呼びます。
実はこのプログラムにはモデルのユニットテスト機能も追加しています。
さっそくVSCodeのLive Serverを用いて、test.htmlを実行してみましょう。
test.htmlを起動したら、F12を押してデベロッパーツールを開いて、コンソールを確認してみてください。
なにやら緑色の文字がでてきました。
何をやっているか確認したいのでソースコードを確認しましょう。
import { calcLength, checkCollision } from "../model/math2d.js";;
const RED = '\u001b[31m';
const GREEN = '\u001b[32m';
const checkAssert = (testName, result) => {
if (result) console.log(`${GREEN}◎ ${testName}`);
else console.log(`${RED}✘ ${testName}`);
}
function calcLengthTest() {
console.log("calcLengthTest");
checkAssert("(10,10)と(10,100)の長さは90である", calcLength({ x: 10, y: 10 }, { x: 10, y: 100 }) === 90);
checkAssert("(20,20)と(100,20)の長さは80である", calcLength({ x: 20, y: 20 }, { x: 100, y: 20 }) === 80);
}
function checkCollisionTextTest() {
console.log("calcCollisionTest");
const rect1 = { x: 10, y: 10, width: 10, height: 10 };
const rect2 = { x: 15, y: 15, width: 10, height: 10 };
const rect3 = { x: 20, y: 20, width: 10, height: 10 };
const rect4 = { x: 20, y: 31, width: 100, height: 10 };
checkAssert("衝突パターン1", checkCollision(rect1, rect2) === true);
checkAssert("衝突パターン2", checkCollision(rect2, rect3) === true);
checkAssert("衝突パターン3(接点が交差)", checkCollision(rect1, rect3) === true);
checkAssert("衝突しないパターン1", checkCollision(rect1, rect4) === false);
checkAssert("衝突しないパターン2", checkCollision(rect2, rect4) === false);
checkAssert("衝突しないパターン3", checkCollision(rect3, rect4) === false);
}
calcLengthTest();
checkCollisionTextTest();
calcLength, checkCollisionという関数に色々な引数を入れて、正しい値が帰ってきているか確認していますね。
このように、「モジュールの挙動が正しいか確認すること」をユニットテストと呼びます。
このユニットテストの書き方はまだ別の記事で紹介します。お楽しみに!
まとめ
以上でMVPパターンを用いたブロック崩しプログラムの説明を終わります。
このパターンはPresenterがメインの処理となります。Viewからの入力イベントが増えると、それに伴いPresenterの処理メソッドもどんどん増えていきます。
難しい処理はModelに移して、Presenterの量を可能な限り減らしていくことがコツです。また変動する値をDataStoreに全部集めると、見通しがよくなります。
モジュールごとに役割を明確にすると、あとで見返した時はわかりやすいコードになりますし、追加で機能を実装する時もどのように実装したらよいか明確になるメリットがあります。
一応この手の設計の欠点をあげますと「ボタンひとつ追加するだけでも、ViewやPresenterにまたがって処理を追加していくことになるので、コード量が多くなりがちでめんどい」というものがあります。
まあ、冒頭にも書きましたが設計に銀の弾丸はありません。
色々な設計方法を参考にして自分が設計しやすい構成を学んでいきましょう。
わからないことや、おかしい点がありましたらご連絡いただけると嬉しいです。