1.はじめに
私は 子ども向けプログラミング教室を運営しているものですが、Scratch3
を使ってプログラミングをしている小中学生さんたちがJavascript
学習へシフトしてもらうための教材として p5.play
がよいのではないか?「常々」思っていました。p5.play
には Scratch3
に似たスプライトの概念があるからです。物理効果適用も簡単なので最初から楽しめます。
しかしながら、p5.js
では 「動作の繰り返し」を表現するプログラミング手法として Scratch3
とは少々異なる考え方を強要させてしまいますことを「何とかならんものか」と思っていました。(Javascript/Pythonによる他のゲームエンジンでもおなじ傾向があるので p5.jsに限った話ではないです)
p5.play
スプライトの挙動を表現する繰り返しの処理を見やすくコード化したい( Scratch3
と親和性を高く、Scratch3
を学習してきた生徒さんたちを p5.js
の世界へ招き入れるときの『壁』をできるだけ低くしたい)との思いをずっと感じていて、自分なりに試行錯誤してきましたので、その結果を記録に残しておきたいと思いました。
今回は『 p5.js + p5.play のLOOP 』のお話しとしてまとめています。もしご興味があればお読みください。
Scratch3
の並列動作の特徴を知っている人はスキップして 『JavascriptでLOOPを書く』の項へお進みください。
2.概説:Scratch3の繰り返し処理
Scratch3のには、繰り返し処理を実現するブロックがあります。
『ずっと(forever)』ブロック、『〇回繰り返す(repeat)』ブロックがその代表例です。
繰り返し処理のブロックを使ってScratch3のスプライトのスクリプトを記述します。
ここで、Scratch3 の動きをよく観察すると次のことがわかります。
【FPS 間隔】1回の繰り返しは FPS 間隔で実行される
Scratch3 の繰り返しのブロックは ブロックの中身を FPS間隔で実行します。
実証例
次の2つのスプライトを用意して、どのように「動く」のか比較してみます。
どちらも 30歩 × 10 = 300歩 動くはずです。
30歩動かす
ブロックを 10個つなげたスクリプト
30歩動かす
ブロックを 10回繰り返したスクリプト
START
動作の様子
緑の旗をクリックした2秒後に、上のネコは一瞬で 300歩動きますが、下のネコは すこしづつ 右に動きます。
- 上のネコ:
30歩動かす
ブロックが10個分 1つのFPS間隔で実行されるます。 - 下のネコ:
30歩動かす
ブロックが FPS間隔で実行されつづけます( 10回だけ )。
【並列動作】複数の繰り返しブロックは 順番に実行され並列動作と見える
別スクリプトに書かれた繰り返しブロックはFPS間隔で順番に繰り返されていきます。
スクリプトAに繰り返しブロック(A)、スクリプトBに繰り返しブロック(B)があるとき、最初に(A)(B)のブロックの中身が実行され、次のFPS間隔後に (A)(B) のブロックの中身が実行されます。
つまり、繰り返しブロック(A)、繰り返しブロック(B)は 他のスクリプトを止めることなく動作します。
この特徴により Scratch3 のスクリプトは 並列に動作するように見えます。
実証例
2つのスプライト(上下のネコ)のスクリプトを、どちらも次のように書いてみます。
上下のネコは すこしづつ 右に動き、どちらも 同じように動いていきます。
Scratch3 では 繰り返し処理を書くのが簡単
このように Scratch3 では 繰り返しブロックを使うことで 自動的にFPS間隔で繰り返されていくため、並列で動くから他のスクリプトに影響を与えない(止めない)ため、繰り返し処理を考えるのが楽です。
3.JavascriptでLOOPを書く
ブラウザ上で Javascript が動作している前提とします。
次のようなコードを書いたとします。move( 30 )
は 30歩動くに該当する架空のメソッドと思ってください。無限ループとして 書きました。
for(;;) {
move( 30 );
}
結論:このコードの実行によりブラウザが固まる
for( ;; ) { }
の繰り返しの動作があまりにも速く繰り返されるため、Javascriptのメインスレッド がその繰り返しで占有されてしまうからです。
4.p5.playで繰り返しを書く
ブラウザ固まるのを回避しながら、繰り返しを明示的に書きたい
こんな風にしないといけなそうです。
for(;;) {
move( 30 );
Until( 10 ); // 短い間隔が経過するまで処理を止める。(A)
}
(A) の 『〇〇まで待つ』 を書くのを忘れると あっという間に ブラウザが固まります。
小中学生に 『〇〇まで待つ』を必ず書きなさいと強要するのは酷ですね。かなりの可能性で忘れそうです。そしてあっというまにブラウザが固まります。そのような心配ごとは起こらないように工夫したいものです。
FPS間隔で 繰り返したい
こんな風に書かないといけなそうです。
for(;;) {
move( 30 );
Until( condition , _=>{
condition = false;
}); // (B)
// 条件成立(condition === true) になるまで待つ。条件成立後に次の繰り返しに入る。
// 条件成立後はコールバックのなかで condition を 不成立 に戻すことで
// 次の繰り返しのときは また (B) Until() で停止する、の繰り返し。
}
(B) の 『〇〇まで待つ』に到達すると 一旦停止しますが、condition === true になったときには 停止解除されるとお考えください。
// p5.js では draw は FPS間隔で呼び出されるので、FPS間隔で condition を trueにし、
// 繰り返し処理のなかの 停止を解除、次の繰り返しに入る。
draw() {
condition = true;
}
永久ループ:このように書けるようにしたい
let idx = 0
C.LoopForEver( async _=> { // <--- for(;;)に該当する行
idx += 1; // 例えば idx をインクリメントする
})();
ForEver の単語は、Scratch3 のブロックを参考にしました。
素のJavascriptの構文ではないですが、近い形で表現できていると思います(慣れたら大丈夫)。
これを実行時に次のように置換するものとします
let idx = 0;
for(;;) {
idx += 1;
await Until( _=> cancelFlg === true , // <-- この条件が成立するまで待つ。
_=>{
cancelFlg = false; // Wait解除されたら 条件不成立に戻す
}
);
}
回数分のループ:このように書けるようにしたい
let idx = 0
C.LoopRepeat( 10, async _=> { // <--- for(;;)に該当する行
idx += 1; // 例えば idx をインクリメントする
})();
Repeat の単語は、Scratch3 のブロックを参考にしました。
これを実行時に次のように置換するものとします
let idx = 0;
for(let i=0; i<10; i++) {
idx += 1;
await Until( _=> cancelFlg === true , // <-- この条件が成立するまで待つ。
_=>{
cancelFlg = false; // Wait解除されたら 条件不成立に戻す
}
);
}
ループの入れ子も大丈夫にしたい
let idx = 0
let idx2 = 0
C.LoopForEver( async _=> {
await C.LoopRepeat( 10, async _=> { // <--- for(;;)に該当する行
idx += 1; // 例えば idx をインクリメントする
})();
idx2 += 1; // 例えば idx をインクリメントする
})();
これは次のように置換されます。
let idx = 0;
let idx2 = 0;
for(;;) {
for(let i=0; i<10; i++) {
idx += 1;
await Until( _=> cancelFlg === true , // <-- この条件が成立するまで待つ。
_=>{
cancelFlg = false; // Wait解除されたら 条件不成立に戻す
}
);
}
idx2 += 1;
await Until( _=> cancelFlg === true , // <-- この条件が成立するまで待つ。
_=>{
cancelFlg = false; // Wait解除されたら 条件不成立に戻す
}
);
}
5.というわけで実装にトライしてみた
await Until()
のところで 都度停止し、FPS間隔で停止を解除する仕組みを考えていきたいと思います。
Javascript の 繰り返し構文の形をなるべく崩さずに、処理を表現できるような仕組みを構築したいと思います。
STEP01 : 『〇〇まで待つ』処理を作る
p5 インスタンスへ メソッドを追加しましょう。
p5.prototype.registerMethod('init', function() {
const p = this;
// 指定したミリ秒だけ待つ。
p.Wait = async (ms) => new Promise(resolve => setTimeout(resolve, ms));
// await Wait(2) により 2ms ~ 4ms 停止する感じ
const UntilMs = 2;
p.Until = async(condition,callback) => new Promise(async resolve=> {
for(;;) {
if(condition()) break; // 条件成立したらLOOPを抜ける。
await p.Wait(UntilMs); // ブラウザ固まらないよう 短時間停止する
}
if(callback) callback();
resolve(); // プロミス解決
});
});
P5 init処理のなかで p5 インスタンスへ Wait / Until メソッドを追加しています。
callbackのなかでは、条件不成立をする処理を書く想定です。
STEP02 : 『LoopForEver』処理を作る
p5.prototype.registerMethod('init', function() {
const p = this;
p.Control = class {
constructor(_methodRegister) {
this.mr = _methodRegister;
}
LoopForEver(_f) {
const mr = this.mr;
if(!mr.isRegisted(_f)) {
const wrapper = async() => {
for(;;) {
await _f();
await p.Until(
_=> mr.waitCancel === true, // <--- Untilへ渡す条件式
_=> mr.waitCancel = false // <--- Untilへ渡すコールバック
);
}
};
mr.regist(_f, wrapper);
}
const wrapper = mr.getWrapMethod(_f);
return wrapper;
}
}
});
LoopForEver
でやること
引数で受け取るメソッドオブジェクト(_f) をキーとして扱う。
methodRegister にキー値が存在するか否かを調べる。
キー値が存在しないときは、_f を組み込んだ、wrapperメソッドを作り出す。
wrapperメソッドには、 次の構文を組み込む。その後に methodRegisterへ登録する。
for(;;) {
await _f();
await Until(); // <--- 条件成立するまで待つ処理 (仔細省略)
}
methodRegisterにキー値(_f)で登録済の場合は,wrapperメソッドを取り出す。
wrapperメソッドを戻り値として返す。
なお、wrapperメソッドは、async 関数として定義することに注意願う。
methodRegister
:
引数で渡されるメソッドを記録しておくためのクラスMethodRegister
インスタンス。
クラスは後述します。
STEP03 : 『LoopRepeat』処理を作る
『STEP03』の Controlクラスへ LoopRepeat を追加するとお考えください。
LoopRepeat(_count, _f) {
const mr = this.mr;
if(!mr.isRegisted(_f)){
const wrapper = async() => {
for(let i=0;i<_count;i++) {
await _f();
await p.Until(
_=> mr.waitCancel === true,
_=> mr.waitCancel = false
);
}
};
mr.regist(_f, wrapper);
}
const wrapper = mr.getWrapMethod(_f);
return wrapper;
}
LoopRepeat
でやること
引数で受け取るメソッドオブジェクト(_f) をキーとして扱う。
methodRegister にキー値が存在するか否かを調べる。
キー値が存在しないときは、_f を組み込んだ、wrapperメソッドを作り出す。
wrapperメソッドには、 次の構文を組み込む。その後に methodRegisterへ登録する。
なお _count は引数で受け取る『回数』である。
for(let i=0; i< _count; i++) {
await _f();
await Until(); // <--- 条件成立するまで待つ処理 (仔細省略)
}
methodRegisterにキー値(_f)で登録済の場合は,wrapperメソッドを取り出す。
wrapperメソッドを戻り値として返す。
なお、wrapperメソッドは、async 関数として定義する。
STEP04 : 『MethodRegister』クラス
p.MethodRegister = class {
constructor() {
this.regester = new Map();
this.waitCancel = false;
}
isRegisted(_f) {
return this.regester.has(_f);
}
regist(_f, _wrap) {
if(!this.isRegisted(_f)){
this.regester.set(_f,_wrap);
}
}
getWrapMethod(_f) {
return this.regester.get(_f);
}
setWaitCancel(_cancel=true) {
this.waitCancel = _cancel;
}
}
STEP05 : Spriteクラスにて使ってみる
// setup()は drawを開始する前に実行しておくとする。
setup() {
// メソッドレジスタをインスタンス化
const mReg = new p.MethodRegister();
this.mReg = mReg;
// コントロールをインスタンス化
const C = new p.Control(mReg);
const _topMethod =
// 引数はアロー関数とし、Spriteのインスタンスを this として扱えるようにしている。
C.LoopForEver(async _=> {
await C.LoopRepeat(40, _=>{ // 40回繰り返す
this.x += 2;
if(this.x > W) this.x = 0;
})(); // 実行
await C.LoopRepeat(10, async _=>{ // 10回繰り返す, 中に入れ子を持つので async関数。
this.rotation += 5;
await C.LoopRepeat(5, _=>{ // 5回繰り返す
this.y += 2;
if(this.y > H) this.y = 0;
})(); // 実行
})(); // 実行
});
// 例えば、1000 msec 後に Loopを開始してみる
setTimeout(_topMethod, 1000);
}
draw() {
super.draw(); // p.Sprite を継承しているサンプルなので super.drawを必要としている。
this.mReg.waitCancel = false; // FPS間隔でp.Until の停止を解除する
}
動作
LOOPを組み合わせて ちょっと複雑な動きをさせています。
動作の例
p5Editor: ( p5playParallelThread )
GitHub
GitHub ( amami-harhid/p5playParallelThread20231119 )
6.まとめ
p5 にて LOOPを多少なりとも見通しよく書きたいと思い、試行錯誤しました。
ForEver(ずっと) 、 Repeat( 〇回繰り返す ) の2つだけしか用意できていませんが、今後に他のループを使えるように対応していきたいと思います。
まだまだ試行の域をでませんので、ふーん、こんなことを考えているヤツもいるんだなぁと思っていただける程度で受け止めてくださいね。