プログラミング はじめの一歩 JavaScript + p5.js編
25:くり返しの中にくり返し

この記事の詳しい内容には毎日新聞の「くり返しの中にくり返し」ページから有料記事に進むことで読めます。

概要

かめのロボットを動かして線を引きます。ロボットには次の4つの命令を出すことができます。

命令:

  • 進む(n) nマス分進む
  • 左回り() その場で左を向く
  • 右回り() その場で右を向く
  • m回繰り返す 囲んだ命令をm回繰り返す

さてここで問題です。次のフローチャートの命令を出すと、ロボットはどんな線を引くでしょう?

論理を考える
繰り返しの繰り返し

フローチャートを日本語の疑似コードで表すと、

2回繰り返す		// 外側の繰り返し
	進む(2)
	4回繰り返す	// 内側の繰り返し
		進む(1)
		左回り()
	繰り返しここまで
繰り返しここまで

となります。

繰り返しの繰り返しには、外側の繰り返しと内側の繰り返しがあり、外から見ると2重の繰り返しに見えます。繰り返しの繰り返しをばらすと、次のように羅列できます。内側の繰り返しは外側の繰り返し分だけ繰り返されるので、当然実行回数が多くなります。

進む(2) // 外側の繰り返しの1回め
進む(1) // 内側の繰り返しの1回め
左回り()
進む(1) // 内側の繰り返しの2回め
左回り()
進む(1) // 内側の繰り返しの3回め
左回り()
進む(1) // 内側の繰り返しの4回め
左回り()

進む(2) // 外側の繰り返しの2回め
進む(1) // 内側の繰り返しの1回め(通算で5回め)
左回り()
進む(1) // 内側の繰り返しの2回め(通算で6回め)
左回り()
進む(1) // 内側の繰り返しの3回め(通算で7回め)
左回り()
進む(1) // 内側の繰り返しの4回め(通算で8回め)
左回り()

繰り返しの繰り返しはJavaScriptではforループやwhileループで表現できます。たとえば次のコードでは、2重のforループから九九の表が作成できます。

function setup() {
    createCanvas(400, 400);
    textSize(20);
    textAlign(CENTER);
    rectMode(CENTER);
    background(220);
    // 九九の表
    for (let i = 1; i < 10; i++) {
        for (let j = 1; j < 10; j++) {
            rect(40 * i, 40 * j - 5, 40, 40);
            text(i * j, 40 * i, 40 * j);
        }
    }
}

かめのロボットの移動には、sketch.jsとKameクラスを使用します。

かめのロボットを動かして線を引く

では、2重のforループを使ってかめのロボットに線を引かせましょう。プログラムのコードは、「13:かめを動かそう」のものを使用します。

const rows = 5; // 横向きの行数
const columns = 5; // 縦向きの列数
const gutter = 0; // 矩形間の空き
const w = 50; // 矩形の幅
const h = 50; // 矩形の高さ
// 矩形の開始位置をずらす量 => (offsetX,offsetY)から始まる
const offsetX = 60;
const offsetY = 60;

let kame, kameImages;

function preload() {
    // かめの右向き、下向き、左向き、上向きの画像を読み込み、
    // そのイメージを右、下、左、上の順に配列に入れる
    const right = loadImage('images/right.png');
    const down = loadImage('images/down.png');
    const left = loadImage('images/left.png');
    const up = loadImage('images/up.png');
    kameImages = [right, down, left, up];
}

function setup() {
    createCanvas(400, 400);
    // イメージの基点をイメージセンターにするモード
    imageMode(CENTER);
    // Kameインスタンスを作成
    kame = new Kame(60, 210, kameImages);
}

function mousePressed() {
    for (let i = 0; i < 2; i++) {
        kame.goForward(2);
        for (let j = 0; j < 4; j++) {
            kame.goForward(1);
            kame.turnToLeft();
        }
    }
}

function draw() {
    background(220);
    // グリッドの線の太さ
    strokeWeight(1);
    // 矩形を格子状に並べて描く
    for (let c = 0; c < columns; c++) {
        for (let r = 0; r < rows; r++)
        // 行数(rows) x 列数(columns)に矩形を描く
            rect(offsetX + c * (gutter + w), offsetY + r * (gutter + h), w, h);
    }
    // カメが引く線の太さ
    strokeWeight(7);
    kame.drawLine() // カメが線を引く
    kame.display(); // カメを描画
}

// かめのロボットのクラス
class Kame {
    constructor(x, y, imgs) {
            this.x = x;
            this.y = y;
            // 向いている向きの配列 [右、下、左、上]の順番
            this.directions = ['right', 'down', 'left', 'up'];
            // 現在の方向を数値で表す
            this.currentDirection = 0;
            // 現在の方向を文字列で表す
            this.direction = this.directions[this.currentDirection];
            // カメのイメージ
            this.images = imgs;
            // 向きによってイメージを変える
            this.image = this.images[this.currentDirection];
            // カメの1歩は50ピクセル = グリッドの正方形の一辺の長さと同じ
            this.step = 50;
            // 移動する座標を保持する配列
            this.prevousXs = [];
            this.prevousYs = [];
            // 最初の位置を追加する
            this.prevousXs.push(this.x);
            this.prevousYs.push(this.y);
        }
        // カメのイメージを描画する
    display() {
            image(this.image, this.x, this.y);
        }
        // 右を向く
    turnToRight() {
            this.currentDirection++;
            // 循環させる
            if (this.currentDirection >= 4) {
                this.currentDirection = 0;
            }
            // カメの新しい向き
            this.direction = this.directions[this.currentDirection];
            // カメの新しいイメージ
            this.image = this.images[this.currentDirection];
        }
        // 左を向く
    turnToLeft() {
            this.currentDirection--;
            if (this.currentDirection <= -1) {
                this.currentDirection = 3;
            }
            this.direction = this.directions[this.currentDirection];
            this.image = this.images[this.currentDirection];
        }
        // stepsマス分だけ前進
    goForward(steps) {
            // 前進と言っても、今の向きによって方向は変わる
            // 今の向きが右なら
            if (this.direction === 'right') {
                // 右にstepsマス分(50 * steps ピクセル)移動
                this.x += this.step * steps;
            }
            else if (this.direction === 'down') {
                this.y += this.step * steps;
            }
            else if (this.direction === 'left') {
                this.x -= this.step * steps;
            }
            else if (this.direction === 'up') {
                this.y -= this.step * steps;
            }
            // 新しいxy位置を配列に追加
            this.prevousXs.push(this.x);
            this.prevousYs.push(this.y);
        }
        // 通ってきた位置を結ぶ線を引く
    drawLine() {
        for (let i = 0; i < this.prevousXs.length; i++) {
            line(this.prevousXs[i], this.prevousYs[i], this.prevousXs[i - 1], this.prevousYs[i - 1]);
        }
    }
}

画面のマウスプレスすると、かめのロボットが線を引きます。下図はその結果で、問題の答えです。

画面のマウスプレスで呼び出されるp5.jsのmousePressed()関数では、次の2重のforループを使用しています。

// 2回繰り返す
for (let i = 0; i < 2; i++) {
    kame.goForward(2);
    // 4回繰り返す
    for (let j = 0; j < 4; j++) {
        kame.goForward(1);
        kame.turnToLeft();
    }
}

この2重のforループは次のコードの実行と同じです。

kame.goForward(2);

kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();

kame.goForward(2);

kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();
kame.goForward(1);
kame.turnToLeft();

ただし線の描画は一瞬で終わり、かめが線を引いているようには見えません。

指定時間だけ待つ

かめの移動と線の描画が一瞬で終わるのは、2重のforループ(または上記のコードの羅列)が、まさに目にも止まらぬ速さで実行されるからです。したがって、実行コードの間に、間を作るコードが挿入できれば解決できることになります。

steTimeout()の入れ子

解決策の1つとして、 window.setTimeout()関数の入れ子があります。setTimeout()を使って指定時間の経過後に1つコードを実行し、そのとき次のsetTimeout()を使ってまた指定時間の経過後に次のコードを実行するという方法です。

この方法は次のような関数で実現できます。

function kameAction(num) {
    let timerID = window.setTimeout(() => {
        kame.goForward(2);
        window.clearTimeout(timerID);
        timerID = window.setTimeout(() => {
            kame.goForward(1);
            kame.turnToLeft();
            window.clearTimeout(timerID);
            timerID = window.setTimeout(() => {
                kame.goForward(1);
                kame.turnToLeft();
                window.clearTimeout(timerID);
                timerID = window.setTimeout(() => {
                    kame.goForward(1);
                    kame.turnToLeft();
                    window.clearTimeout(timerID);
                    timerID = window.setTimeout(() => {
                        kame.goForward(1);
                        kame.turnToLeft();
                        window.clearTimeout(timerID);
                        if (num === 1) {
                            kameAction(2);
                        }
                    }, ms);
                }, ms);
            }, ms);
        }, ms);
    }, ms);
}

このkameAction()関数は、繰り返し全体の半分を実行します。kameAction()関数を呼び出すときには、引数に1を指定します。すると、setTimeout()の連鎖の最後でkameAction(2)が呼び出され、残りの半分が実行されます。

setTimeout()の入れ子(setTimeout()のコールバック関数でさらにsetTimeout()を呼び出すこと)は、ご覧のように、決して読みやすいコートではありません。修正したくなった場合、どこに手を入れてよいかすぐには分かりません。

Promiseの使用

setTimeout()の入れ子は、その読みにくさからコールバック地獄などと呼ばれます。この地獄を解消する方法にPromiseオブジェクトの使用があります。

Promise非同期処理に使用される、比較的新しいオブジェクトです。

次の関数を定義します。この関数は「引数に指定されたmsミリ秒たったタイミングで解決する」Promiseオブジェクトを返します。

function waitUntill(ms) {
    const promise = new Promise(executor);
    // Promiseのコンストラクタに渡すexecutor()関数
    function executor(resolve, reject) {
            if (isNaN(ms)) {
                reject('数値でない');
            }
            else {
                // ms秒まったら解決
                window.setTimeout(() => {
                    resolve('成功');
                }, ms);
            }
        }
        // というPromiseオブジェクトを返す
    return promise;
}

そして次のkameAction()関数を定義します。この関数は、async functionで宣言された、非同期で動作する非同期関数で、頭にawaitのついたawait waitUntill(1000)がPromiseオブジェクトを返すのを待ちます(次の行の実行に移りません)。これにより、1000ミリ秒待つことができます。

// かめを動かす
async function kameAction() {
    // 2回繰り返す
    for (let i = 0; i < 2; i++) {
        kame.goForward(2);
        await waitUntill(1000); // 1000ミリ秒待つ
        // 4回繰り返す
        for (let j = 0; j < 4; j++) {
            kame.goForward(1); // かめを2マス前進
            await waitUntill(1000); // 1000ミリ秒待つ
            kame.turnToLeft(); // かめを左に回転
            await waitUntill(1000); // 1000ミリ秒待つ
        }
    }
}

kameAction()関数はマウスプレス時に呼び出します。

function mousePressed() {
    kameAction();
}

下はこの実行画面です。画面のマウスプレスでかめのロボットが移動し線を引きます。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA