プログラミング はじめの一歩 JavaScript + p5.js編
8:番外編 売れたスイーツを補充する

本稿は、毎日新聞の「プログラミング・はじめの一歩」とは関係のないオリジナル記事です。

概要

次の5品を専門に売るスイーツ店があります。

  • あめ:¥50
  • だんご:¥150
  • アイスクリーム:¥200
  • パフェ:¥450
  • ショートケーキ:¥300

お客は5品の中から1つ買いたいスイーツを選択します。店の棚からは一時的にそのスイーツがなくなりますが、すぐに補充されます。下図は動作の例です。

次のように動作しています。

  1. スイーツは<select>要素で選択。ここでは[だんご]を選択している
  2. 選択すると、選択したスイーツが棚からなくなる。ここでは左から2つめにあっただんごがなくなっている
  3. 少し待つと補充される。購入合計金額も表示される。だんごが棚の左端に補充され、左上に¥150が表示されている
論理を考える

さて、このプログラムの裏ではどんな論理が働いているのでしょう? 慣れてくると、舞台裏の大まかな論理は想像がつくようになります。ただしそのためには相応の知識を身につけ、経験を積む必要があります。

上図の(1)、(2)、(3)を見ると、たとえば「だんごが選ばれたときにはだんごのイメージを非表示にして、右の3つのイメージを左に移すのか?」とか「だんごを補充するときには、ほかの4つを右に移動させるのか?」などと考えてしまうかもしれません。しかし表示や移動というのは見た目の話であって、プログラムの根本的な論理ではありません。

プログラミングを始めるとっかかりを探すには、物事をシンプルにするところから始めます。これは、前の「1:おかしに当たり券を入れる」の「物事をどう表すか?」でも述べています。スイーツは5種あるので、これらを文字列で表すところから始めます。

上図の(1)は次のように表すことができます。

スイーツは棚に並べるので、そこには順番があります。一番左は’あめ’で、その右は’だんご’です。これはつまり、スイーツはそれぞれ自分の位置を持っているということです。このように考えてくると、スイーツの文字列を配列に入れてはどうか? という発想が浮かびます。

すると上図の(2)は、この配列から、インデックス位置1にある要素を削除すればよいことになります。

同様に上図の(3)は、(2)の配列の先頭(インデックス位置0)に’だんご’を追加すればよいわけです。

実は、この配列要素の出し入れに気づくことができれば、このお題の80%はクリアできたと言えます。

配列のメソッド

配列について行いたいことはその配列のメソッドで行えます。配列のメソッドはp5.jsの機能ではなく、JavaScriptのArrayオブジェクト(配列)が持つ機能です。メソッドはそのオブジェクトで実行するアクションです。ArrayオブジェクトについてはMDN web docsサイトの「Array」項に詳しく書かれています。

配列の要素を削除するメソッドは複数あります。以下で簡単に見ていきます。

Array.shift()
Array.shift()メソッドはその配列の先頭の要素を削除し、削除した要素を返します。

let sweets = ['あめ', 'だんご', 'アイスクリーム', 'パフェ', 'ショートケーキ'];
//配列の先頭要素を削除して返す
const first = sweets.shift();
print(first); // あめ
print(sweets); // ["だんご", "アイスクリーム", "パフェ", "ショートケーキ"]

Array.pop()
Array.pop()メソッドはその配列の最後の要素を削除し、削除した要素を返します。

// 配列の最後の要素を削除して返す
const last = sweets.pop();
print(last); // ショートケーキ
print(sweets); // ["あめ", "だんご", "アイスクリーム", "パフェ"]

Array.splice()
Array.splice()メソッドは少し複雑で、指定された要素を削除します。引数には、削除を開始する位置と削除する要素の数が指定できます。残った要素は位置が前に詰められます。

// 削除する要素を指定 (0番めから要素を1個削除)
let deleted = sweets.splice(0, 1);
print(deleted); // ["あめ"] => これは配列
print(sweets); // ["だんご", "アイスクリーム", "パフェ", "ショートケーキ"]

// 削除する要素を指定 (1番めから要素を2個削除)
deleted = sweets.splice(1, 2);
print(deleted); // ["アイスクリーム", "パフェ"] => これは配列
print(sweets); // ["だんご", "ショートケーキ"]

Array.filter()
Array.filter()メソッドはさらに複雑で、その配列の各要素にアクセスして、引数に指定された関数で各要素をテストします。そしてテストに合格した要素を残し、不合格の要素を削除して、合格した要素を持つ新しい配列を作成します。下の例の場合には、’アイスクリーム’でない要素が残るので、結果としてsweets配列から’アイスクリーム’が削除されたことになります(sweetsは上書きされる)。

sweets = sweets.filter((item) => {
    // このコールバック関数がtrueを返した要素は残され、falseを返した要素は削除される
    // 'アイスクリーム'でないitemを返す
    return item !== 'アイスクリーム';
});
print(sweets); // ["あめ", "だんご", "パフェ", "ショートケーキ"]
選択された項目を配列から削除し、同じ項目をまた追加する

このプログラムの根本の論理を考えたとき、スイーツのイメージの描画や売れた金額の合計は根本の論理ではありません。。根本は「選択された項目を配列から削除し、同じ項目をまた追加する」という論理です。

具体的に言うと、最初[‘あめ’, ‘だんご’, ‘アイスクリーム’, ‘パフェ’, ‘ショートケーキ’]であった配列が、<select>要素での[だんご]項目を選択した場合には、[‘あめ’, ‘アイスクリーム’, ‘パフェ’, ‘ショートケーキ’]となり、次いで[ ‘だんご’, ‘あめ’,’アイスクリーム’, ‘パフェ’, ‘ショートケーキ’]になるというこです。

まずはスイーツの配列と、項目の選択を行う<select>要素を作成します。

// 販売しているスイーツのリスト
let sweets = ['あめ', 'だんご', 'アイスクリーム', 'パフェ', 'ショートケーキ'];

function setup() {
  noCanvas();
  // <SELECT>要素を作成
  select = createSelect();
  select.position(10, 10);
  select.size(130, 30);
 ...
}

p5.jsでは、createSelect()関数を呼び出すだけでHTMLフォームの<select>要素がページ上に作成できます。position()とsize()メソッドは<select>要素の表示位置と大きさを設定します。

次いで<select>要素で選択できる選択肢をoption()メソッドで設定します。option()には選択肢の名前を指定しますが、オプションとしてその選択肢の値も指定できます。たとえばselect.option(‘あめ’)の場合、この選択肢が選ばれると、現在選ばれている値は文字列の’あめ’になります。select.option(‘あめ’, 0)とすると、選択肢[あめ]を選んだときの値は文字列でなく数字の’0’になります。 人間にとって分かりやすいのは前者でしょう。

// オプションの項目
select.option('スィーツを選択');
select.option('あめ');
select.option('だんご');
select.option('アイスクリーム');
select.option('パフェ');
select.option('ショートケーキ');

そして選択肢が選ばれたときにどうするかを決めます。p5.jsが作る<select>要素は、選択に変更があったときに自動的に呼び出されるchanged()メソッドを持っているので、これを使用します。さて選択に変更があったとき、つまり選択項目の[だんご]が選ばれたときどうすればよいのでしょう? 答えはもちろん、配列から’だんご’を削除し、その後’だんご’を配列の先頭に追加する、です。

項目の削除にArray.filter()を、追加にArray.shift()を使用すると、次のようなコードが記述できます。

select.changed(() => {
    // 今選択されている項目
    const selectedItem = select.value();
    print(selectedItem + 'が売れた');
    // 売れたのでsweetsからその項目を削除する
    sweets = sweets.filter((item) => {
        return item !== selectedItem;
    });
    print(sweets);
    print(selectedItem + 'を補充する');
    // 売れた項目を配列の先頭に追加する。
    sweets.unshift(selectedItem);
    print(sweets);
});

そしてここまでのコードを実行し、[だんご]を選択すると、下図の結果が[コンソール]に表示されます。これは期待通りの完璧な結果に見えます。

しかし残念ながら完璧ではありません。一番上の[スィーツを選択]項目を選ぶと、配列に’スィーツを選択’が含まれてしまいます。コンピュータにとって’あめ’も’スィーツを選択’も同じ文字列なので、’スィーツを選択’はスイーツではないだろうと言っても通用しません。

またchanged()に渡す関数で行う事柄が多いので、関数を別の作成してそれを呼び出す方が、役割がはっきりします。そこでsoldという名前の新しい関数を作成することにします。changed()の関数では選択に変化があったときsold()を呼び出し、sold()はスイーツが売れたときに配列を操作する、という役割分担です。

さらに、ここではsweets配列を直接操作し、sweets.filter()でsweetsを上書きしています。このとき一瞬ではありますが、スイーツを表す文字列が失われます。sweetsをこの店で販売しているスイーツのリストだと考えたとき、sweetsを直接操作するのはよくない、という考え方ができます。そこで、現在の棚の状況を表す配列を作成し、これを操作するように変更します。

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

// 売れたスイーツを補充する
// 選択された項目を配列から削除し、同じ項目をまた追加する

// 販売しているスイーツのリスト
let sweets = ['あめ', 'だんご', 'アイスクリーム', 'パフェ', 'ショートケーキ'];
// 購入に使用する<select>要素
let select;
// 現在の陳列棚の状況。この配列を操作する(sweetsではなく)
let currents = ['あめ', 'だんご', 'アイスクリーム', 'パフェ', 'ショートケーキ'];

function setup() {
    noCanvas();
    // <SELECT>要素を作成
    select = createSelect();
    select.position(10, 10);
    select.size(130, 30);
    // オプションの項目
    select.option('スィーツを選択');
    select.option('あめ');
    select.option('だんご');
    select.option('アイスクリーム');
    select.option('パフェ');
    select.option('ショートケーキ');
    // <SELECT>要素で選択したら
    select.changed(() => {
        // sold()関数に選択項目の値を渡して呼び出す
        sold(select.value());
    });
}

//  <SELECT>要素での選択に変化があったら呼び出される
function sold(selectedItem) {
    // オプション('スィーツを選択')は除外
    // return文を実行すると、関数の実行ははそこで終わり、return以降は実行されない
    if (selectedItem === 'スィーツを選択') return;

    print(selectedItem + 'が売れた');
    // 売れたのでcurrentsからその項目を削除する
    currents = currents.filter((item) => {
        return item !== selectedItem;
    });
    print(currents);
    print(selectedItem + 'を補充する');
    // 売れた項目を配列の先頭に追加する。
    currents.unshift(selectedItem);
    print(currents);
}

ここでは現在の棚の状況を表す配列として新たにcurrentsを作成し、最初の値として、sweetsと同じ要素を指定しています。要素を削除したり追加したりするのはこのcurrentsです。

sold()関数には、今選択されている項目の情報が必要なので、select.changed()の引数の関数で、sold()関数にselect.value()を渡しています。

sold()関数本体({と}の間)では最初に、if (selectedItem === ‘スィーツを選択’) return; というコードを追加しています。これは、選択項目として’スィーツを選択’が選ばれたとき、’スィーツを選択’を配列に追加しないようにする処置です。returnを実行すると以降のコードを実行せず、関数を抜けることができます。

ここまでで、このお題の根本的な論理とした「選択された項目を配列から削除し、同じ項目をまた追加する」プログラムが記述できました。次は販売の合計金額に着手します。

販売の合計金額

このプログラムでは1度に複数個の同じスイーツを買うことはできないので、販売したスイーツの合計金額は、スイーツが売れるたびにスイーツの単価を加算していくことで算出できます。グローバル変数のtotalを初期値0で作成しておいて、sold()関数内でスイーツが売れるたびに、totalに売れたスイーツの単価を足していけばよいわけです。

let total = 0;

合計金額の論理自体は簡単そうです。問題は単価をどうやってプログラムに持ち込むかです。sold()関数内で、もしselectedItemが’あめ’ならtotalにあめの単価の50円を足し、そうでなくselectedItemが’だんご’ならtotalにだんごの単価の100円を足し、そうでなく…というif…else ifをつなげる方法もありますが、コードがかなり長くなります。また単価を変えたい場合には、(大変な作業ではないにせよ)、長いif…else ifを読んで該当箇所を修正する必要があります。

sweets配列を思い出してください。sweetsでは文字列の’あめ’があめを表しています。これをObjectオブジェクトに置き換えるのです。あめだとするObjectオブジェクトにname属性とprice属性を設定し、それぞれに’あめ’と50という値を与えます。するとnameが’あめ’でpriceが50というあめオブジェクトができます。これをsweets配列の最初に入れると、sweets[0].nameで’あめ’が、sweets[0].priceで50が参照できるようになります。

let sweets = [
  { name: 'あめ', price: 50 },
  { name: 'だんご', price: 150 },
  { name: 'アイスクリーム', price: 200 },
  { name: 'パフェ', price: 450 },
  { name: 'ショートケーキ', price: 300 },
]

ではcurrentsはどうしましょう? currentsは現在の棚の状況を表す配列なのでsweetsのオブジェクトを入れるのが理にかなっているように思えます。しかし残念ながら問題に突き当たります。

それは、<select>要素で選んだ値(select.value())は文字列になるという問題です。そもそも<select>要素は、select.option()で指定する選択項目の名前かその値(文字列)を処理するよう設計されているのです(HTMLの<select>要素の仕様)。

しかし、select.value()が返す文字列を元のオブジェクトに戻す方法を探るより、もっとスマートで、一般的な方法があります。それはselect.option()の第2引数に、そのオブジェクトのsweets配列でのインデックス位置を渡す方法です。たとえばあめオブジェクトならインデックス位置が0なので0を渡します。sold()関数では0を受け取るので、それがあめだと分かります。

select.option('スィーツを選択', -1);
select.option(sweets[0].name, 0);
select.option(sweets[1].name, 1);
select.option(sweets[2].name, 2);
select.option(sweets[3].name, 3);
select.option(sweets[4].name, 4);

となると、currentsはsweetsでのインデックス位置で各オブジェクトを表すことになります。たとえば、あめ、だんご、…とオブジェクトが5つ並ぶcurrentsは[0, 1, 2, 3, 4,5]です。

// 現在の陳列棚の状況
let currents = [0, 1, 2, 3, 4];

ここまで述べてきた変更を加えたコードは次のようになります。

let sweets = [
  { name: 'あめ', price: 50 },
  { name: 'だんご', price: 150 },
  { name: 'アイスクリーム', price: 200 },
  { name: 'パフェ', price: 450 },
  { name: 'ショートケーキ', price: 300 },
]

let total = 0;

// <select>要素
let select;
// 現在の陳列棚の状況
let currents = [0, 1, 2, 3, 4];

function setup() {
    noCanvas();
    // <SELECT>要素を作成
    select = createSelect();
    select.position(10, 10);
    select.size(130, 30);
    select.option('スィーツを選択', -1);
    select.option(sweets[0].name, 0);
    select.option(sweets[1].name, 1);
    select.option(sweets[2].name, 2);
    select.option(sweets[3].name, 3);
    select.option(sweets[4].name, 4);
    // <SELECT>要素で選択したら
    select.changed(() => {
        // sold()関数に選択された項目が対応する値を渡して呼び出す
        // 値は数字(文字列)なのでint()関数で数値に直す
        sold(int(select.value()));
    });
}

//  <SELECT>要素での選択変化から呼び出される
function sold(selectedItemNum) {
    // オプション-1('スィーツを選択')は除外
    if (selectedItemNum === -1) return;
    print(sweets[selectedItemNum].name + 'が売れた');
    // priceプロパティ値が数値であることを確認
    // => 加算するので数値でなくてはならない
    print(typeof sweets[selectedItemNum].price);
    // いくらかの確認
    print(sweets[selectedItemNum].price);
    // スイーツの単価を加算する
    total += sweets[selectedItemNum].price;
    print(total)

    // 売れたのでcurrentsからその項目を削除する
    currents = currents.filter((item) => {
        return item !== selectedItemNum;
    });
    print(currents);

    print(sweets[selectedItemNum].name + 'を補充する');
    // 売れた項目を配列の先頭に追加する。
    currents.unshift(selectedItemNum);
    print(currents);
}

下図は[だんご]を選んだときの結果です。

150というのはだんごの単価で、正しく出力されていることが確認できます。currentsは0から4までの数値を含んだ配列となります。[0, 2, 3, 4]は今の棚にあめ、アイスクリーム、パフェ、ショートケーキが並んでいることを表しています。

最後は視覚化です。スイーツのイメージを読み込み、currentsが持つ現在の数値に対応するイメージを描画します。また合計金額の描画と冒頭の図の(3)で見た「選択したスイーツが棚からなくなり、少し待つと補充される」という動作も実装します。

視覚化

スイーツの画像ファイルはpreload()関数で読み込みます。そしてスイーツのオブジェクトのimageプロパティとして設定します。

プログラムのコードは次のようになります。

// 売れたら補充するスイーツ店
// 陳列するスイーツのリスト
let sweets;
// <select>要素
let select;
// 現在の陳列棚の状況
let currents = [0, 1, 2, 3, 4];

let total = 0;

// スイーツのイメージを保持する配列
let sweetsImages;

function preload() {
    const ameImage = loadImage('images/ame.png');
    const dangoImage = loadImage('images/dango.png');
    const icecreamImage = loadImage('images/icecream.png');
    const parfaitImage = loadImage('images/parfait.png');
    const shorcakeImage = loadImage('images/shortcake.png');

    sweets = [
        { name: 'あめ', price: 50, image: ameImage },
        { name: 'だんご', price: 150, image: dangoImage },
        { name: 'アイスクリーム', price: 200, image: icecreamImage },
        { name: 'パフェ', price: 450, image: parfaitImage },
        { name: 'ショートケーキ', price: 300, image: shorcakeImage },
      ]
}

function setup() {
    createCanvas(400, 200);
    noStroke();

    // <SELECT>要素を作成
    select = createSelect();
    select.position(130, 150);
    select.size(130, 30);
    select.option('スィーツを選択', -1);
    select.option(sweets[0].name, 0);
    select.option(sweets[1].name, 1);
    select.option(sweets[2].name, 2);
    select.option(sweets[3].name, 3);
    select.option(sweets[4].name, 4);
    // <SELECT>要素で選択したら
    select.changed(() => {
        // sold()関数に選択された項目を渡して呼び出す
        sold(int(select.value()));
    });
}

function draw() {
    background(255);
    // 棚を描画
    fill(255, 153, 0);
    rect(10, 100, 380, 30);
    // 今、棚にあるスイーツを描画
    for (let i = 0; i < currents.length; i++) {
        image(sweets[currents[i]].image, i * 70 + 20, 75);
    }
    // 合計金額を左上に描画
    fill(0);
    text('合計: ¥' + total, 20, 20);
}

//  <SELECT>要素での選択変化から呼び出される
function sold(selectedItemNum) {
    // オプション-1('スィーツを選択')は除外
    if (selectedItemNum === -1) return;
    // 売れたのでcurrentsからその項目を削除する
    currents = currents.filter((item) => {
        return item !== selectedItemNum;
    });
    // 削除から追加まで2秒、間を取る
    const timerID = window.setTimeout(() => {
        // 売れた項目を配列の先頭に追加する。
        currents.unshift(selectedItemNum);
        total += sweets[selectedItemNum].price;
        window.clearTimeout(timerID);
    }, 2000);
}

論理が堅固であれば、視覚化はさほど難しくはありません。スイーツのオブジェクトのプロパティに、単価を加えたのと同じようにイメージを加えると、そのオブジェクトの描画に使用できます。

上記コードでは、「少し待つと補充される」の「少し待つ」の実現に、JavaScriptの(p5.jsの機能ではありません)window.setTimeout()メソッドを使って、売れた項目のcurrentsの先頭への追加と合計金額の更新を2秒後に送らせています。

下は上記コードの実行画面です。

最初文字列’あめ’で表していたあめは、nameとprice、imageプロパティを持つオブジェクトで表すようになりました。プロパティはいくつでも増やせるので、たとえば消費期限などもスイーツのオブジェクトに持たせることができます。

コメントを残す

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

CAPTCHA