はじめに
筆者のラボでは,人を含むシステムの挙動を分析するために,またそうしたシステムの中に置かれた人の挙動を分析するために,ユーザが途中で介入できる形式のコンピュータシミュレーション(インタラクティブシミュレーション)をウェブ上のゲームとして開発し,よく利用している.
このドキュメントは,そうしたインタラクティブシミュレーションのアプリケーションをp5.jsとDjangoを用いて開発するための基礎を身につけてもらうことを狙いとしたもので,あまり一般的なニーズはないかもしれないが,もし多少でもどなたかの参考になれば幸いだ.
今回は全4回中の3回目で,全体のコードはまとめてGitHubに置いた.
シミュレーションをインタラクティブにしてみよう
今回は,在庫管理モデルのシミュレーションにインタラクティブな機能を組み込んで,少しゲームらしくしてみよう.具体的には,定量発注方式で在庫量が発注点まで減ってきたら自動的に所定の量を発注していた部分を削除し,発注のタイミングと量をユーザがインタラクティブに決定できるようにする.下のスケッチが到達点のイメージである(いつか動画に差替えたい).

UI要素の準備
p5.jsにはマウスやキーの操作に関連したイベントリスナーやマウスの座標を参照するための変数が標準装備されているので,スケッチ内にインタラクションのための機能を盛り込むこともできるが,ここでは,簡単のために,p5.domのライブラリに用意されているUI要素を利用することにする.
index.htmlを見直してみると,p5.dom.min.jsがすでに読み込まれていることが確認できる(ので,p5.domライブラリの機能はすでに利用可能になっている).
UI要素を配置するためのdivを用意しておこう.index.htmlのbody要素を下記のように少し書き換える.
<body>
<h1>Interactive Simulation</h1>
<div id="mysketch"></div>
<div id="buttons"></div>
<h1>Please Enjoy!</h1>
</body>
インタラクションに利用するUI要素は,このbuttonsというidのdivの中に配置していく.具体的には,setup()
の中に下記のコードを書き加えよう(p5.domライブラリに用意されている機能の詳細はここを参照してほしい).
order_input = createInput("How many?");
var order_btn = createButton("Order");
var stop_btn = createButton("Pause");
var resume_btn = createButton("Resume");
order_input.parent("buttons");
order_btn.parent("buttons");
stop_btn.parent("buttons");
resume_btn.parent("buttons");
order_btn.mousePressed(place_order);
stop_btn.mousePressed(noLoop);
resume_btn.mousePressed(loop);
ボタンや入力ボックスを作成し,それらを上で作成したbuttonsのdivの子要素に設定して,最後に各ボタンが押されたときに実行するコールバック関数を定義している.
order_input
にだけ頭にvar
がついていないのには意味がある.後で定義する関数place_order()
の中でこれを参照したいので,setup()
内のローカル変数としてではなく,大域変数として定義しておく必要があるからである.setup()
の外に,
var order_input;
と書いておこう.
介入効果を実現するためのコールバック関数
stop_btn
のコールバック関数はnoLoop()
,resume_btn
のコールバック関数はloop()
になっている.これらはp5.jsの関数で,それぞれdraw()
のループを止める,再開するという機能に対応している.したがって,これらのボタンでシミュレーションを途中で停止させたり,再開させたりすることが可能になる.
発注の機能は,order_input
の入力ボックス,order_btn
とそのコールバック関数place_order()
で実現していく.これについて見ていく前に,少しモデルを修正しておくことにする.
function Model() {
this.par = {
MTB: 2, //mean time between shipments
LT: 10, // lead time to replenishment
HC: 1, // stock holding cost
OC: 500, // ordering cost
SOP: 250, // stock out penalty
RV: 100, // sales revenue
};
this.state = {
time: 0, // what time is it now?
vol: 20, // stock volume at hand
ordered: [], // quantities ordered
outs: 0, // number of stockouts
hc: 0, // total stock holding cost
oc: 0, // total ordering cost
rv: 0, // total revenue
};
this.calendar = new Calendar([
{time:exp_rand(1 /this.par.MTB), type:"ship_out"},
{time:900, type:"over"}
]);
this.stateLog = [this.state];
}
モデルのパラメータ(par
)と状態変数(state
)に少し変更を加えている.これは,売上とコストを導入してそれらの差額で最終的な収益(=ゲームのスコア)を計算するようにしたいためである.
メソッドreduce()
は下のように更新した.
Model.prototype.reduce = function() {
if(this.state.vol > 0) {
this.state.vol --;
this.state.rv += this.par.RV;
} else {
this.state.outs ++;
}
this.calendar.extend({
time: this.state.time +exp_rand(1 /this.par.MTB),
type: "ship_out"
});
this.par.MTB = max(0.1, this.par.MTB +random(-0.1, 0.1))
}
在庫量が発注点を下回ると自動的に発注をかけ,それに対応する補充イベントをカレンダに追加していた部分は削除した.
また,4行目で,1つ出荷されるごとに売上(rv
)の値を一定値ずつ増加させるようにしている.最後の部分でMTB
の値をランダムウォークで変化させているのは,需要を徐々に変化させるようにしてゲーム性を高めるためである.
update()
にも少しコードを追加している.
Model.prototype.update = function() {
var e = this.calendar.fire();
this.state = Object.assign({}, this.state);
this.state.ordered = this.state.ordered.concat();
this.state.hc += (e.time -this.state.time) *this.state.vol *this.par.HC;
this.state.time = e.time;
if(e.type == "over") {
my_model.show_results();
noLoop();
} else if(e.type == "ship_out") {
this.reduce();
} else if(e.type == "refill") {
this.raise();
}
this.stateLog.push(this.state);
}
4行目に追加しているのは,在庫保管コスト(hc
)の更新式である.このコストは在庫量と保管期間の長さに比例する.また,7行目で,シミュレーション終了時に結果を表示するメソッドshow_results()
を呼ぶようにしている.
show_results()
は新しく下記のように作成した.
Model.prototype.show_results = function() {
background(200);
var my_ratio = width /1000;
push();
scale(my_ratio);
translate(50, 100);
var my_score = this.state.rv -this.state.hc -this.state.oc -this.state.outs *this.par.SOP;
textSize(40);
text("Your Score: " +floor(my_score), 0, 0);
textSize(20);
text("Sales Revenue: " +floor(this.state.rv), 100, 100);
text("Holding Cost: " +floor(this.state.hc), 100, 140);
text("Ordering Cost: " +floor(this.state.oc), 100, 180);
text("Stockout Penalty: " +floor(this.state.outs *this.par.SOP), 100, 220);
pop();
}
上で説明した,ゲームスコアとしての収益のほか,売上(rv
),在庫保管コスト(hc
),発注コスト(oc
),欠品ペナルティの値を表示している.
最後に,発注のためのインタラクションを実現するメソッドplace_order()
を作っておこう.
function place_order() {
var oq = parseInt(order_input.value());
if(Number.isNaN(oq)) oq = 0;
my_model.calendar.extend({
time: frameCount +my_model.par.LT,
type: "refill"
});
my_model.state.ordered.push(oq);
my_model.state.oc += my_model.par.OC;
}
入力ボックスorder_input
の値を整数に変換し,補充イベントを作成してカレンダに追加してることがわかる.あわせて発注コスト(oc
)の値も更新している.なお,3行目は,入力ボックスに数値以外のデータが入っていたりしてうまく整数に変換できなかったときのエラー処理である.
これで,単純だけど,インタラクティブなシミュレーションが完成した.試しに動かしてみてほしい.
おわりに
第3回はここで終了.第4回に続く.