1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

p5.play のループを『Scratch3風 』に表現したい(実験中)

Last updated at Posted at 2023-11-19

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)』ブロックがその代表例です。

image.png image.png

繰り返し処理のブロックを使ってScratch3のスプライトのスクリプトを記述します。

【例】
image.png

ここで、Scratch3 の動きをよく観察すると次のことがわかります。

【FPS 間隔】1回の繰り返しは FPS 間隔で実行される

Scratch3 の繰り返しのブロックは ブロックの中身を FPS間隔で実行します。

実証例

次の2つのスプライトを用意して、どのように「動く」のか比較してみます。
どちらも 30歩 × 10 = 300歩 動くはずです。

30歩動かす ブロックを 10個つなげたスクリプト

image.png

30歩動かす ブロックを 10回繰り返したスクリプト

image.png

START

image.png

動作の様子

p5play_pico39.gif

緑の旗をクリックした2秒後に、上のネコは一瞬で 300歩動きますが、下のネコは すこしづつ 右に動きます。

  • 上のネコ: 30歩動かすブロックが10個分 1つのFPS間隔で実行されるます。
  • 下のネコ: 30歩動かすブロックが FPS間隔で実行されつづけます( 10回だけ )。

【並列動作】複数の繰り返しブロックは 順番に実行され並列動作と見える

別スクリプトに書かれた繰り返しブロックはFPS間隔で順番に繰り返されていきます。

スクリプトAに繰り返しブロック(A)、スクリプトBに繰り返しブロック(B)があるとき、最初に(A)(B)のブロックの中身が実行され、次のFPS間隔後に (A)(B) のブロックの中身が実行されます。
つまり、繰り返しブロック(A)、繰り返しブロック(B)は 他のスクリプトを止めることなく動作します。

この特徴により Scratch3 のスクリプトは 並列に動作するように見えます。

実証例

2つのスプライト(上下のネコ)のスクリプトを、どちらも次のように書いてみます。

image.png

上下のネコは すこしづつ 右に動き、どちらも 同じように動いていきます。

p5play_pico40.gif

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クラスにて使ってみる

拡張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 の停止を解除する
    }

動作

p5play_pico41.gif

LOOPを組み合わせて ちょっと複雑な動きをさせています。

動作の例

p5Editor: ( p5playParallelThread )

GitHub

GitHub ( amami-harhid/p5playParallelThread20231119 )

6.まとめ

p5 にて LOOPを多少なりとも見通しよく書きたいと思い、試行錯誤しました。
ForEver(ずっと) 、 Repeat( 〇回繰り返す ) の2つだけしか用意できていませんが、今後に他のループを使えるように対応していきたいと思います。

まだまだ試行の域をでませんので、ふーん、こんなことを考えているヤツもいるんだなぁと思っていただける程度で受け止めてくださいね。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?