💣

ClusterScript+MVVMで作るマインスイーパー

2024/12/06に公開


この記事は「Cluster Script Advent Calendar 2024」の6日目です
昨日はinabaさんの「【ベータ機能】ワープアイテムの作り方」でした!
GotandaClusterではお世話になりました!!!!!


こんばんは!!かおもと申します!!!

CCK v2.26.0よりuGUIのButtonがクリックできるようになりました。
画期的な更新でしたので、筆者も色々遊んでみました。
しかし、なにぶんAPIが生っぽいためか、ダラダラと書くとすぐにカオスになります。

そこで、よくあるデザインパターンを模倣すれば、このカオス問題は解決できるだろうか?というのがこの記事の内容です。

対戦相手はマインスイーパーです、よろしくお願いします!
CCK v2.29.0現在でベータ機能利用です。

MVVM的な感じでやると決めた

MVVMは、プログラムの書き方や整理の仕方(デザインパターン)の一種です。
今回はこのデザインパターンを模倣して、マインスイーパーを作ってみます。

まず、MVVMはどういうものなのかを簡単に確認していきます。

MVVMはModel-View-ViewModelの略

MVVMはプログラムを3つ(Model、View、ViewModel)の役割に分割してラクしましょう、というものです。
順番に確認していきます。

Viewはユーザーインターフェース

いわゆる画面です。
MVVMで考える場合、基本的に状態を持たないという特徴があります。例えるなら、

  • ボタンを押されたら、押されたということを他の誰かにそのまま言うだけ。
  • 何かの文字を表示するなら、誰かに言われた文字をそのまま表示するだけ。

ぐらいのスタンスです。
しかしただの丸投げというわけではなく、仕事があります。

上記の例なら、ボタンや文字を何色でどういうフォントで表示するかというのはViewの仕事になります。
また、ボタンが押されたことを知るのもViewの仕事になります。
例えば色を操作するという仕様の場合は、Viewは言われた色で表示する、という内容になったりもします。

またMVVMが使われている所では、Viewを作るための方法が、そのプログラムでメインで使われる言語以外の手段で作られる場合があります。
具体的には、Viewを作るための専用の言語が使われたりします。
そういう意味では、致し方なくViewに分けられているとも言えます。

今回ClusterScriptでマインスイーパーを作りますが、メイン言語がJavaScriptに対しての、UnityのGameObjectでの画面(View)という意味では、案外ちょうど良いかもしれません。

ViewModelはViewの丸投げを全て受ける

一番特徴的で重要なところです。
Viewのための状態を持ち、Viewの入力を受けます。

先のViewの丸投げ先は、このViewModelになります。同じような例で言うと、

  • Viewからボタンを押されたと言われたら、適切な人に作業を依頼する。
  • 作業結果を受け、Viewに適した情報にして持ち、Viewに表示を指示する。

という内容になります。変換するのと状態を保持するのが仕事です。
つまり、ViewModelはViewがどういった内容を表示するか、どういった操作がなされるかを全て知っている必要があります。

ここまでベッタリであれば、ViewとViewModelを分ける意味がないのでは?という考えがよぎります。
しかし、Viewが別手段や別言語で作られている場合があることを思い出す必要があります。
つまりどうしても、この独特なViewの都合を受け止めるやつが別に欲しいのです。
この受け止めるやつがViewModelです。
また、基本的にはViewModelはメインで使われる言語で作られるため、これもまた致し方なく分けられているとも言えます。

今回のClusterScriptでマインスイーパーを作る場合では、ViewModelはJavaScriptで書かれます。
余談ですがこれはかなり重要で、Viewの動きをJavaScriptで操作して確認ができる、つまり間接的にViewのテストが行えることを示しています。

Modelはこれら以外の全て

Modelの方はシンプルです。
ViewとViewModel以外の全てです。

先から続けている例で言うと、

  • ViewModelから依頼された作業を行い、ViewModelに結果を通知する。

だけです。
しかし、プログラムの分量はModelが一番多いです。

ViewModelも多少の変換はするものの、基本的に丸投げです。
つまり、このプログラムの本質的な処理は結果的にModelに集まることになります。
マインスイーパーの例では、ゲームのルールは全てここに書かれます。

しかしここでまた、ViewModelの変換や保持をModelが引き受けてもいいのでは?ViewModelとModelをまとめてもよいのでは?という疑問もよぎります。
この疑問に関してはViewとViewModelの時と違い、くっつけてよい場合はあると思います。
小規模プログラムの場合は、ViewとModelだけの方がよかったりすると思います。
今回のマインスイーパーの場合、分けるほどではなさそうですが、あえて分けています。

ここでさらに、あえて分けた場合のModelについて考えてみます。
ViewModelはViewの都合を引き受けているものでした。
つまり、ModelはViewの都合以外のものとも言えそうです。

いざ実装!

ここまででMVVMについてざっと確認しました。
これより実際にMVVM的思想でClusterScriptでマインスイーパーを実装していきます。

実装したソースコードはこちら

筆者はViewから考えるのが好きなので、Viewから行きます。
今回のマインスイーパーは、PlayerScriptの方で作ります。

Viewの範囲をどうするかという問題

早速ですが、これは大きな問題です。
例えばMVVMが適用されるWPFはViewが独特で、意図的にMVVMを強制されるようなフレームワークです。

しかし、今回のClusterScriptでの場合は違います。強制されるViewはありません。
しいて言うならUnityのGameObjectですが、実態はClusterScriptのUnityComponentで、言語にいたってはメインの言語であるJavaScriptから操作できます。
極論すればViewを分ける必要すらないとも言えてしまいます。
(余談ですが、これがカオスになる原因と考えています)

しかしよくよく考えてみると、ClusterScriptのUnityComponentはJavaScriptで操作できますが、いわゆる純粋なJavaScriptには存在していない機能です。
さらに、ここで思い出して欲しいことがあります。
それはViewModelがメイン言語でViewを受け止めるということです。

ここでViewを分けられる一つの可能性に思い至れます。
ClusterScript独自の機能と純粋なJavaScriptの機能とで、ViewとViewModelを分ける可能性です。
つまりViewをClusterScriptで記述し、ViewModelを純粋なJavaScriptで記述するとします。
今回はこれで行きます。

ViewにClusterScriptを押し込む

つまり具体的にはこういうことになります。

const localObject = _.playerLocalObject("Start");
const start = localObject.getUnityComponent("Button");
start.onClick((isDown) => {
    //ここでViewModelに丸投げする
});
const localObject = _.playerLocalObject("Message");
const message = localObject.getUnityComponent("Text");
message.unityProp.text = "ViewModelに言われた文字をそのまま表示する";

上手に押し込めています。
あとは状態を持たないようにすることと、ClusterScript独自機能をViewModelに出さないようにすることに注意して進めていけばOKです。

ViewModelに指示をするコマンド

ここからは、ViewModelとすり合わせていく必要があるので、ViewModelの実装も進めていきます。
Viewは丸投げ受け身の姿勢なので、ViewModelの方を重視して進めます。

まずはViewからViewModelに操作を丸投げする場合を考えます。これは簡単です。
単にViewModelにViewからアクセスできるメソッドを生やすだけです。

ViewModel

const ViewModel = () => {
    //classみたいなものだと思って下さい(モジュールパターンの応用です)。
    return {
        start(){
            //必要な変換とかをやってModelに投げる
        },
    };
};

View

const viewModel = ViewModel();

const localObject = _.playerLocalObject("Start");
const start = localObject.getUnityComponent("Button");
start.onClick((isDown) => {
    viewModel.start(); //これが丸投げ
});

できました。
本来はこんな感じで十分だと思います。ViewとViewModelが方針通りに分けられています。

さて、少し前に触れたWPFのMVVMでは、こういった時にViewは、ViewModelのICommandを呼び出す、としています。
WPFの世界では半ば強制されてICommandを使用する事になっています。
もちろんClusterScriptの世界にはその様なものはありませんし強制されていません。

しかし、ここではあえてそれを模倣し強制するようにしてみます。
具体的には、以下のようなコマンドをViewModelに追加し、Viewはそれを呼び出すことを強制します。

const C_Command = () => {
    let _command = () => {};
    return {
        execute(){ _command(); },
        set(command){ _command = command; },
    };
};

具体的にはこんな感じで使えます。

const start = C_Command();
start.execute();

これをViewModelに生やしてみます。

ViewModel

const ViewModel = () => {
    let isStarted = false;
    const start = C_Command(); //コマンドを作って
    start.set(() => isStarted = true); //処理内容を決めて
    return {
        start: start, //生やす
    };
};

View

const viewModel = ViewModel();

const localObject = _.playerLocalObject("Start");
const start = localObject.getUnityComponent("Button");
start.onClick((isDown) => {
    viewModel.start.execute(); //ViewModelのCommandに丸投げする
});

これでViewからViewModelに処理を投げるところは完了です。
ただ記述量が増えて面倒になっただけじゃないか、という話はあると思いますが、Viewを作る時にうまくすり合わせると楽になります。

ViewとViewModelを分けつつ繋ぐ

次に、ViewModelから言われた文字を、Viewがそのまま表示する場合を考えます。
これはだいぶややこしいです。

例えば、

ViewModel

view.setMessage("start");

View

const setMessage = (newMessage) => {
    const localObject = _.playerLocalObject("Message");
    const message = localObject.getUnityComponent("Text");
    message.unityProp.text = newMessage;
};

これはNGです。
setMessageを通じ、ClusterScriptの独自機能(unityProp.text)をViewModelが触ることを避けられません。
言い換えるとViewModelがsetMessageを実行するのに、Viewが必須になっています。
ViewModelにとってViewが必須ということは、それはつまりClusterScriptの独自機能を抱えているのと同義で、ViewModelからClusterScriptを分けられていません

こういう場合は、コールバックがお手軽なのでこれで行きます。

ViewModel

const ViewModel = () => {
    const start = C_Command();
    let onMessageUpdatedCallback = () => {};

    start.set(() => {
        onMessageUpdatedCallback("start");
    });
    return {
        onMessageUpdated(callback){
            //コールバックを登録する。
            onMessageUpdatedCallback = callback; 
        },
        start: start,
    };
};

View

const viewModel = ViewModel();

const localObject = _.playerLocalObject("Message");
const message = localObject.getUnityComponent("Text");
//ViewModelにコールバックを登録する。
viewModel.onMessageUpdated((newMessage) => {
    //ViewModelから通知されたメッセージをそのまま表示する
    message.unityProp.text = newMessage;
});

//ViewModelに処理を投げると、
//メッセージ更新の通知が来てmessage.unityProp.textが更新される。
viewModel.start.execute();

ViewModelがViewから分けられているか、ソースコードを確認してみます。
ViewModel内のonMessageUpdatedCallbackを実行する時に、Viewが登場していません。
実際動かしてみると分かるのですが、これはViewがなくともViewModelを動かせます。

ちょっと横道にそれますが、なんならView以外からでもViewModelを動かせます。
例えば、

const viewModel = ViewModel();

let actual = "";
viewModel.onMessageUpdated((newMessage) => {
    actual = newMessage;
});
viewModel.start.execute();
console.log(`メッセージはstartになっているか?${actual === "start"}`);

というテストもできます。
こういうテストができるのは地味に大きな利点です。

本筋に戻ります。

先の実装について、Viewからコールバックを登録した場合、ViewModelは間接的にClusterScriptの独自機能を実行しているのでは?つまり分けられていないのでは?という疑問もよぎると思います。
この疑問については、スッキリしないかもしれないのですが、こう考えてみて下さい。
ViewModelの動作にView(=ClusterScript)が「必須」でなければ分けられている。

中々にトリッキーだとは思うのですが、なんとか飲み込んで下さい。
筆者はこの感覚が脳に染み渡る?まで何日か掛かった記憶があります。
「依存性の逆転」で検索すると参考になるかもしれません。

ViewとViewModelを繋ぐデータバインディング

さて、これでViewとViewModelを方針通りに分けられました。
実際これで十分だとは思います。
(だいぶ面倒ではあるので、改良の余地はありますが。)

しかしコマンドの時のように、ここでまた特殊な実装を入れます。
前に触れたWPFのMVVMでは、ViewがViewModelの値を表示するような場合、データバインディングという機能を用います。
これもコマンドの時と同じく、半ば強制されて使用する事になっています。
しかし、ここでもあえてそれを模倣し強制するようにしてみます。

データバインディングとは、名前の通り互いのデータを結びつけるような機能です。
とはいえ今回は、結び付けられているViewModelの値を変更したら、View側の値が勝手に変更されて表示も変わる機能、ぐらいのイメージでOKです。
つまり今回は、先程のコールバックでの実装とさほど変わらないと思ってOKです。

ソースコード的にも、先程のViewModelのコールバックの実装を、以下のようなデータバインディング用のプロパティとして置き換える程度です。

const C_ChangeableProperty = () => {
    let value;
    const valueChanged = C_Event();
    return {
        get(){ return value; },
        set(_value){
            if(value === _value) return;
            value = _value;
            valueChanged.invoke(value);
        },
        setListener(listener){
            valueChanged.set(listener);
            listener(value);
        },
    };
};

コールバック系の実装がC_ChangeablePropertyに吸収されてスッキリします。

ViewModel

const ViewModel = () => {
    const start = C_Command();
    const message = C_ChangeableProperty(); //バインド用のプロパティを準備して

    start.setCommand(() => {
        message.set("start"); //setすると自動的に通知が行く。
    });
    message.set("");

    return { //この辺がスッキリ。
        message: message, //生やす
        start: start,
    };
};

View側はやることはほとんど変わらないので省略します。

ここまでくればViewは簡単

ここまでのViewModelの実装で、Viewが「状態を持たないように」「ClusterScriptの独自機能が漏れないように」実装できるようになっています。
あとはViewもスッキリと書けるように工夫するだけです。

どの様な工夫がされているかは、実際のソースコードを確認いただいたほうが早いと思いますので省略します。
今回は、ClusterScriptのUnityComponentを、ViewModelと繋ぎやすくなるように、JavaScriptで新たなComponentとして定義しなおす、という方針で工夫しています。

Modelの機能をどう決定するか

先述していますが、ModelはViewの都合以外のモノ全部でした。
つまりMVVMとして考えるだけなら、ここから先の方針は何もないとも言えます。

とはいえ、せっかくの記事なので、実際にModelを書く時に何を考えていたかを書いていきます。

Viewの都合以外のモノ全部ということならば、まずViewの都合を挙げていきます。

  • UI系GameObjectのヒエラルキーがどうなっているか。
  • UnityComponentのAPIがどうなっているか。

他にもあるかもしれませんが、今回は2つしか出せませんでした。余談ですが筆者はこの手の作業が得意ではありません。
それぞれ具体的に考えていきます。


UI系GameObjectのヒエラルキーがどうなっているか。

今回のUIのヒエラルキーまわりはこうなっています。

例えば、

  • 旗を立てる時は 「Status」のImage(右の画像)を移動させて旗を表示する
  • Pickelを使っている時は、子の「On」を表示「Off」を非表示にする

といったことがViewの都合です。

ViewModelはこのViewのヒエラルキーの都合をそのまま受け止め、それぞれ例えば「Status」や、Pickel用の「On」「Off」それぞれに表示非表示のプロパティを持っています。

const VM_Cell = () => {
    const count = VM_TextProperty();
    const button = VM_Command();
    const status = VM_VisibleProperty();
    const statusTransform = VM_RectTransformProperty();
///////////////////////中略//////////////////////////
const VM_Tools = () => {
    const pickel = {
        button: VM_Command(),
        on: VM_VisibleProperty(),
        off: VM_VisibleProperty(),
    };

Modelはこのプロパティ構成に影響されない、というイメージです。
結果以下のようになりました。

  • 旗はonになったかoffになったかを伝えるのみ。
  • ツールにはピッケルと旗の2種類があって、どのツールになったかを伝えるのみ。

そして以下のようなコードになりました。

onFlagChanged(listener){
    flagChangedEvent.set(listener);
    listener(hasFlag);
},
///////////////////////中略//////////////////////////
onToolChanged(listener){
    tool.setListener(listener);
}

Modelらしく?スッキリとし、ゲームのコアっぽくなったのではないでしょうか。
ViewModelはこれらの通知を受け変換を行うのが仕事となります。


UnityComponentのAPIがどうなっているか。

これは例えば、

  • セルのButtonを押した時は、UnityComponent.onClickのコールバックで処理する。
  • 残り爆弾数を表示する時は、UnityComponent.unityProp.textに文字を指定する。

などがView側の都合になります。
しかし、今回の実装ではこれらのUnityComponentの都合については、View側で閉じ込めてあります。

なので、Model側では具体的な処理の意味を考え、クリックされたとか、テキストを表示するといったUnityComponent寄りの考えから脱却させます。
結果以下のようになりました。

  • セルに対して現在のツールを使う。ピッケルなら掘る、旗なら立てる。
  • 旗を立てた時に、残り爆弾数を数値として伝える

コードにすると以下のようになりました。

useTool(cell){
    if(tool.get() === M_ToolKind.flag) cell.toggleFlag();
    if(tool.get() === M_ToolKind.pickel) cell.dig();
},
///////////////////////中略//////////////////////////
cell.onFlagChanged(f => {
    numberOfFlags += (f ? 1 : -1);
    applyLeft(); //ここのleftは残りの意味。
});
const applyLeft = () => {
    left.set(numberOfBombs - numberOfFlags); //setのタイミングで通知される。
};

ViewModel側のコードは以下の通りです。
buttonの処理をuseToolに変換し、爆弾数の数値を文字に変換している様子が分かります。

cells.foreach((vmc, x, y) => {
    const mc = model.cells.getCell(x, y);
    vmc.button.command.set(() => model.tool.useTool(mc));
});
///////////////////////中略//////////////////////////
model.cells.left.setListener(v => left.text.value = `${v}`);

今回はこのようなことを考えながらModelの機能を決定し、実装を進めました。

少し上で触れていますが、筆者はこの作業が楽しくはあるのですが、上手でも得意でもありません。
このあたりの作り方は、「トランザクションスクリプト」だとか「ドメインモデル」だとかあるようなのですが、筆者はこれに連戦連敗しており未だ理解に及んでいません。誰か助けて下さい!!

感想や課題や展望

まとめです。

冒頭にあったこれ、

しかし、なにぶんAPIが生っぽいためか、ダラダラと書くとすぐにカオスになります。
そこで、巷で見かけるデザインパターンを適用すれば、このカオス問題は解決できるだろうか?というのがこの記事の内容です。

は解決できたように思います!!!!!
ソースコードの全文はこちら

以下、色々個別に振り返っていきます。

結構気持ち良く書けたよね

特にModel内のロジックの見通しが良いです。ここしばらく味わっていなかった気持ちよさです。
セル(M_Cell)1個のロジックはこれ、セルのまとまり(M_Cells)としてのロジックはこれ、ゲーム全体(M_Game)のロジックはこれ、という具合に、それぞれの階層で綺麗にまとまりとても気持ちよく書けました。

ViewとViewModelもかなり気持ち良かったです。コピペで増やせそうな感じが良いですね。
JSONで構築するとか、エディタ拡張とソースジェネレーターの合せ技で独自言語で記述するとか、そんな未来も見えそうです。

初期化時の負荷が想像以上に高い

細かく計測できていないのですが、ViewとViewModelの初期化が重かったです。
どちらも初期化すべきコンポーネントとプロパティの数が多いためと思われます。
筆者の環境で500ms制限に掛かるので、16x16にしました。

このあたりの負荷分散の仕組み構築が課題です。
_.onFrameに入れて回せるようにするか、遅延初期化あたりになるでしょうか。

ClusterScriptの他APIにも対応できるのか?

いくつか考える点があるように思います。

  • 例えば、_.setPosition()や_.getPosition()はどうするか。
  • ScriptableItem側では動くか。
  • Vector3あたりのクラスはどうするか。

_.setPosition()や_.getPosition()はどうするか。

これはどちらもView相当で行けそうな気がしています。

_.setPosition()は、3D空間への出力であるという意味で。
_.getPosition()も、プレイヤーの操作の結果(入力)という意味で。
ただここまで意味を拡張?するとViewではなく、別の名前が欲しくなります。

毎フレーム_.setPosition()をするような場合、それをModelにするかどうかは検討したほうが良さそうです。

ScriptableItem側では動くか。

これは少し工夫が必要に思います。

まず、ScriptableItemはownerの概念があったり、5分に1回のリロードなどもあるので、状態の保持が課題となると思っています。
ScriptableItemの状態保持ということは$.stateの出番ですが、ClusterScriptの独自機能です。
ですので、Modelの裏というか内部に$.stateを押し込む何か(永続化用のRepository的な何か?)を用意する感じになるでしょうか。
Handle系を保持したい場合はかなり工夫が必要なように思います。

Vector3あたりのクラスはどうするか。

独自のVector3を再実装するあたりが妥当でしょうか。

この場合、Viewで変換してViewModelに出すことになりそうです。
ClusterScriptのVector3周辺の関数を使えないのが悩ましいです。

多層UIや複数画面に対応できるのか?

できそうですが、初期化の問題に相当に注意する必要があるかと思います。

全UIを一斉に完全に初期化すると、500ms制限で止まりそうに思います。
Viewあたりから設計を見直す必要がありそうです。

MVVMを使わずに作ったら実際どうなるのか?

この手の宿命として冗長感は否めないので、今回のコード量は多くなってしまっただろうなぁという気持ちがあります。
切り分け次第では、かなりシンプルに短く書けそうな気がしています。
誰か試してくれないかなぁ!!

今回のソースコード

せっかくなのでMITライセンスつけてみました!!かっこいい!!
定義関係も丁寧にしているので、VSCodeでのコード補完もチェックもバチバチに効きます!!!
よかったらご覧ください!!!!

実装したUnityのSceneを配布しています。
clusterのワールドとしても公開しています。

ソースコード 1000行あるので注意
// MVVMMineSweeper
// MIT License
// Copyright © 2024 kaomo https://vkao.booth.pm/
// https://opensource.org/licenses/mit-license.php

// @ts-check
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// Model-View-ViewModelにおいて、共通して使われる機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/**
 * イベントを扱う機能。
 * @template T
 * @typedef {Object} C_Event
 * @property {(listener: T) => void} set
 * @property {T} invoke
 * 
 * @returns {C_Event<any>}
 */
const C_Event = () => {
    const listeners = [];
    const invoke = (...args) => {
        for(let l of listeners){
            l.apply(null, args);
        }
    };
    return Object.freeze({
        set(listener){
            listeners.push(listener);
        },
        invoke(...args){
            invoke.apply(null, args);
        },
    });
};

/**
 * C_Event用のイベントリスナ
 * @callback VoidListener
 * @returns {void}
 */
/**
 * C_Event用のイベントリスナ
 * @template T
 * @callback Listener1<T>
 * @param {T} value1
 * @returns {void}
 */
 /**
 * C_Event用のイベントリスナ
 * @template T1, T2
 * @callback Listener2<T1,T2>
 * @param {T1} value1
 * @param {T2} value2
 * @returns {void}
 */
 /**
 * C_Event用のイベントリスナ
 * @template T1, T2, T3
 * @callback Listener3<T1,T2,T3>
 * @param {T1} value1
 * @param {T2} value2
 * @param {T3} value3
 * @returns {void}
 */

/**
 * 値が変更されたらそれを通知できる機能。
 * @template T
 * @typedef {Object} C_ChangeableProperty
 * @property {T} value
 * @property {(listener: (value: T) => void) => void} onChanged
 * 
 * @returns {C_ChangeableProperty<any>}
 */
const C_ChangeableProperty = () => {
    let value;
    const valueChanged = C_Event();
    return Object.freeze({
        get value() { return value; },
        set value(v) {
            if(value === v) return;
            value = v;
            valueChanged.invoke(value);
        },
        onChanged(listener){
            valueChanged.set(listener);
            listener(value);
        },
    });
};

/**
 * 処理を指示する機能。
 * @typedef {Object} C_Command
 * @property {() => void} execute
 * @property {C_ChangeableProperty<boolean>} canExecute
 * @property {(command: () => void) => void} set
 * 
 * @returns {C_Command}
 */
const C_Command = () => {
    /** @type {C_ChangeableProperty<boolean>} */
    const canExecute = C_ChangeableProperty();
    let _command = () => {};
    canExecute.value = true; //デフォルト
    return Object.freeze({
        execute(){ _command(); },
        canExecute: canExecute,
        set(command){ _command = command; },
    });
};

/**
 * @template T
 * @typedef {(op: (cell: T, x: number, y: number) => void) => void} CellForeach
 */
/** 
 * @template T
 * @param {number} w
 * @param {number} h
 * @param {T[][]} cells
 * @returns {CellForeach<any>}
 */
const CellForeach = (w, h, cells) => {
    return (op) => {
        for(let x = 0; x < w; x++){
            for(let y = 0; y < h; y++){
                op(cells[x][y], x, y);
            }    
        }
    };
};

//負荷分散用の急ごしらえの実装。
const OnFrame = () => {
    let ops = [];
    let op = null;
    let args = null;
    let running = false;
    _.onFrame((dt) => {
        if(op == null){
            if(ops.length > 0) op = ops.shift();
            return;
        }
        if(running){
            ops.length = 0;
            op = null;
            running = false;
            return;
        }
        running = true;
        args = op.apply(null, [dt, ...(Array.isArray(args) ? args : [args])]);
        running = false;
        if(args != null) op = null;
    });
    return {
        push(op){ ops.push(op); },
        shift(op){ ops.unshift(op); },
    }
};


////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// ViewModelにおいて、共通して使われる機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/**
 * 表示非表示を扱えるプロパティ。
 * @typedef {Object} VM_VisibleProperty
 * @property {C_ChangeableProperty<boolean>} visible
 * 
 * @returns {VM_VisibleProperty}
 */
const VM_VisibleProperty = () => {
    const property = Object.freeze({
        visible: C_ChangeableProperty(),
    });
    property.visible.value = true; //デフォルト

    return property;
};

/**
 * RectTransformを扱えるプロパティ。
 * 今のところAnchordPositionのみ。
 * @typedef {Object} VM_RectTransformProperty
 * @property {VM_RectTransformProperty_AnchordPosition} anchordPosition
 * 
 * @typedef {Object} VM_RectTransformProperty_AnchordPosition
 * @property {number} x readonly
 * @property {number} y readonly
 * @property {(x: number, y: number) => void} set
 * @property {(listener: (x: number, y: number) => void) => void} onChanged
 * 
 * @returns {VM_RectTransformProperty}
 */
const VM_RectTransformProperty = () => {
    const property = {};

    let anchordPositionX = 0;
    let anchordPositionY = 0;
    /** @type {C_Event<Listener2<number, number>>}  */
    const anchordPositionChangedEvent = C_Event();
    const invokeAnchordPositionChanged =
        () => anchordPositionChangedEvent.invoke(anchordPositionX, anchordPositionY);
    const anchordPosition = Object.freeze({
        get x() { return anchordPositionX; },
        get y() { return anchordPositionY; },
        set x(v) { anchordPositionX = v; invokeAnchordPositionChanged(); },
        set y(v) { anchordPositionY = v; invokeAnchordPositionChanged(); },
        set(x, y){
            anchordPositionX = x;
            anchordPositionY = y;
            invokeAnchordPositionChanged();
        },
        onChanged(listener){
            anchordPositionChangedEvent.set(listener);
            listener(anchordPositionX, anchordPositionY);    
        },
    });

    property.anchordPosition = anchordPosition;
    return property;
};

/**
 * テキストを扱えるプロパティ。
 * @typedef {Object} _VM_TextProperty
 * @property {C_ChangeableProperty<string>} text
 * 
 * @typedef {VM_VisibleProperty & _VM_TextProperty} VM_TextProperty
 * 
 * @returns {VM_TextProperty}
 */
const VM_TextProperty = () => {
    const property = Object.freeze({
        text: C_ChangeableProperty(),
    });
    return {...VM_VisibleProperty(), ...property};
};

/**
 * 押すことのできるボタン。
 * @typedef {Object} VM_Command_Command
 * @property {C_Command} command
 * 
 * @typedef {VM_Command_Command & VM_VisibleProperty} VM_Command
 * 
 * @returns {VM_Command}
 */
const VM_Command = () => {
    const command = C_Command();
    return Object.freeze({
        command: command,
        ...VM_VisibleProperty()
    });
};


////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// Viewにおいて、共通して使われる機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/**
 * PlayerLocalObjectからUnityComponentまでのpathを規定して解析する
 * @typedef {Object} V_ParsePath_return
 * @property {string[]} hierarchy
 * @property {string} component
 * 
 * @param {string} path
 * @returns {V_ParsePath_return}
 */
const V_ParsePath = (path) => {
    const pc = path.split("|");
    const ps = pc[0].split("/");
    return { hierarchy: ps, component: pc[1] };
};

/**
 * pathで指定したPlayerLocalObjectを取得する。UnityComponent分は無視される。
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {PlayerLocalObject}
 */
const V_FindObject = (path, baseLocalObject) => {
    const parsed = V_ParsePath(path);
    let localObject = baseLocalObject ?? _.playerLocalObject(parsed.hierarchy.shift()??"");
    for(let p of parsed.hierarchy){
        localObject = localObject?.findObject(p)??null;
    }
    if(localObject == null) throw new Error(`${path}が見つからない`);
    return localObject;    
};

/**
 * pathで指定したUnityComponentを取得する。
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {UnityComponent}
 */
const V_FindComponent = (path, baseLocalObject) => {
    const parsed = V_ParsePath(path);
    const localObject = V_FindObject(path, baseLocalObject);
    const unityComponent = localObject.getUnityComponent(parsed.component);
    if(unityComponent == null) throw new Error(`${path}が見つからない`);
    return unityComponent;    
};

/**
 * 表示非表示ができるコンポーネント。
 * @typedef {Object} V_VisibleComponent
 * @property {(viewModelProperty: VM_VisibleProperty) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {V_VisibleComponent}
 */
const V_VisibleComponent = (path, baseLocalObject) => {
    const localObject = V_FindObject(path, baseLocalObject);
    return {
        bindViewModel(viewModelProperty){
            viewModelProperty.visible.onChanged((isVisible) => {
                localObject.setEnabled(isVisible);
            });
        },
    };
};

/**
 * コンポーネントの有効無効を表示非表示で制御するコンポーネント。
 * @typedef {Object} V_EnableComponent
 * @property {(viewModelProperty: VM_VisibleProperty) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {V_EnableComponent}
 */
const V_EnableComponent = (path, baseLocalObject) => {
    const unityComponent = V_FindComponent(path, baseLocalObject);
    return {
        bindViewModel(viewModelProperty){
            viewModelProperty.visible.onChanged((isVisible) => {
                unityComponent.unityProp.enabled = isVisible;
            });
        },
    };
};

/**
 * RectTransformコンポーネント。
 * @typedef {Object} V_RectTransformComponent
 * @property {(viewModelProperty: VM_RectTransformProperty) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {V_RectTransformComponent}
 */
const V_RectTransformComponent = (path, baseLocalObject) => {
    const unityComponent = V_FindComponent(path, baseLocalObject);
    return {
        bindViewModel(viewModelProperty){
            viewModelProperty.anchordPosition.onChanged((x, y) => {
                unityComponent.unityProp.anchoredPosition = new Vector2(x, y);
            });
        },
    };
};


/**
 * Textコンポーネント。
 * @typedef {Object} V_TextComponent
 * @property {(viewModelProperty: VM_TextProperty) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {V_TextComponent}
 */
const V_TextComponent = (path, baseLocalObject) => {
    const unityComponent = V_FindComponent(path, baseLocalObject);
    const visibleComponent = V_VisibleComponent(path, baseLocalObject);
    return {
        bindViewModel(viewModelProperty){
            visibleComponent.bindViewModel(viewModelProperty);
            viewModelProperty.text.onChanged((text) => {
                unityComponent.unityProp.text = text;
            });
        },
    };
};

/**
 * Buttonコンポーネント。
 * @typedef {Object} V_ButtonComponent
 * @property {(viewModelProperty: VM_Command) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject?} baseLocalObject
 * @returns {V_ButtonComponent}
 */
const V_ButtonComponent = (path, baseLocalObject) => {
    const unityComponent = V_FindComponent(path, baseLocalObject);
    const visibleComponent = V_VisibleComponent(path, baseLocalObject);
    return {
        bindViewModel(viewModelProperty){
            visibleComponent.bindViewModel(viewModelProperty);
            unityComponent.onClick((isDown) => {
                if(isDown) viewModelProperty.command.execute();
            });
            viewModelProperty.command.canExecute.onChanged((canExecute) => {
                unityComponent.unityProp.interactable = canExecute;
            });
        },
    };
};


////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// Modelにおいて、このゲーム特有の機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/** 
 * @typedef {Object} M_ToolKind
 * @property {string} pickel
 * @property {string} flag
 * 
 * @enum {M_ToolKind}
 */
const M_ToolKind = Object.freeze({
    pickel: "pickel",
    flag: "flag",    
});

/**
 * セルに何かをするツール。
 * @typedef {Object} M_Tool
 * @property {() => void} usePickel
 * @property {() => void} useFlag
 * @property {(cell: M_Cell) => void} useTool
 * @property {() => void} restart
 * @property {(listener: (tool: keyof M_ToolKind) => void) => void} onToolChanged
 * 
 * @returns {M_Tool}
 */
const M_Tool = () => {
    /** @type {C_ChangeableProperty<keyof M_ToolKind>} */
    const tool = C_ChangeableProperty();
    tool.value = M_ToolKind.pickel;

    return {
        usePickel(){
            tool.value = M_ToolKind.pickel;
        },
        useFlag(){
            tool.value = M_ToolKind.flag;
        },
        useTool(cell){
            if(tool.value === M_ToolKind.flag) cell.toggleFlag();
            if(tool.value === M_ToolKind.pickel) cell.dig();
        },
        restart(){
            tool.value = M_ToolKind.pickel;
        },
        onToolChanged(listener){
            tool.onChanged(listener);
        }
    };
};

/**
 * @typedef {Object} M_CellStatus
 * @property {string} flat,
 * @property {string} dig,
 * @property {string} bomb,
 * @property {string} flag,
 * @property {string} among,
 * 
 * @enum {M_CellStatus}
 */
const M_CellStatus = Object.freeze({
    flat: "flat",
    dig: "dig",
    bomb: "bomb",
    flag: "flag",
    among: "among",
});

/**
 * セル。
 * @typedef {Object} M_Cell
 * @property {() => void} buryBomb 爆弾を埋める
 * @property {() => void} addAroundBomb 周囲にあるとされている爆弾の個数を増やす
 * @property {() => void} toggleFlag
 * @property {() => void} dig
 * @property {() => void} end
 * @property {() => void} restart
 * @property {(listener: () => void) => void} onBombBuried 爆弾を埋めることができた場合に通知する。
 * @property {(listener: (count: number) => void) => void} onAroundBombsChanged 周囲にあるとされている爆弾の個数が変更された場合に通知する。
 * @property {(listener: () => void) => void} onAroundDigRequired 周囲も自動でdigできることを通知する。
 * @property {(listener: (status: keyof M_CellStatus) => void) => void} onStatusChanged
 * @property {(listener: (hasFlag: boolean) => void) => void} onFlagChanged
 * @property {(listener: () => void) => void} onBombHit
 * @property {(listener: (hasBuriedBomb: boolean, isMistake: boolean) => void) => void} onCellEnded
 * @property {(listener: () => void) => void} onCellRestarted
 * 
 * @returns {M_Cell}
 */
const M_Cell = () => {
    /** @type {C_Event<Listener1<keyof M_CellStatus>>}  */
    const statusChangedEvent = C_Event();
    /** @type {C_Event<VoidListener>}  */
    const bombHitEvent = C_Event();
    /** @type {C_Event<Listener2<boolean, boolean>>}  */
    const cellEndedEvent = C_Event();
    /** @type {C_Event<VoidListener>}  */
    const cellRestartedEvent = C_Event();
    /** @type {C_Event<Listener1<boolean>>}  */
    const flagChangedEvent = C_Event();
    /** @type {C_Event<VoidListener>}  */
    const bombBuriedEvent = C_Event();
    /** @type {C_Event<VoidListener>}  */
    const aroundDigRequiredEvent = C_Event();

    /** @type {C_ChangeableProperty<number>} */
    const aroundBombCount = C_ChangeableProperty();

    let isEnd = false;
    let hasBomb = false;
    let hasFlag = false;
    let isDigged = false;

    const initialize = () => {
        isEnd = false;
        hasBomb = false;
        hasFlag = false;
        isDigged = false;
        aroundBombCount.value = 0;
    };
    /** @returns {keyof M_CellStatus} */
    const getStatus = () => {
        if(isDigged){
            //掘ったら爆弾があるイメージ
            if(hasBomb) return M_CellStatus.bomb;
            if(aroundBombCount.value > 0) return M_CellStatus.among;
            return M_CellStatus.dig;
        }
        if(hasFlag) return M_CellStatus.flag;
        return M_CellStatus.flat;
    };
    const invokeStatusChanged = () => statusChangedEvent.invoke(getStatus());

    //操作がミスであったことを判定する
    const isMistake = () => {
        return (hasFlag && !hasBomb) || (isDigged && hasBomb);
    };

    return {
        buryBomb(){
            if(hasBomb) return false;
            hasBomb = true;
            bombBuriedEvent.invoke();
        },
        addAroundBomb(){
            aroundBombCount.value += 1;
        },
        toggleFlag(){
            if(isEnd) return;
            if(isDigged) return;
            hasFlag = !hasFlag;
            invokeStatusChanged();
            flagChangedEvent.invoke(hasFlag);
        },
        dig(){
            if(isEnd) return;
            if(isDigged) return;
            if(hasFlag) return;
            isDigged = true;
            invokeStatusChanged();
            if(hasBomb){
                bombHitEvent.invoke();
            }
            if(aroundBombCount.value === 0){
                aroundDigRequiredEvent.invoke();
            }
        },
        end(){
            isEnd = true;
            const hasBuriedBomb = !hasFlag && hasBomb && !isDigged;
            cellEndedEvent.invoke(hasBuriedBomb, isMistake());
        },
        restart(){
            initialize();
            invokeStatusChanged();
            cellRestartedEvent.invoke();
        },
        onBombBuried(listener){
            bombBuriedEvent.set(listener);
        },
        onAroundBombsChanged(listener){
            aroundBombCount.onChanged(listener);
        },
        onAroundDigRequired(listener){
            aroundDigRequiredEvent.set(listener);
        },
        onStatusChanged(listener){
            //statusをC_ChangeablePropertyにしてもいいと思う
            statusChangedEvent.set(listener);
            listener(getStatus()); //値を通知するタイプのものは登録時にも返す
        },
        onFlagChanged(listener){
            //hasFlagはC_ChangeablePropertyでもいいと思う
            flagChangedEvent.set(listener);
            listener(hasFlag);
        },
        onBombHit(listener){
            bombHitEvent.set(listener);
        },
        onCellEnded(listener){
            cellEndedEvent.set(listener);
        },
        onCellRestarted(listener){
            cellRestartedEvent.set(listener);
        },
    }
};

/**
 * セルのあつまり。
 * テストの時にrandomを上書きして制御できる。
 * @typedef {Object} M_Cells
 * @property {C_ChangeableProperty<number>} left 想定残り爆弾数。
 * @property {(x: number, y: number) => M_Cell} getCell
 * @property {CellForeach<M_Cell>} foreach
 * @property {() => void} restart
 * 
 * @param {number} w セルの横の数
 * @param {number} h セルの縦の数
 * @param {number} maxOfBomb 埋める爆弾数
 * @param {() => number} random 0-1の乱数を生成する関数
 * @returns {M_Cells}
 */
const M_Cells = (w, h, maxOfBomb, random) => {
    /** @type {M_Cell[][]} */
    const cells = [];
    /** @type {C_ChangeableProperty<number>} */
    const left = C_ChangeableProperty();
    let numberOfBombs;
    let numberOfFlags;

    const buildCells = () => {
        for(let x = 0; x < w; x++){
            cells[x] = [];
            for(let y = 0; y < h; y++){
                cells[x][y] = M_Cell();
            }    
        }    
    };
    /** @type {CellForeach<M_Cell>} */
    const foreach = CellForeach(w, h, cells);

    const buryBombs = () => {
        while(numberOfBombs < maxOfBomb){
            const x = Math.floor(random() * w);
            const y = Math.floor(random() * h);
            cells[x][y].buryBomb();
        }    
    };

    const applyLeft = () => {
        left.value = numberOfBombs - numberOfFlags;
    };

    const restart = () => {
        foreach((cell) => {
            cell.restart();
        });
        initialize();
    };

    const initialize = () => {
        numberOfBombs = 0;
        numberOfFlags = 0;
        applyLeft();
        buryBombs();
    };

    buildCells();
    foreach((cell, x, y) => {
        cell.onBombBuried(() => {
            numberOfBombs++;
            applyLeft();
            //爆弾が埋められたなら、周囲のセルに爆弾が埋められたことを通知する。
            if(x !== 0  ) cells[x-1][y].addAroundBomb();
            if(x !== w-1) cells[x+1][y].addAroundBomb();
            if(y !== 0  ) cells[x][y-1].addAroundBomb();
            if(y !== h-1) cells[x][y+1].addAroundBomb();
            if(x !== 0   && y !== 0  ) cells[x-1][y-1].addAroundBomb();
            if(x !== 0   && y !== h-1) cells[x-1][y+1].addAroundBomb();
            if(x !== w-1 && y !== 0  ) cells[x+1][y-1].addAroundBomb();
            if(x !== w-1 && y !== h-1) cells[x+1][y+1].addAroundBomb();
        });
        cell.onAroundDigRequired(() => {
            //周囲のセルも自動でdigする。
            if(x !== 0  ) cells[x-1][y].dig();
            if(x !== w-1) cells[x+1][y].dig();
            if(y !== 0  ) cells[x][y-1].dig();
            if(y !== h-1) cells[x][y+1].dig();
        });
        cell.onFlagChanged(f => {
            numberOfFlags += (f ? 1 : -1);
            applyLeft();
        });
        cell.onBombHit(() => {
            foreach((_cell) => _cell.end());
        });
    });

    return {
        left: left,
        getCell(x, y){ return cells[x][y]; },
        foreach(op){ foreach(op); },
        restart(){ restart(); },
    }
};

/**
 * ゲーム本体。
 * テストの時にrandomを上書きして制御できる。
 * @typedef {Object} M_Game
 * @property {M_Cells} cells
 * @property {M_Tool} tool
 * @property {() => void} restart
 * @property {() => void} start
 * @property {(listener: () => void) => void} onStarted
 * 
 * @param {number} w セルの横の数
 * @param {number} h セルの縦の数
 * @param {number} maxOfBomb 埋める爆弾数
 * @param {() => number} random 0-1の乱数を生成する関数
 * @returns {M_Game}
 */
const M_Game = (w, h, maxOfBomb, random) => {
    /** @type {C_Event<VoidListener>}  */
    const startedEvent = C_Event();

    const cells = M_Cells(w, h, maxOfBomb, random);
    const tool = M_Tool();

    const initialize = () => {
        cells.restart();
        tool.restart();
    };

    return {
        cells: cells,
        tool: tool,
        restart(){
            initialize();
        },
        start(){
            initialize();
            startedEvent.invoke(); //ここからのイベント伝播が実質初期化になる
        },
        onStarted(listener){
            startedEvent.set(listener);
        }
    }
};

////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// ViewModelにおいて、このゲーム特有の機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/**
 * @typedef {Object} VM_Cell
 * @property {VM_TextProperty} count
 * @property {VM_Command} button
 * @property {VM_VisibleProperty} status
 * @property {VM_RectTransformProperty} statusTransform
 * @property {(status: keyof M_CellStatus) => void} setStatus
 * @property {(count: number) => void} setCount
 * @property {(hasBuriedBomb: boolean, isMistake: boolean) => void} end
 * @property {() => void} restart
 * 
 * @returns {VM_Cell}
 */
const VM_Cell = () => {
    const count = VM_TextProperty();
    const button = VM_Command();
    const status = VM_VisibleProperty();
    const statusTransform = VM_RectTransformProperty();
    /** @type {keyof M_CellStatus} */
    let nowStatus = M_CellStatus.flat;
    count.visible.value = false;
    status.visible.value = false;

    /** 
     * @param {keyof M_CellStatus} _status
     * @param {boolean} isMistake
     */
    const applyStatus = (_status, isMistake) => {
        nowStatus = _status;
        count.visible.value = (_status === M_CellStatus.among);
        status.visible.value = (_status !== M_CellStatus.among);

        if(isMistake){
            if(_status === M_CellStatus.bomb) statusTransform.anchordPosition.set(-40, -80);
            if(_status === M_CellStatus.flag) statusTransform.anchordPosition.set(0, -80);
        }else{
            if(_status === M_CellStatus.bomb) statusTransform.anchordPosition.set(0, 0);
            if(_status === M_CellStatus.dig) statusTransform.anchordPosition.set(-120, -80);
            if(_status === M_CellStatus.flag) statusTransform.anchordPosition.set(-40, 0);
            if(_status === M_CellStatus.flat) statusTransform.anchordPosition.set(-80, -80);
        }
    };

    return {
        count: count,
        button: button,
        status: status,
        statusTransform: statusTransform,
        setStatus(status){
            applyStatus(status, false);
        },
        setCount(_count){
            const text = (_count === 0) ? "" : `${_count}`;
            count.text.value = text;
        },
        end(hasBuriedBomb, isMistake){
            if(hasBuriedBomb){
                applyStatus(M_CellStatus.bomb, isMistake);
            }else{
                applyStatus(nowStatus, isMistake);
            }
            button.command.canExecute.value = false;
        },
        restart(){
            button.command.canExecute.value = true;
        }
    };
};

/**
 * @typedef {Object} VM_Cells
 * @property {(x: number, y: number) => VM_Cell} getCell
 * @property {CellForeach<VM_Cell>} foreach
 * 
 * @returns {VM_Cells}
 */
const VM_Cells = (w, h) => {
    /** @type VM_Cell[][] */
    const cells = [];
    for(let x = 0; x < w; x++){
        cells[x] = [];
        for(let y = 0; y < h; y++){
            cells[x][y] = VM_Cell();
        }    
    }

    return {
        getCell(x, y){ return cells[x][y]; },
        foreach: CellForeach(w, h, cells),
    }
};

/**
 * @typedef {Object} VM_Tools
 * @property {VM_Tools_on_off} pickel
 * @property {VM_Tools_on_off} flag
 * @property {(tool: keyof M_ToolKind) => void} useTool
 * 
 * @typedef {Object} VM_Tools_on_off
 * @property {VM_Command} button
 * @property {VM_VisibleProperty} on
 * @property {VM_VisibleProperty} off
 * 
 * @returns {VM_Tools}
 */
const VM_Tools = () => {
    /** @type {VM_Tools_on_off} */
    const pickel = {
        button: VM_Command(),
        on: VM_VisibleProperty(),
        off: VM_VisibleProperty(),
    };
    /** @type {VM_Tools_on_off} */
    const flag = {
        button: VM_Command(),
        on: VM_VisibleProperty(),
        off: VM_VisibleProperty(),            
    };

    return {
        pickel: pickel,
        flag: flag,
        useTool(tool){
            pickel.on.visible.value = false;
            pickel.off.visible.value = false;
            flag.on.visible.value = false;
            flag.off.visible.value = false;
    
            if(tool === M_ToolKind.pickel){
                pickel.on.visible.value = true;
                flag.off.visible.value = true;    
            }
            if(tool === M_ToolKind.flag){
                pickel.off.visible.value = true;
                flag.on.visible.value = true;    
            }    
        },
    };
};

/**
 * @typedef {Object} VM_Game
 * @property {VM_VisibleProperty} gameArea
 * @property {VM_Cells} cells
 * @property {VM_Tools} tools
 * @property {VM_Game_commands} commands
 * @property {VM_TextProperty} left
 * 
 * @typedef {Object} VM_Game_commands
 * @property {VM_Command} close
 * @property {VM_Command} reset
 * 
 * @param {number} w
 * @param {number} h
 * @param {M_Game} model
 * @returns {VM_Game}
 */
const VM_Game = (w, h, model) => {
    //プロパティやコマンドを準備する。
    //プロパティはViewの状態を保持する機能。
    //コマンドはViewからの操作を受ける機能。
    const gameArea = VM_VisibleProperty();
    const cells = VM_Cells(w, h);
    const tools = VM_Tools();
    const commands = {
        close: VM_Command(),
        reset: VM_Command(),
    };
    const left = VM_TextProperty();

    //ViewModelへの操作(Viewからの操作)に対応する。
    //(View ->) ViewModel -> model
    tools.pickel.button.command.set(() => model.tool.usePickel());
    tools.flag.button.command.set(() => model.tool.useFlag());
    commands.close.command.set(() => gameArea.visible.value = false);
    commands.reset.command.set(() => model.restart());
    cells.foreach((vmc, x, y) => {
        const mc = model.cells.getCell(x, y);
        vmc.button.command.set(() => model.tool.useTool(mc));
    });

    //Modelからの通知に対応する。
    //Model -> ViewModel (-> View)
    model.cells.foreach((mc, x, y) => {
        const vmc = cells.getCell(x, y);
        mc.onAroundBombsChanged(count => vmc.setCount(count));
        mc.onStatusChanged(status => vmc.setStatus(status));
        mc.onCellEnded((hasBuriedBomb, isMistake) => vmc.end(hasBuriedBomb, isMistake));
        mc.onCellRestarted(() => vmc.restart());
    });
    model.cells.left.onChanged(v => left.text.value = `${v}`);
    model.tool.onToolChanged(tool => tools.useTool(tool));
    model.onStarted(() => {
        gameArea.visible.value = true;
    });

    return {
        gameArea: gameArea,
        cells: cells,
        tools: tools,
        commands: commands,
        left: left,
    }
};


////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// Viewにおいて、このゲーム特有の機能
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

/**
 * @typedef {Object} V_Cell
 * @property {(vm: VM_Cell) => void} bindViewModel
 * 
 * @param {string} path
 * @param {PlayerLocalObject} baseLocalObject
 * @returns {V_Cell}
 */
const V_Cell = (path, baseLocalObject) => {
    const cellObject = V_FindObject(path, baseLocalObject);
    const count = V_TextComponent(`Count|Text`, cellObject);
    const button = V_ButtonComponent(`Button|Button`, cellObject);
    const status = V_VisibleComponent(`Status`, cellObject);
    const statusTransform = V_RectTransformComponent(`Status|RectTransform`, cellObject);
    return {
        bindViewModel(vm){
            count.bindViewModel(vm.count);
            button.bindViewModel(vm.button);
            status.bindViewModel(vm.status);
            statusTransform.bindViewModel(vm.statusTransform);
        },
    };
};

/**
 * @typedef {Object} V_Cells
 * @property {(vm: VM_Cells) => void} bindViewModel
 * 
 * @param {string} path
 * @param {number} w
 * @param {number} h
 * @returns {V_Cells}
 */
const V_Cells = (path, w, h) => {
    const cellsObject = V_FindObject(path, null);
    /** @type {V_Cell[][]} */
    const cells = [];
    for(let x = 0; x < w; x++){
        cells[x] = [];
        for(let y = 0; y < h; y++){
            cells[x][y] = V_Cell(`CellColumn_${x}/Cell_${y}`, cellsObject);
        }
    }
    /** @type {CellForeach<V_Cell>} */
    const foreach = CellForeach(w, h, cells);

    return {
        bindViewModel(vm){
            foreach((c, x, y) => c.bindViewModel(vm.getCell(x, y)));
        },
    };
};

/**
 * @typedef {Object} V_OnOffTool
 * @property {(vm: VM_Tools_on_off) => void} bindViewModel
 * 
 * @returns {V_OnOffTool}
 */
const V_OnOffTool = (path, name) => {
    const button = V_ButtonComponent(`${path}/${name}|Button`, null);
    const on = V_VisibleComponent(`${path}/${name}/On|Image`, null);
    const off = V_VisibleComponent(`${path}/${name}/Off|Image`, null);
    return {
        bindViewModel(vm){
            button.bindViewModel(vm.button);
            on.bindViewModel(vm.on);
            off.bindViewModel(vm.off);
        },
    };
};

/**
 * @typedef {Object} V_Tools
 * @property {(vm: VM_Tools) => void} bindViewModel
 * 
 * @returns {V_Tools}
 */
const V_Tools = (path) => {
    const pickel = V_OnOffTool(`${path}`, "Pickel");
    const flag = V_OnOffTool(`${path}`, "Flag");
    return {
        bindViewModel(vm){
            pickel.bindViewModel(vm.pickel);
            flag.bindViewModel(vm.flag);
        },
    };
};

/**
 * @typedef {Object} V_Commands
 * @property {(vm: VM_Game_commands) => void} bindViewModel
 * 
 * @param {string} path
 * @returns {V_Commands}
 */
const V_Commands = (path) => {
    const close = V_ButtonComponent(`${path}/Close|Button`, null);
    const reset = V_ButtonComponent(`${path}/Reset|Button`, null);
    return {
        bindViewModel(vm){
            close.bindViewModel(vm.close);
            reset.bindViewModel(vm.reset);
        },
    };
};

/**
 * @typedef {Object} V_Game
 * @property {(vm: VM_Game) => void} bindViewModel
 * 
 * @param {number} w
 * @param {number} h
 * @returns {V_Game}
 */
const V_Game = (w, h) => {
    //CSEのバグでsetEnabled(false)スタートのオブジェクトの初期化が正常に行われないので、その回避策
    //const gameArea = V_EnableComponent("Canvas|Canvas");
    const gameArea = V_VisibleComponent("SafeArea", null);
    const cells = V_Cells("SafeArea/Cells", w, h);
    const tools = V_Tools("SafeArea/LeftTools");
    const commands = V_Commands("SafeArea/RightTools");
    const left = V_TextComponent("SafeArea/BombLeft/Text|Text", null);
    return {
        bindViewModel(vm){
            gameArea.bindViewModel(vm.gameArea);
            cells.bindViewModel(vm.cells);
            tools.bindViewModel(vm.tools);
            commands.bindViewModel(vm.commands);
            left.bindViewModel(vm.left);
        },
    };
};


////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
//// Model-View-ViewModelを結合
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★
////★★★★★★★★★★★★★★★★★★★★★★★★★★★★

//本来はこうやりたいが、500ms制限に引っかかりそうになるので分割している
// const m = M_Game(12, 12, 22);
// m.start();
// const vm = VM_Game(12, 12, m);
// const v = V_Game(12, 12);
// v.bindViewModel(vm);

const sorry = _.playerLocalObject("Sorry");
const checkSorry = (op) => {
    sorry?.setEnabled(true);
    const ret = op();
    sorry?.setEnabled(false);
    return ret;
};

const onFrame = OnFrame();
onFrame.push((dt) => {
    _.log(`M_Game`);
    return checkSorry(() => M_Game(12, 12, 22, () => Math.random()));
});
onFrame.push((dt, m) => {
    _.log(`M_Game.start`);
    checkSorry(() => m.start());
    return m;
});
onFrame.push((dt, m) => {
    _.log(`VM_Game`);
    return [m, checkSorry(() => VM_Game(12, 12, m))];
});
onFrame.push((dt, m, vm) => {
    _.log(`V_Game`);
    return [m, vm, checkSorry(() => V_Game(12, 12))];
});
onFrame.push((dt, m, vm, v) => {
    _.log(`V_Game.bindViewModel`);
    checkSorry(() => v.bindViewModel(vm));
    return true;
});

おわり

明日は滝 竜三さんの記事です!!
「Cluster World Tools」では、大変お世話になっております…!!!

良きClusterScriptライフを!!!!!!!!!

Discussion