プログラミング はじめの一歩 JavaScript + p5.js編
13:かめを動かそう

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

概要

5 x 5 のマス目の上にかめのロボットがいます。かめには次の3つの命令を出すことができ、かめは自分の動いた跡に太い線を引きます。

  1. 指定されたマス数分、前進する:前進(num)
  2. その場で左を向く:左回り
  3. その場で右を向く:右回り

下図をスタート位置として、前進(3)と左回りを4回繰り返したとき、かめはどんな線を描くでしょう?

論理を考える

最初に、お題の答えを細かく考えてみます。命令は、3マス前進する[前進(3)]とその場で左を向く[左回り]を4回繰り返す、です。

  1. スタート位置から右に3コマ進んで左を向く(かめの頭は上に向く)
  2. 今かめは上を向いているので、上に向けて3コマ進んで左を向く(かめの頭は上に向く)
  3. 今かめは左を向いているので、左に向けて3コマ進んで左を向く(かめの頭は下に向く)
  4. 今かめは下を向いているので、下に向けて3コマ進んで左を向く(かめの頭は右に向く)

結果は下図のようになります。

お題だけ読むとそう難しくは思えませんが、プログラムにするにはなかなかの難度です。

指定されたマス分だけ前進するとは?

たとえば、1マスを50 x 50 のサイズにした場合、右を向いているかめが2マス分だけ前進する、ということは、移動前のかめの座標(x,y)のxに、50 x 2 = 100 を足すということです。かめの新しい座標は(x+100, y)になります。

かめはつねにグリッドの線上を移動して太い線を引きます。かめの位置決めにはかめの(x,y)座標を使用し、移動跡の太線の描画にもかめの(x,y)座標を使用します。これは、かめはかめのイメージをセンターにして描いた方が物事が単純になる、ということです。p5.jsのimage()関数はデフォルトでイメージの左上隅を(x,y)として描画しますが、setup()関数でimageMode(CENTER)を1度呼び出しておくだけで、イメージのセンターを(x,y)として描画できます。これにより、かめのイメージの位置決めや太線の描画に微調整する必要がなくなります。

上下左右に前進するとは?

かめは頭が右に向いているときは右方向に、上に向いているときは上方向に前進します。同じ前進するにしても、頭が向いている方向によって、かめが前進した位置に対する計算が変わります。具体的に言うと、

  • かめが上を向いているとき、1マス前進するには、かめのy位置から1マスの高さを引く
  • かめが下を向いているとき、1マス前進するには、かめのy位置に1マスの高さを足す
  • かめが左を向いているとき、1マス前進するには、かめのx位置から1マスの幅を引く
  • かめが右を向いているとき、1マス前進するには、かめのx位置に1マスの幅を足す

ということになります。

右に回るとは?

かめは左と右に90度、向きを変えることができます。頭が右方向に向いているとき、右に90度回ると、かめは下を向いていることになります。向きは、右に90度回りつづける限り、右 -> 下 -> 左 -> 上 -> 右 の順番を繰り返します。右の次に左が来ることはありません。

かめは向きの属性を持つ

このように考えてくると、かめには今どっちを向いているかという向きの属性を持たせる必要のあることが分かります。今向いている方向が分からないと、前進の命令を出されたとき、上下左右のどっちに進んでいいか分かりません。また右か左に回れと言われたときにも、どっちに向いていいか分かりません。

さらに言えば、かめの自分の向きに応じて、自分のイメージも変更する必要があります。右を向いているときには頭が右を向いているイメージを、上を向いているときには頭が上を向いているイメージに変える必要があります。

描画に入る

通常ですと、たとえばマスの位置やかめの向きなどを[コンソール]に出力して、舞台裏で働く論理を探っていくのですが、かめが実際に位置や向きを変えると楽しいので、かめを描画しながらプログラミングを考えていくことにします。

マス目の描画

かめが動く舞台となるマス目は、「格子ブロックのひな型コード」で描画できます。一辺が50ピクセルの正方形が5 x 5 = 25個ならんだマス目は次のコードで描けます。

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

...
// draw()内
// 矩形を格子状に並べて描く
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);
    }
}
かめの画像の読み込み

かめの画像は、かめの頭が上下左右に向いたものを用意します。マスのサイズ(50 x 50)を超えないようにします。

かめ用の変数(kame)と、かめの画像を入れる配列用の変数(kameImages)を宣言しておいて、preload()関数で各画像ファイルをロードします。

読み込んだイメージはkameImages配列に入れます。かめが右向きのときは右向きのイメージを、下向きのときは下向きのイメージを描画するので、配列に入れておくと、イメージがインデックス位置で指定できるようになります。配列内のイメージの順番は右、下、左、上にします(右向きからスタートして右回りの順)。これについては後で見ていきます。

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];
}
かめのクラス

かめは、「指定されたマス分だけ前進するとは?」と「上下左右に前進するとは?」、「右に回るとは?」、「かめは向きの属性を持つ」で述べた事柄を実現するものでなくてはなりません。そのためにはかめの身になって考えるのが分かりやすいので、オブジェクト指向プログラミングの手法でかめを作成することにします。

では、上記4つの事柄を実現するには、かめをどんなクラスで表すのがよいでしょうか? プロパティから見ていきましょう。

「指定されたマス分だけ前進するとは?」に必要なプロパティ

「指定されたマス分だけ前進するとは?」で述べた事柄を実現するには、まずかめの位置を表すプロパティが必要です。また1マスの長さを一歩として前進するので、一歩の長さも要ります。マス目の描画ですでに一辺が50の正方形を作成しているので、この長さは50になります。

  • xとyプロパティ => かめの位置を表す
  • stepプロパティ => かめが前進するときの一歩の長さを表す

「上下左右に前進するとは?」に必要なプロパティ
これを実現するには、かめが今どっちを向いているかを追跡する向きのプロパティが必要です。向きは文字列(‘right’, ‘down’, ‘left’, ‘up’)で表せます。

  • directionプロパティ => かめの向きを表す

「右に回るとは?」に必要なプロパティ
これを実現する方法としては、かめが今何度回転しているかを追跡する角度のプロパティが考えられます。また「右に90度回りつづける限り、右 -> 下 -> 左 -> 上 -> 右 の順番を繰り返」す仕組みが要ります。

「右 -> 下 -> 左 -> 上」の順番を繰り返す仕組みとして思いつくのは、配列のインデックス位置を右に1ずつ変化させ、最後までいったらインデックス位置を0に戻す方法です。

次の変数を宣言しておいて、

// 向きの配列、右 -> 下 -> 左 -> 上 の順番
let directions = ['right', 'down', 'left', 'up'];
// 現在の向きを示す数値
let currentDirection = 0;
// 現在の向き => directions[0] => 最初は'right'
let direction = directions[currentDirection];

次のコードを何回かマウスプレスで実行すると、

print('現在の向き: ' + direction);
// currentDirectionを1大きくする
currentDirection++;
// 循環させる
if (currentDirection >= 4) {
    currentDirection = 0;
}
// 新しい向き
direction = directions[currentDirection];
print('新しい向き: ' + direction);
print('-----------------');

currentDirectionの値を循環させることで、向きの文字列も循環させることができます。

また、今右を向いているなら(directionが’right’なら)、描画するかめも右を向いているはずです。これは、今の向きはかめの今のイメージに対応するということです。ということは、この仕組みはかめのイメージにもそのまま利用できるということです。

let kameImages = [right, down, left, up];
let img = kameImages[currentDirection];

カメのイメージが今の向きによって変更できるなら、回転角度のプロパティは必要ありません。したがって必要なプロパティは、

  • directionsプロパティ => 向きの配列 [‘right’, ‘down’, ‘left’, ‘up’]
  • currentDirectionプロパティ => 今の向きを数値で示す
  • directionプロパティ => 今の向きを文字列で表す
  • imagesプロパティ => カメのイメージの配列
  • imageプロパティ => かめの今のイメージ

が考えられます。

かめのクラスのコンストラクタ
ここまでをまとめると、かめのクラスのコンストラクタは次のようなコードで記述できます。

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;

    }

Kameクラスのインスタンスを作成するには、描画する(x,y)位置と、右、下、左、上の順にかめのイメージを入れた配列(kameImages)を渡します。

* とはいえ、いきなりこのようなコードが記述できるわけはなく、通常は何回もテストして書き直します。

かめのクラスのメソッド
つづいてメソッドを考えていきましょう。

display()メソッド

まずはかめを描画するdisplay()です。display()は簡単で、かめの現在のイメージを自分のxとy位置に、p5.jsのimage()関数で描画するだけです。

// カメのイメージを描画する
display() {
    image(this.image, this.x, this.y);
}

sketch.jsのstep()関数で、imageMode(CENTER);を呼び出すことで、かめは(x,y)位置をイメージのセンターとして描画されます。

ここまでの全コードをまとめると、次のようになります。

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 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);
        }
    }
    kame.display(); // カメを描画
}

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

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;

        }
        // カメのイメージを描画する
    display() {
        image(this.image, this.x, this.y);
    }
}

kameオブジェクトは、new Kame(60, 210, kameImages)で作成されるので、キャンバスの(60,210)で描画されます。この位置にはマスの枠線が描かれるので、kameのセンターがマスの交点に重なります。draw()関数でkameオブジェクトのdisplay()メソッドを呼び出すと、かめが描画できます。

goForward()メソッド
次は指定されたマス数だけ前進するgoForward()メソッドです。Kameクラスのdirectionプロパティは最初’right’なので、kameオブジェクトは右向きに、与えられたマス数分だけ前進する、というのが正しい理屈です。

goForward()はマス数をパラメータで受け取ります。1マスの長さはthis.stepです。これはインスタンスの作成時に50で初期化されています。かめが移動する距離はthis.stepにマス数を掛けた数値になります。そして今かめは右向きなので、xプロパティに加算します。

// stepsマス分だけ前進
goForward(steps) {
    // 右にstepsマス分(50 * steps ピクセル)移動
    this.x += this.step * steps;
}

goForward()メソッドを実行するには、kameオブジェクトからgoForward()メソッドを呼び出します。このときマス数の数値を指定します。2を指定するとかめは右向きに2マス分前進します。

// [前進!]ボタン
const goForwardButton = setButton('前進!', {
    x: 200,
    y: 460
});
goForwardButton.mousePressed(() => {
    kame.goForward(2);
});

指定されたマス分だけ前進するとは?」はこれでクリアできました。コマ数の指定は後のコードで、ラジオボタンを通して行うようにします。

今はまだ右回りや左回りの機能を追加していないので、かめは右に進むだけですが、goForward()メソッドでは、かめの今の向きに応じて進む方向を変える必要があります。と言ってもさほど難しくはありません。「上下左右に前進するとは?」で述べた事柄をコードに置き換えるだけです。

// 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;
    }
}

turnToRight()メソッド
つづいて、かめから見て右へ回るturnToRight()メソッドを追加しましょう。これは、前の「右に回るとは?」に必要なプロパティ」で述べた方法で実現できます。かめのイメージを回転させるのではなく、currentDirectionプロパティを1ずつ大きくし、これをインデックス位置としてdirectionsとimages配列に使用します。すると’right’の次は’down’が、その次は’left’、’up’が参照でき、循環します。イメージも同様で右、下、左、上向きのものが循環して参照できます。

// 右を向く
turnToRight() {
    this.currentDirection++;
    // 循環させる
    if (this.currentDirection >= 4) {
        this.currentDirection = 0;
    }
    // カメの新しい向き
    this.direction = this.directions[this.currentDirection];
    // カメの新しいイメージ
    this.image = this.images[this.currentDirection];
}

新たに[右を向く!]ボタンを追加し、マウスプレスで kame.turnToRight()を実行します。かめは1回のマウスプレスで右向きに90度回転します。

// [右を向く!]ボタン
const turnToRightButton = setButton('右を向く!', {
    x: 10,
    y: 420
});
turnToRightButton.mousePressed(() => {
    kame.turnToRight();
});

左に回転するturnToLeft()メソッドは、右回りと反対にdirectionsとimages配列をバックしたいので、currentDirectionの値を1ずつ減らすことで定義できます。次のコードで示されているので参考にしてください。

turnToLeft()メソッドと、前進のマス数が指定できるラジオボタンも含めたここまでの全コードは次のように記述できます。

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);

    // [右を向く!]ボタン
    const turnToRightButton = setButton('右を向く!', {
        x: 10,
        y: 420
    });
    turnToRightButton.mousePressed(() => {
        kame.turnToRight();
    });
    // [左を向く!]ボタン
    const turnToLefttButton = setButton('左を向く!', {
        x: 100,
        y: 420
    });
    turnToLefttButton.mousePressed(() => {
        kame.turnToLeft();
    });
    // [前進!]ボタン
    const goForwardButton = setButton('前進!', {
        x: 200,
        y: 460
    });
    goForwardButton.mousePressed(() => {
        // ラジオボタンの選択値を進むマス数とする
        const step = int(radio.value())
        kame.goForward(step);
    });
    // カメの進むマス数
    radio = createRadio();
    radio.option('1');
    radio.option('2');
    radio.option('3');
    radio.option('4');
    radio.option('5');
    radio.style('width', '160px');
    radio.position(200, 420);
    radio.selected('1');
}

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);
        }
    }
    kame.display(); // カメを描画
}

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

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;

        }
        // カメのイメージを描画する
    display() {
            image(this.image, this.x, this.y);
        }
        // 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;
            }
        }
        // 右を向く
    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];
    }
}

下図は実行画面です。クリックすると実際の画面が開きます。

ここまででかめに必要な機能の多くがKameクラスに実装できました。最後はかめが通った跡に線を引く機能です。

drawLine()メソッド
線はかめが通った跡に引くので、Kameクラスのメソッドとして定義するのが適切です。名前はdrawLineにします。

かめが通った跡に線を引くとは?
では、「かめが通った跡に線を引く」とは、具体的にどうすれば実現できるでしょうか?

線はp5.jsのline()関数で描画できます。line()関数が描画するのは2点間を結んだ直線なので、パラメータには2点の(x,y)座標を指定します。

かめが通るのはかめのxとyプロパティです。正確に言うと、xとyプロパティから分かるのはかめの現在の位置です。では、その前の位置は? それはその時点でのかめのxとyプロパティです。

かめの現在位置を出力するコードを追加して、[前進!]ボタンを3回クリックし、[左を向く!]ボタンを1回クリックして、[前進!]ボタンを1回クリックすると、下図の結果が得られます(赤丸は当方で追加したもので、実際には描画されません)。

これは、かめが(60,210) => (110,210) => (160,210) => (210,210)と右に進み、左に90度回転して、(210 160)と進んだことを示しています。drawLine()メソッドで行いたいのは、かめが(60,210) => (110,210)と進んだときには、(60,210)と(110,210)を線で結び、(160,210)まで進んだときには(60,210)と(160,210)を線で結ぶということです。これならかめの最初の位置(60,210)を覚えておいて、現在位置のx,yプロパティとの間で線を引けばよいわけです。

つづけると、(210 210)まで進んだときは、(60,210)と(210 210)間に線を引きます。かめはここで向きを変えて、(210 160)に進むので、(60,210)と(210 160)間に線を引きます…ではだめです! 

下図に示すように、かめが右に進んでいる間はうまくいっていますが、方向を変えると、それまで通過した位置を通らず、最初の位置と現在位置が直接結ばれてしまいます。

このとき描画したいのは(210 210)と(210 160)を結ぶ線です。ということは、かめが(60,210)から(110,210)に進んだときにはこの2点を結ぶ線を描き、(110,210)から(160,210)に進んだときにはこの2点を結ぶ線を描けばよいということです。

ここで登場するのがまたしても配列です。かめが前進するたびに現在位置を配列に追加し、線を引くときには、配列に追加した最新の要素と、その1つ前の要素の間で線を引くのです。

かめが通った跡に線を引くとは?に必要なプロパティ
そのためには、drawLine()メソッドで使用する配列のプロパティが必要です。最初の位置はthis.xとthis.yなので、インスタンスの作成時に追加しておきます。

// 移動する座標を保持する配列
this.prevousXs = [];
this.prevousYs = [];
// 最初の位置を追加する
this.prevousXs.push(this.x);
this.prevousYs.push(this.y);

そしてgoForward()メソッド定義の最後で、配列に現在位置を追加します。

// 新しいxy位置を配列に追加
this.prevousXs.push(this.x);
this.prevousYs.push(this.y);

drawLine()メソッドは次のように定義できます。配列の長さ分だけ、対象とする要素(i)とそれの1つ前の要素(i)の間で線を引きます。

// 通ってきた位置を結ぶ線を引く
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]);
    }
}

drawLine()メソッドはdraw()関数内で呼び出します。このときにはstrokeWeight()関数でかめが引く線を太くします。線はかめのイメージより奥に描画したいので(そうしないとかめの上に線が引かれます)、display()より先に記述します。

// カメが引く線の太さ
strokeWeight(7);
kame.drawLine() // カメが線を引く
kame.display(); // カメを描画

* なおdraw()関数内では、strokeWeight(1)を、グリッド線のforループの前に呼びだしています。これによりグリッド線は細い線で描かれます。グリッド線を描いた後、strokeWeight(7)を呼び出すとdrawLine()の線は太くなります。1/60秒後の次のdraw()関数の呼び出しではstrokeWeight(1)によって細いグリッド線が描かれ、その後strokeWeight(7)でかめの太い線が描かれます。これがずっとつづきます。

お題の実行

最後にお題を実行するプログラムのコードを示しておきます。

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);

    // [命令する]ボタン
    const actionButton = setButton('命令する', {
        x: 150,
        y: 330
    });
    actionButton.mousePressed(() => {
        let cnt = 0;
        const timerID = window.setInterval(() => {
            if (cnt >= 4) {
                window.clearInterval(timerID);
                print('終了');
            }
            else {
                kame.goForward(3);
                kame.turnToLeft();
                cnt++;
                print(cnt);
            }
        }, 1000);
    });
}

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(); // カメを描画
}

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

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]);
        }
    }
}

ボタンをクリックすると、かめがお題の答えを描きます。

変換(transform)

本稿では、かめの向きの変更を、前もって用意しておいたその向き専用のイメージに置き換えることで行っていますが、イメージが回転しているように描画することでも実現できます。そのためには、p5.jsの変換(transform)と呼ばれる機能を用います。

変換には移動、回転、拡大縮小などがあり、かめのイメージの回転には移動と回転を使用します。移動については「5_1:移動(translate) p5.js JavaScript」で、回転については「5_2:回転(rotate) p5.js JavaScript」で詳しく述べています。

かめのクラスの変更

回転を使用するには、まずかめのクラスを変更します。

コンストラクタのプロパティでは、rotationプロパティを追加します。またイメージは右向きのものしか使用しないので、imagesプロパティは不要で、imageプロパティには引数で渡されたimgs配列の0番めを指定します。ほかのものは変更しません。

// カメのイメージ
// this.images = imgs;
// 向きによってイメージを変える
//  this.image = this.images[this.currentDirection];
// 使用するイメージは右向きのイメージだけ
this.image = imgs[0];
// 回転角度を追跡する
this.rotation = 0;

そしてturnToRight()とturnToLeft()では、imageプロパティへの代入をやめて、rotationプロパティに90を加算または減算します。rotationプロパティは0で初期化されているので、turnToRight()メソッドが1回呼び出されるとrotationプロパティは90になり、つづけてturnToLeft()メソッドが呼び出されると、rotationプロパティは0に戻ります。

turnToRight() メソッド
    //this.image = this.images[this.currentDirection];
this.rotation += 90;

turnToLeft() メソッド
    //this.image = this.images[this.currentDirection];
this.rotation -= 90;

かめのクラスの変更は以上です。

sketch.jsの変更

setup()関数では、angleMode(DEGREES)を呼び出します。これによりラジアン単位で動作する関数やメソッドが度単位で動作するようになります。回転を実行するrotate()関数はデフォルトでラジアン単位の引数を取るので、度単位が使用できるようにします。

// 回転の単位をラジアンでなく度にする
angleMode(DEGREES);

そしてdraw()関数では、グリッド線を描くforループの後を次のように変更します。

// drawLine()は変換と関係しない
strokeWeight(7);
kame.drawLine();

translate(kame.x, kame.y); // 座標の原点をかめのセンターに移動する
rotate(kame.rotation) // かめのセンターを中心に、kame.rotation度、座標システムを回転する
translate(-kame.x, -kame.y); // 座標の原点を元の(0,0)に戻す
kame.display(); // カメを描画

p5.jsの変換を扱うときに意識しておかなければならないのは、変換はp5.jsのキャンバスに対して行われるということです。

translate()
次のコードでは、同じrect(50, 50, 50, 100)を2回呼び出していますが、その間でtranslate(100, 50)を実行しています。塗り色は矩形を区別するために変えています。

// 赤の矩形
fill(255, 0, 0);
rect(50, 50, 50, 100);

// 座標の原点を右に100、下に50移す
translate(100, 50);
// 緑の矩形
fill(0, 255, 0);
rect(50, 50, 50, 100);

結果は次のようになります。

これは、下図のように考えることができます。赤い矩形は通常通り、キャンバスの左上隅を原点とする(50,50)に50 x 100のサイズで描画されますが、その後translate(100, 50)によって、原点が(100,50)に移動します。緑の矩形が赤い矩形と同じ位置に同じサイズで描かれるときには、原点が(100,50)に移動しているので、元の原点から見ると(100+50,50+50)の位置に描かれます。

rotate()
次のコードでは、上と同じ矩形を描いていますが、間でrotate(15)を実行しています。これにより座標システムが原点を中心に15度時計回りに回転したように描画されます。

// 赤の矩形
fill(255, 0, 0);
rect(50, 50, 50, 100);

// 座標システムを右に15度回転する
rotate(15);
// 緑の矩形
fill(0, 255, 0);
rect(50, 50, 50, 100);

イメージのセンターで回転
特定のイメージが回転したように描画するには、

  1. キャンバスの原点を回転させたいイメージのセンターに移動する => translate(kame.x, kame.y)
  2. 移動させたキャンバスの原点を中心に座標システムを回転させる => rotate(kame.rotation)
  3. 移動させたキャンバスの原点を元に戻す => translate(-kame.x, -kame.y)
  4. イメージを描画する => kame.display()

という面倒な手順を取る必要があります。なおdraw()関数内では、変換は毎フレームリセットされます。

コメントを残す

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

CAPTCHA