本稿は、毎日新聞の「プログラミング・はじめの一歩」とは関係のないオリジナル記事です。
目次
概要
次の5品を専門に売るスイーツ店があります。
- あめ:¥50
- だんご:¥150
- アイスクリーム:¥200
- パフェ:¥450
- ショートケーキ:¥300
お客は5品の中から1つ買いたいスイーツを選択します。店の棚からは一時的にそのスイーツがなくなりますが、すぐに補充されます。下図は動作の例です。
次のように動作しています。
- スイーツは<select>要素で選択。ここでは[だんご]を選択している
- 選択すると、選択したスイーツが棚からなくなる。ここでは左から2つめにあっただんごがなくなっている
- 少し待つと補充される。購入合計金額も表示される。だんごが棚の左端に補充され、左上に¥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プロパティを持つオブジェクトで表すようになりました。プロパティはいくつでも増やせるので、たとえば消費期限などもスイーツのオブジェクトに持たせることができます。