はじめに
筆者のラボでは,人を含むシステムの挙動を分析するために,またそうしたシステムの中に置かれた人の挙動を分析するために,ユーザが途中で介入できる形式のコンピュータシミュレーション(インタラクティブシミュレーション)をウェブ上のゲームとして開発し,よく利用している.
このドキュメントは,そうしたインタラクティブシミュレーションのアプリケーションをp5.jsとDjangoを用いて開発するための基礎を身につけてもらうことを狙いとしたもので,あまり一般的なニーズはないかもしれないが,もし多少でもどなたかの参考になれば幸いだ.
今回は全4回中の最終回で,全体のコードはまとめてGitHubに置いた.
p5.jsとDjangoの間のajaxでのデータのやり取り
前回まででインタラクティブシミュレーションのプログラムをp5.jsで作成しwebに公開することができるようになった.実は,それだけが目的ならあえてDjangoを後ろに置く必要はない.
では,なぜp5.jsをDjangoと組み合わせるのかというと,シミュレーションのログをサーバ側に引き取ってデータベースに格納したり,サーバ側で適切に設定したパラメータをシミュレーションに送り込んだり,といったことを実現するためである.
インタラクティブシミュレーションを研究の手段として用いるためには,これらの機能は欠かすことができない.今回は,そうした機能の基礎として,p5.jsのシミュレーションとDjangoの間のajaxでのデータのやり取りの基本を押さえよう.
Django側の準備
最初にDjangoにログデータを格納するためのデータ構造のクラスを追加しておこう.具体的には,sim/models.pyに下記の内容を書き込む.
from django.db import models
class Game(models.Model):
score = models.IntegerField(default=0)
def __str__(self):
return u"game_{}".format(self.pk)
class State(models.Model):
game = models.ForeignKey(Game, on_delete=models.CASCADE)
time = models.FloatField()
vol = models.IntegerField()
ordered = models.IntegerField()
outs = models.IntegerField()
holding_cost = models.FloatField()
ordering_cost = models.IntegerField()
revenue = models.IntegerField()
def __str__(self):
return u"state_{}_{}".format(self.game.pk, self.pk)
Gameクラスは,インタラクティブシミュレーションの1試行に対応しており,その試行のスコアの値を保持するようになっている.Stateクラスは,シミュレーションモデル(my_model
)のstateLog
の各要素に対応するデータを保持するためのデータ構造(ただし,簡単のため,ordered
はリスト全体ではなくその先頭の値のみを整数で保持するようにしている)であり,多対1の関係でGameクラスと関係づけられている.
これらのクラスはadminサイトにも登録しておくと便利である.そのためには,sim/admin.pyに次の内容を書き込んでおけばよい.
from django.contrib import admin
from . import models
class StateInline(admin.TabularInline):
model = models.State
extra = 0
class GameAdmin(admin.ModelAdmin):
inlines = [StateInline]
admin.site.register(models.Game, GameAdmin)
続いて,ajaxでのデータ通信を処理するためのview関数を用意する.具体的には,sim/views.pyの中に次の2つの関数を定義する.
import json
from django.shortcuts import render
from django.http import JsonResponse, HttpResponseServerError
from . import models
def get_games(request):
games = models.Game.objects.all().order_by("-score").values()
gamelist = list(games)
return JsonResponse(gamelist, safe=False)
def post_logs(request):
if request.method == 'POST' and request.body:
json_dict = json.loads(request.body)
score = int(json_dict['score'])
game = models.Game.objects.create(score=score);
for log in json_dict['logs'].values():
models.State.objects.create(
game=game,
time=float(log['time']),
vol=int(log['vol']),
ordered=int(log['ordered']),
outs=int(log['outs']),
holding_cost=float(log['hc']),
ordering_cost=int(log['oc']),
revenue=int(log['rv']),
);
return JsonResponse(json_dict)
else:
return HttpResponseServerError()
get_games()
関数は,データベースから取得した(Gameオブジェクトの)クエリセットをリストにして,さらにそれをJsonResponse
に変換して返していることがみてとれる.これがGETでのajaxリクエストに応える典型的なパターンである.
一方,post_logs()
関数の方は,クライアント側からPOSTで受け取ったログデータをデータベースに格納する処理を担当している.
送られてくるのはJSON形式のデータ(request.body
)であり,それをまずjson.loads()
でpython
の辞書に変換する.この段階では,まだ辞書の値は文字列になっているので,続いて,必要に応じて,int()
やfloat()
で型変換を施しているのがわかる.
また,GameとStateのどちらにもcreate()
メソッドを用いているが,このメソッドは,新しいオブジェクトの生成とセーブを同時に行う(ため,後でsave()
メソッドを呼ぶ必要はない).これは,POSTでのajaxリクエストに応える典型的なパターンである.
これらのview関数をテンプレート側から利用できるようにするためにurlルーティングも追加しておく.sim/urls.pyを次のように変更しておこう.
from django.urls import path
from django.views.generic import TemplateView
from . import views
app_name = 'sim'
urlpatterns = [
path('', TemplateView.as_view(template_name='sim/index.html'), name='index'),
path('get/', views.get_games, name='get_games'),
path('post/', views.post_logs, name='post_logs'),
]
これでDjango側の準備は整った(ただし,makemigrationsとmigrateを忘れないこと).続いて,p5.jsのコードの方にDjangoとのやり取りに関する機能を追加していく.
p5.js側の拡張
まず,シミュレーション終了時にshow_results()
で表示する画面に,今回のスコアの詳細だけでなく,これまでのベストスコアや,今回の順位についての情報を含めるようにする.
そのために,update()
メソッドの
if(e.type == "over") {
my_model.show_results();
noLoop();
の箇所を次のように書き換える.
if(e.type == "over") {
loadJSON("/sim/get/", my_model.close_game.bind(this));
noLoop();
loadJSON()
は,p5.jsに用意されているJSONデータをajaxで取得してくるための関数である.第1引数がurlを指定しており,上で指定したurlルーティングによって,これがget_games()
のview関数に紐付けられることがわかる.
第2引数はいわゆるコールバック関数で,JSONデータを取得し終わったらそのデータを引数にしてこの関数が呼ばれることになる.ここでは,コールバック関数として,my_model
のclose_game()
メソッドに.bind(this)
をつけて利用している.
close_game()
メソッドは次のように定義した.
Model.prototype.close_game = function(games) {
var scores = [];
for(var game of games) {
scores.push(parseInt(game.score));
}
var my_score = this.calc_score();
var best_score = my_score;
var my_rank = 1;
if(scores.length > 0) {
best_score = max(best_score, scores[0]);
for(var score of scores) {
if(score > my_score) {
my_rank ++;
} else {
break;
}
}
}
this.show_results(my_score, best_score, my_rank, scores.length +1);
this.save_log();
}
view関数get_games()
から返されてくるJSONデータから過去のスコアのリスト(scores
)を作成し,それと今回のスコア(my_score
)を比較しながら,今回の順位(my_rank
)やこれまでのベストスコア(best_score
)を算出していることがわかる.
最後にそれらのデータを引数として,show_results()
メソッドとsave_log()
メソッドを呼んでいる.ここでは,show_results()
の詳細は省略するが,今回のスコアに加えて,順位やベストスコアなども表示するように少し拡張を加えた.
また,ゲームスコアの計算はcalc_score()
というメソッドにまとめている.
Model.prototype.calc_score = function() {
return floor(
this.state.rv -this.state.hc -this.state.oc -this.state.outs *this.par.SOP
);
}
save_log()
メソッドについて紹介する前に,.bind(this)
についてみておこう.これはjavaScriptのthis
の挙動と関係がある.
仮に,close_game()
をmy_model
のメソッドとして単純に,
my_model.close_game();
のように呼び出した場合は,直感どおり,this
はmy_model
オブジェクトを指す.
ところが,my_model.close_game
がコールバック関数として別の関数などに渡された場合は,close_game()
の関数としての定義だけが引数として送り込まれ,それが後で利用される.その際,引数として送り込まれた関数とmy_model
オブジェクトとの対応関係は見えなくなり,this
はmy_model
を指さなくなってしまう(グローバルオブジェクトを指すようになる).
そこで,コールバック関数(だけではないが)の呼び出し時に,強制的にthis
を指定できるようにするのが.bind()
の機能である.上では,update()
メソッドが動いている際のthis
(すなわちmy_model
)を.bind()
の引数に与えて,my_model.close_game
が呼び出されたときのthis
がmy_model
を指すように仕組んでいるわけである.
続いて,save_log()
メソッドについてみていく.これは,ログデータをajaxでDjangoに送り込むためのメソッドである.
このメソッドでは,結果としてデータベースに変更を加えることになるため,POSTを利用する.p5.jsにはhttpPost()
というPOSTのための関数も用意されているが,Djangoのcsrf対策のために必要なヘッダ情報を付与することができないので,Djangoとの通信に利用するのは難しい.
そこで,JavaScriptクッキーライブラリとaxiosを用いることにする.まず,これらを利用できるようにするために,index.htmlの中に次の2行を加えておく.
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
そして,save_log()
メソッドを次のように定義する.
Model.prototype.save_log = function() {
var csrftoken = Cookies.get('csrftoken');
var headers = {'X-CSRFToken': csrftoken};
var data = {
"score": this.calc_score(),
"logs": {}
};
for(var i = 0; i < this.stateLog.length; i++) {
var log = Object.assign({}, this.stateLog[i]);
log.ordered = log.ordered.length ? log.ordered[0] : 0;
data.logs[String(i)] = log;
}
console.log(data);
axios.post("/sim/post/", data, {headers: headers})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
}
2行目と3行目でcsrf対策のためのヘッダ情報(headers
)を作成し,それをaxios.post()
を呼ぶ際にデータと一緒に渡していることがわかる.データの中身はスコアとstateLog
であり,上で作成したview関数post_logs()
に対応した形式でJSONデータを送り込んでいる.
ここまでで,シミュレーションが終了すると,それに対応したログデータがデータベースに格納されるようになった.
シミュレーションの再実行ボタン
もう一度シミュレーションを最初から開始したくなった場合は,このままでもリロードすればよいが,最後に,そのためのボタンを追加してみよう.setup()
の中に,ボタンを1つ付け加える.
var reset_btn = createButton("Play Again");
reset_btn.parent("buttons");
reset_btn.mousePressed(reset_sim);
そして,このボタンに対応するコールバック関数reset_sim()
を次のように定義する.
function reset_sim() {
if(frameCount >= 900) {
my_model = new Model();
frameCount = 0;
loop();
}
}
これで,このボタンを押せば再度シミュレーションが最初から開始されるようになった.
おわりに
以上で最終回も終了.
ここまで読んでくださった方に感謝. m(__)m