プログラミング はじめの一歩 JavaScript + p5.js編
14:イスを人数分ならべる

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

概要

このロボットは、指示された命令の手順で椅子を人数分並べます。命令は下図のフローチャートで表されます。

ロボットは、フローチャートの1つめの台形に書かれている「ここから 回」の空欄で指定された回数だけ、椅子Aと椅子Bを並べます。たとえば空欄に3を入力すると、ロボットは1回めで椅子Aと椅子Bの2脚、2回めも同様に2脚、3回めも同様に2脚の椅子を並べるので、6人分の椅子が並ぶことになります。

では、10人が座る椅子を用意するには、空欄にどんな数字を入力すればよいでしょうか? というのが問題です。

論理を考える

ロボットは椅子を1回の繰り返しで2脚並べるので、椅子の数は繰り返し回数の倍ということになります。式で考えると、椅子の数(=人数) = 繰り返し回数 x 2 なので、

繰り返し回数 = 椅子の数(人数) / 2

となります。 したがって、10人分の椅子10脚を並べるには、10 / 2 = 5 で、5回繰り返せばよいということになります。

繰り返し回数を指定して椅子を並べるプログラム

ロボットは1回で、椅子Aと椅子Bを並べるので、椅子Aと椅子Bは交互に並ぶことになります。6脚並べた場合には下図のように並びます。

では、椅子を並べることは、プログラムでどのように表現できるでしょうか? 「1:おかしに当たり券を入れる」の「物事をどう表すか?」と同じように、椅子Aと椅子Bを最もシンプルに文字列で表すと、6脚の椅子は、’椅子A’,’椅子B’,’椅子A’,’椅子B’,’椅子A’,’椅子B’で表されます。’椅子A’の次には必ず’椅子B’があり、文字列の数が椅子の数を表しています。この並びは、次の配列で表せます。

[‘椅子A’,’椅子B’,’椅子A’,’椅子B’,’椅子A’,’椅子B’]

またロボットは椅子を椅子Aと椅子Bのセットで並べるので、[‘椅子A’,’椅子B’]と考えることができます。すると、6脚の椅子は、配列[‘椅子A’,’椅子B’]を要素として3つ持つ配列と考えることもできます。

[[‘椅子A’,’椅子B’],[‘椅子A’,’椅子B’],[‘椅子A’,’椅子B’]]

椅子の並びを配列で表すと考えると、最初椅子は並んでいないので空の配列でスタートし、ロボットがその配列に配列[‘椅子A’,’椅子B’]を、指定された繰り返し回数だけ追加する、と考えるのが論理的であるように思えます。

これをコードにすると次のようになります。

let chairs = ['椅子A', '椅子B']; // ロボットが並べる椅子の種類
let lineUpChairs = []; // ロボットが並べた椅子の配列を入れる配列
let repeatNum = 3; // 繰り返し回数

// 繰り返し回数だけ、
for (let i = 0; i < repeatNum; i++) {
    // 配列lineUpChairsに、配列['椅子A', '椅子B']を追加する
    lineUpChairs.push(chairs);
}

繰り返し回数をテキストボックスで指定し、ボタンのクリックでロボットが[‘椅子A’, ‘椅子B’]を空の配列に追加するコードは次のように記述できます。

let chairs; // ロボットが並べる椅子の種類
let lineUpChairs; // ロボットが並べた椅子の配列を入れる配列
let textField; // 入力用テキストボックス

function setup() {
    noCanvas();
    chairs = ['椅子A', '椅子B']; // ロボットは1度に'椅子A'と'椅子B'を並べる
    lineUpChairs = [];
    // 入力用テキストボックスを作成
    textField = createInput('1');
    textField.size(50);
    textField.position(20, 20);
    textField.style('text-align', 'center');
    textField.elt.focus();
    // [START!]ボタン
    const startButton = setButton('START!', {
        x: 20,
        y: 50
    });
    // マウスプレスで、テキストボックスに入力された値を取得し、
    // それをlineupChairs()関数に渡して呼び出す
    startButton.mousePressed(() => {
        lineUpChairs = []; // 2回め以降は初期化する必要がある
        const num = int(textField.value()); // 文字列を数値化
        lineupChairs(num);
    });
}

// repeatNum回だけ繰り返し、chairs配列をlineUpChairs配列に追加する
function lineupChairs(repeatNum) {
    for (let i = 0; i < repeatNum; i++) {
        lineUpChairs.push(chairs);
    }
    print(lineUpChairs); // 2次元配列。要素数はrepeatNum個
    // 椅子の数は要素数 x  2
    print('椅子を' + lineUpChairs.length * 2 + '脚並べた');
}

function setButton(label, pos) {
    const button = createButton(label);
    button.size(80, 40);
    button.position(pos.x, pos.y);
    return button;
}

下図はこの実行結果です。

lineUpChairsの要素は配列chairs([‘椅子A’, ‘椅子B’])なので、lengthは3です。この3つの要素は配列で、それぞれが文字列の’椅子A’と’椅子B’を要素として持っているので、lineUpChairsが持つ’椅子’の数はlineUpChairs.length * 2で計算できます。したがって、10人が座る椅子を用意するには、空欄に5を入力すればよい、ということになります。

人数を指定して椅子を並べるプログラム

お題は繰り返し回数を聞いていますが、通常は、人が何人いるのでその分の椅子を並べろ、がロボットに対する命令でしょう。並べる椅子の数は当然、人の数と同じですが、ロボットは1度に椅子を2脚並べるので、奇数のときは人数よりも椅子の数が1脚多くなるはずです。次はこのプログラミングを見ていくことにしましょう。

ロボットは融通が効かないので、ロボットが1回で並べる数は固定の2です。繰り返し回数は、人数を2で割ればよいわけですが(繰り返し回数 = 椅子の数(人数) / 2)、2で割り切れないときは端数を切り上げる必要があります。たとえば、人数が5のとき、ロボットは、3回繰り返して椅子を6脚並べるはずです。

繰り返し回数 = 5 / 2 = 2.5 => 小数点以下を切り上げて3

これはコードで次のように記述できます。

const robotLineupChairsOnce = 2; // ロボットが1回で椅子を並べる数
// 繰り返すべき回数 => 割った端数は切り上げる必要がある ex) 2.5 => 3
let repeatNum = ceil(peopleNum / robotLineupChairsOnce);
for (let i = 0; i < repeatNum; i++) {
    lineUpChairs.push(chairs);
}

ceil()関数は小数点以下を切り上げます。ceilには、切り上げのイメージが感じられる”天井”という意味があります。ceil()の逆はfloor()です。floorは”床”の意味で、切り下げるイメージが感じられます。

ceil()

説明
パラメータの値以上の、最も近い整数値を計算する。Math.ceil()と同じ。たとえばceil(9.03)は値10を返す
シンタックス
Syntax
ceil(n)
パラメータ
n 数値: 切り上げる数値
戻り
整数: 切り上げられた数値

次のコードは、前のプログラムの改良版です。テキストボックスに人数を入力してボタンをクリックすると、ロボットが繰り返し回数を計算して椅子を並べます。奇数の人数を入力すると、ロボットは人数より1脚多い椅子を並べます。

let chairs;
let lineUpChairs;
let textField;

function setup() {
    noCanvas();
    chairs = ['椅子A', '椅子B'];
    lineUpChairs = [];
    // 入力用テキストボックスを作成
    textField = createInput('1');
    textField.size(50);
    textField.position(20, 20);
    textField.style('text-align', 'center');
    textField.elt.focus();
    // [START!]ボタン
    const startButton = setButton('START!', {
        x: 20,
        y: 50
    });
    // 今度はlineupChairs()には人数を渡すが、コードに変更はない!
    startButton.mousePressed(() => {
        lineUpChairs = [];
        const num = int(textField.value()); // 人数
        lineupChairs(num);
    });
}

// ここで受け取るのは人数
function lineupChairs(peopleNum) {
    const robotLineupChairsOnce = 2; // ロボットが1回で椅子を並べる数
    // 繰り返すべき回数 => 割った端数は切り上げる必要がある ex) 2.5 => 3
    let repeatNum = ceil(peopleNum / robotLineupChairsOnce);
    for (let i = 0; i < repeatNum; i++) {
        lineUpChairs.push(chairs);
    }
    print(lineUpChairs); // 2次元配列。要素数はrepeatNum個
    // 椅子の数は要素数 x  2
    print('ロボットは' + repeatNum + '回繰り返して、椅子を' + lineUpChairs.length * 2 + '脚並べた');
}

function setButton(label, pos) {
    const button = createButton(label);
    button.size(80, 40);
    button.position(pos.x, pos.y);
    return button;
}

下図をテキストボックスに5を入力したときの実行結果です。ロボットはあくまでも1回で椅子を2脚並べるので、座れない人をなくそうとすると、人数が奇数の場合、どうしても1脚多くなります。

可視化

では、上記の「人数を指定して椅子を並べるプログラム」を可視化に取り組んでみましょう。下図に示すように、テキストボックスに人数を入力しボタンをクリックすると、その下に人のイメージと椅子のイメージが平行に描画されるというものです。

理論的には人数は何百人でも入力できますし、椅子も椅子Aと椅子Bのイメージを用意すればよいので、いくつでも描画できます。しかし人のイメージは理論的には全部異なるはずです(同じ人は2人いないので)、用意できるイメージの数には限界があります。ここでは10個準備できるということにします。

人数を指定して椅子を並べるプログラム(10人まで)

人のイメージはそれぞれの画像をloadImage()関数で読み込み、イメージ専用の配列に入れて、指定人数分のイメージをdraw()関数で描画します。コードは次のものが記述できます。

let peopleImages = [];
...
// preload()関数内
const peopleImageNum = 10;
for (let i = 0; i < peopleImageNum; i++) {
    loadImage('images/p' + i + '.png', (img) => {
        peopleImages.push(img);
    });
}

// draw()関数内
for (let i = 0; i < peopleNum; i++) {
    image(peopleImages[i], 50 * i + 20, 100);
}

読み込む画像ファイルが多い場合には、forループが役立ちます。このとき画像ファイルは同一フォルダに入れ、下図に示すように、0から始まる連番の名前を付けておく(p0,p1,p2…)と、loadImage(‘images/p’ + i + ‘.png’)で一括してロードできます。

loadImage()関数は読み取ったイメージをp5.Imageオブジェクトとして返しますが、イメージを取得する別の方法として、loadImage()の2つめの引数にコールバック関数を指定し、その関数の引数として取得する方法もあります(「6_3:コールバック関数の使用 p5.js JavaScript」参照)。このコールバック関数はイメージの取得が完了した時点で呼び出されます。

forループを使ったときのイメージの取得で1つ覚えておくべきことがあります。それは、読み取るファイズのサイズがまちまちの場合、早く読み取りを開始したファイルのイメージが、後のイメージよりも先にイメージ化されるとは、必ずしも限らないということです。これは、イメージを追加する配列要素の順番に影響します。

preload()関数で、forループを使わずイメージを1つずつ取得して配列に追加した場合には、イメージはその順番で追加されます。今の場合、人のイメージの順番は問わないので問題になりませんが、配列内のイメージの順番を決め込みたい、たとえば椅子のイメージの場合には、原則的にforループは使わず、下記のように1つずつ読み込むようにします。

let chairImages; // 椅子のイメージを入れる配列
...
// preload()関数内
// chairImages配列では、イメージを椅子A、椅子Bの順番にしたいので、
// その順番で1つずつ読み込んで配列に追加する
const c1 = loadImage('images/chair0.png');
const c2 = loadImage('images/chair1.png');
chairImages = [c1, c2];

椅子のイメージは椅子Aと椅子Bに対応するので、setup()関数内で、chairs配列にObjectオブジェクトとして入れます。これにより椅子Aのイメージはchairs[0].imageで、椅子Bのイメージはchairs[1].imageで参照できるようになります。

// setup()関数内
// chairsをイメージ対応に変更
chairs = [{
    type: '椅子A',
    image: chairImages[0]
}, {
    type: '椅子B',
    image: chairImages[1]
}, ];

またdraw()関数では人のイメージを人数分だけ描画するので、人数を表す変数peopleNumを用意します。このpeopleNumはテキストボックスの作成時や、スタートボタンのマウスプレス時にも使用します。

// 人のイメージの描画で必要(テキストボックスで入力される数字用変数)
let peopleNum = 1;
...
// setup()関数内
// 入力用テキストボックスを作成
textField = createInput(peopleNum);
...
// [START!]ボタン
startButton.mousePressed(() => {
    lineUpChairs = [];
    // 人数をpeopleNumに代入
    peopleNum = int(textField.value()); // 人数
    lineupChairs(peopleNum);
});

lineupChairs()関数の論理は描画に関係ないので、変更はありません。切り上げた繰り返し回数だけ、lineUpChairs配列にchairs配列を追加します。前日したdraw()関数での椅子の描画に移ります。

やっかいな問題

ここまで視覚化は順調に進んでいますが、実は椅子の描画でやっかいな問題に出くわします。peopleImages配列は要素として直接イメージを入れた単純な配列なので、前述したようにforループの変数iで容易に参照できます(peopleImages[i])。しかし、lineUpChairsは、chairs配列を要素として持つ2次元配列なので、peopleImages[i]のように簡単には椅子のイメージが参照できないのです。

椅子のイメージまでたどり着くにはforループを2つ重ねます。

  1. lineUpChairsの長さだけループし、lineUpChairsに含まれるchairs配列を参照する
  2. chairs配列の長さだけループし、chairs配列に含まれる椅子のオブジェクトを参照する
  3. このオブジェクトのimageプロパティを使って参照する
  4. さらに、今の場合には’椅子A’と’椅子B’は交互に描画するので、場合分けをして描画位置をずらす必要があります。

  5. 椅子のオブジェクトのtypeプロパティを使って’椅子A’と’椅子B’を区別する
  6. ‘椅子A’の場合には、横に1つ飛ばしに描画する
  7. ‘椅子B’の場合には、横に1つ飛ばししてさらにずらした位置に描画する

コードは次のようになります。

for (let i = 0; i < lineUpChairs.length; i++) {
    // lineUpChairsの要素である配列
    const chairsArray = lineUpChairs[i];
    // elementArrayの長さは2
    for (let j = 0; j < chairsArray.length; j++) {
        // elementArrayの要素は椅子のオブジェクト
        const chair = chairsArray[j];
        // "椅子A"と"椅子B"に分け、位置をずらして描画
        if (chair.type === '椅子A') {
            image(chair.image, 50 * i * 2 + 20, 150);
        }
        else if (chair.type === '椅子B') {
            const offset = 50;
            image(chair.image, 50 * i * 2 + 20 + offset, 150);
        }
    }
}

今後何か手を加えたくなったときにはもう内容を忘れているような、ずいぶん複雑なコードです。

解決策
上記のコードでも動作に問題はありませんが、もっと単純に描画できる解決策があります。コードをやっかいにしているのはlineUpChairsが2次元配列であることなので、これを単純な1次元配列に変換するという方法です。

これはJavaScriptのArray.flat()メソッドで実に簡単に実行できます。

lineupChairs()関数でlineUpChairsを2次元配列にした後、lineUpChairs.flat()を呼び出して結果をlineUpChairsに代入するだけです。

let repeatNum = ceil(peopleNum / robotLineupChairsOnce);
for (let i = 0; i < repeatNum; i++) {
    lineUpChairs.push(chairs);
}
print(lineUpChairs); // 2次元配列。要素数はrepeatNum個
print('ロボットは' + repeatNum + '回繰り返して、椅子を' + lineUpChairs.length * 2 + '脚並べた');
// 2次元配列を1次元配列に平滑化して、lineUpChairsを上書きする
lineUpChairs = lineUpChairs.flat();
print(lineUpChairs); // 1次元配列

下図は上記コードの実行例です。2次元配列が1次元配列に変化していることが分かります。

lineUpChairsは単純な1次元配列になったので、人のイメージのpeopleImages配列と同様、次の単純なforループで椅子のイメージが描画できるようになります。

for (let i = 0; i < lineUpChairs.length; i++) {
    image(lineUpChairs[i].image, 50 * i + 20, 150);
}

配列のflat()メソッドを使った方が、描画コードがはるかに単純になるので、優れた方法だと言えます。と同時に、これは、lineupChairs()関数でlineUpChairs配列に直接chairs配列を追加するのではなく、chairs配列の要素を1つずつ追加して、lineUpChairs配列を2次元配列にしない方がよかったということでもあります。

// lineUpChairs配列を2次元配列にしない
function lineupChairs(num) {
    const robotLineupChairsOnce = 2;
    let repeatNum = ceil(num / robotLineupChairsOnce);
    for (let i = 0; i < repeatNum; i++) {
	// chairsの要素を1つずつ追加する
        const chair1 = chairs[0];
        const chair2 = chairs[1];
        lineUpChairs.push(chair1);
        lineUpChairs.push(chair2);
    }
    print(lineUpChairs);
    print('ロボットは' + repeatNum + '回繰り返して、椅子を' + lineUpChairs.length + '脚並べた');
}

ロボットは椅子を1度に2脚並べるので、lineUpChairs配列に直接chairs配列を追加するのは正しい論理ですが、chairs配列の要素は不変で、順番も変わらないので、ことさら配列でくくる必要もなかったと言えます。

とは言えここまでたどってきたことは決して無駄ではありません。2次元配列の扱い方やflat()メソッドの使い方を覚えました。これらは今後、プログラミングするときの武器の1つになるでしょう。

人数を指定して椅子を並べるプログラム(10人以上も可)

おそらくお気づきでしょうが、draw()関数では、指定された人数分だけpeopleImagesからのイメージを描画しているので、指定人数が10人を超えるとエラーになります。

for (let i = 0; i < peopleNum; i++) {
    image(peopleImages[i], 50 * i + 20, 100);
}

下図の英文は「未定義の’width’プロパティの読み取りができない」という意味です。peopleImages配列には10個にイメージが入っていて、peopleNumがたとえば11になると、peopleImages[10]は存在しないので、存在しないもののプロパティを読み取れません。

人のイメージ数を超える指定人数でもエラーが出ないようにする手っ取り早い方法は、また別の配列を用意し、そこに10人分のイメージと超えた人数分のイメージを保持する方法です。超えた人数分のイメージを追加するとき、peopleImagesからランダムにイメージを選択するようにすると、同じイメージが連続する可能性が低くなります。

以下は10人を超える人数も指定できるプログラムの全コードで、2次元配列を使わないバージョンです。

/*
10人以上指定できる = 人のイメージが10個なので、使いまわす
*/
// 配列のflat()を使用しないバージョン

let chairs; // ロボットが並べる椅子の種類
let lineUpChairs; // ロボットが並べた椅子を入れる配列
let textField; // 入力用テキストボックス

let peopleNum = 1; // 最初の指定人数
let peopleImages = []; // 人のイメージ10個を入れる配列
let chairImages = []; // 椅子のイメージ2個を入れる配列

let manyPeopleImages = []; // 人のイメージの描画に使用する配列

function preload() {
    const peopleImageNum = 10;
    // 画像ファイルはforループで読み込めるが、配列に追加される順番は必ずしも一定ではない。
    // 配列には読み込みが早かったものから順に追加される。
    for (let i = 0; i < peopleImageNum; i++) {
        loadImage('images/p' + i + '.png', (img) => {
            peopleImages.push(img);
        });
    }
    // chairImages配列では、イメージを椅子A、椅子Bの順番にしたいので、
    // その順番で1つずつ読み込んで配列に追加する
    const c1 = loadImage('images/chair0.png');
    const c2 = loadImage('images/chair1.png');
    chairImages = [c1, c2];
}

function setup() {
    createCanvas(1000, 300);
    // 椅子の種類を表す文字列とそのイメージを持つオブジェクトをまとめた配列
    chairs = [{
            type: '椅子A',
            image: chairImages[0]
        }, // 椅子Aは0番め
        {
            type: '椅子B',
            image: chairImages[1]
        }, // 椅子Bは1番め
    ];
    // 暫定的にpeopleImagesを割り当てておく
    manyPeopleImages = peopleImages;
    lineUpChairs = [];
    // 人数を入力する
    textField = createInput(peopleNum);
    textField.size(50);
    textField.position(20, 20);
    textField.style('text-align', 'center');
    textField.elt.focus();
    // [START!]ボタン
    const startButton = setButton('START!', {
        x: 20,
        y: 50
    });
    // マウスプレスで、テキストボックスに入力された値を取得し、
    // それをlineupChairs()関数に渡して呼び出す
    startButton.mousePressed(() => {
        lineUpChairs = [];
        peopleNum = int(textField.value());
        // 指定数が10人以下の場合
        manyPeopleImages = peopleImages;
        // 指定数が10人を超えていたら
        if (peopleNum > peopleImages.length) {
            // その差を計算して
            const diff = peopleNum - peopleImages.length;
            // 差分の回数だけ
            for (let i = 0; i < diff; i++) {
                // 0-10の整数をランダムに作成し
                const rndNum = int(random(peopleImages.length));
                // peopleImagesからのランダムなイメージをmanyPeopleImagesに追加する
                manyPeopleImages.push(peopleImages[rndNum]);
            }
        }
        lineupChairs(peopleNum);
    });
}

function draw() {
    background(220);
    // peopleImagesをmanyPeopleImagesに変更
    for (let i = 0; i < peopleNum; i++) {
        image(manyPeopleImages[i], 50 * i + 20, 100);
    }
    // lineUpChairsは1次元配列なので描画が簡単
    for (let i = 0; i < lineUpChairs.length; i++) {
        image(lineUpChairs[i].image, 50 * i + 20, 150);
    }
}

// ここで受け取るのは人数
function lineupChairs(num) {
    const robotLineupChairsOnce = 2; // ロボットが1回で椅子を並べる数
    // 繰り返すべき回数 => 割った端数は切り上げる
    let repeatNum = ceil(num / robotLineupChairsOnce);
    for (let i = 0; i < repeatNum; i++) {
        // chairsの要素を1つずつ追加する => 複雑な2次元配列にしない
        const chair1 = chairs[0];
        const chair2 = chairs[1];
        lineUpChairs.push(chair1);
        lineUpChairs.push(chair2);
    }
    print(peopleNum + '人分の椅子を用意するために、ロボットは' + repeatNum + '回繰り返して、椅子を' + lineUpChairs.length + '脚並べた');
}

function setButton(label, pos) {
    const button = createButton(label);
    button.size(80, 40);
    button.position(pos.x, pos.y);
    return button;
}

数は、10人より多い17人を指定したときの実行画面と出力結果です。

コメントを残す

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

CAPTCHA