Chapter 66

タートルグラフィックス

miku
miku
2021.11.19に更新

「64章 - 再帰」では、右に移動、下に移動などを再帰で繰り返して円を描画した。今回はもっと汎用的に描画ができるようにしてみよう。

上下左右に移動できるようにしてもいいが、なんなら角度を指定して360度移動できるようにしたい。移動する距離も指定できるといいだろう。

そこで用意する機能は下記の通りとなる。

left(x); // 今の向きから左にx度の方向に向く
right(x); // 今の向きから右にx度の方向に向く
forward(x); // 今向いている方向にx進みながら線を描く

今までと違うのは向きを保持しており、常にその向きが基準になる。なので forward() では向きを指定せず、線を描く長さだけを指定すればいい。

他にも関数を付けてもいいが一先ずは上記だけだ。

実装

let x, y, angle;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  x = width / 2;
  y = height / 2;
  angle = 0;
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}

コードを一つ一つ見ていこう。

let x, y, angle;

必要な変数は座標と角度だ。leftright は度数法の角度で指定したいので、angle も度数法の角度を入れることにする。

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  x = width / 2;
  y = height / 2;
  angle = 0;
}

angleMode()DEGREES を指定すると、本来ラジアンを指定するところを角度で指定できるようになる。基本的には線の描画だけになるので noFill() で塗りを無効にしておく。位置と角度も初期化する必要があるが今は実装の範囲で何も描画しないので、このへんで初期化するんだな、ぐらいに思っていればいい。

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

left()v 度を指定すると、左に v 度回転してほしい。なので angle から v を引く。right はその逆の処理になる。

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}

(x, y) の座標から angle 度の方向に v 進んだ位置まで線を描く。線を描いた後は、終点が (x, y) になるように変更する。

実際に描いてみる

コード全体を見る
let x, y, angle;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  strokeWeight(2);
  stroke(240);
  noFill();

  x = width / 2;
  y = height / 2;
  angle = 0;

  forward(100);
  right(60);
  forward(100);
  right(60);
  forward(100);
  right(60);
  forward(100);
  right(60);
  forward(100);
  right(60);
  forward(100);
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}
forward(100);
right(60);
forward(100);
right(60);
forward(100);
right(60);
forward(100);
right(60);
forward(100);
right(60);
forward(100);

前に進んで右60度回転を繰り返す。実行例では分かりやすいように開始地点に丸を付けている。

同じような処理が続く場合はループか再帰を利用してもいい。次がその作例になる。

コード全体を見る
let x, y, angle;
const len = 200;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  strokeWeight(2);
  stroke(240);
  noFill();

  x = width / 2;
  y = height / 2;
  angle = 0;

  walk(5);
}

function walk(n) {
  if (n === 0) {
    return;
  }

  forward(len);
  right(144);
  walk(n - 1);
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}
walk(5);

function walk(n) {
  if (n === 0) {
    return;
  }

  forward(len);
  right(144);
  walk(n - 1);
}

五芒星の角は36度だが、相対的に見ると 180 - 36 = 144 で144度だ。なので、前に移動後、右に144度回転を5回続けると五芒星を描画できる。

次に forward() で移動するサイズを可変にしたい。

コード全体を見る
let x, y, angle;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  x = width / 2;
  y = height / 2;
  angle = 0;

  walk(100, 5);
}

function walk(n, len) {
  if (n === 0) {
    return;
  }

  forward(len);
  len += 5;
  right(90);
  walk(n - 1, len);
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}
walk(100, 5);

function walk(n, len) {
  if (n === 0) {
    return;
  }

  forward(len);
  len += 5;
  right(90);
  walk(n - 1, len);
}

forward() で前に進んで、右90度回転を繰り返すのだが、毎回 forward() で移動する距離を増やすと矩形型の螺旋になる。

次は再帰関数の中で複数の再帰呼び出しを書いてみよう。

複数の再帰呼び出し

forward() で進んだあと、left() で左回転後再帰呼び出し、right() で右回転後再帰呼び出しをして木のような形を作る。

const len = 50;
const a = 15;

function walk(n) {
  if (n === 0) {
    return;
  }

  forward(len);

  left(a);
  walk(n - 1);

  right(a);
  walk(n - 1);
}

文章通りにコードを記述するこのようになるが、これで実行するとぐちゃぐちゃな線が描画される。まず問題なのが、座標 (x, y) と角度 (angle) がグローバル変数だということだ。

left(a);
walk(n - 1);

// (x, y, angle)の値がわからない

right(a);
walk(n - 1);

再帰の中で再帰が呼ばれて、更にその中で再帰が呼ばれて・・・と繰り返すと、(x, y, angle) の値がどんどん変化していくので、コメントの位置で現在 (x, y, angle) の値が何になっているのかがわからない。

これの解決方法をいくつか紹介する。1つ目は再帰呼び出し後に再帰前に設定した内容をもとに戻すやり方だ。

left(a);
walk(n - 1);
right(a); // left()で傾いた分をもとに戻す

right(a);
walk(n - 1);
left(a); // right()で傾いた分をもとに戻す

left() で傾けた後、再帰呼び出しをした後に right()left() で傾けた分をもとに戻す。逆の場合も同じだ。

// この時点で angle = 30

left(a);
walk(n - 1);
right(a);

// この時点でも angle = 30

このようにすれば、再帰呼び出しの後に再帰呼び出しをしても、前の呼び出しが後の呼び出しに影響を与えることがなくなる。再帰呼び出し先でも全く同じ手順を踏むからだ。

これで傾きの対処はできた。あとは移動についてだ。

forward(len);

left(a);
walk(n - 1);
right(a);

right(a);
walk(n - 1);
left(a);

// ここでforward()で進んだ分をもとに戻したい

forward() で進んでその先の処理をしたあと、進んだ位置をもとに戻したい。そのための関数を追加しよう。

function backward(v) {
  x -= cos(angle) * v;
  y -= sin(angle) * v;
}

backward(v) は今向いている方向とは正反対の方向に v だけ進む関数だ。向きは変わらない。

位置を戻す際に線を描画する必要ないので、上記コードだけでいい。

forward(len);

left(a);
walk(n - 1);
right(a);

right(a);
walk(n - 1);
left(a);

backward(len);

最終的にはこうなる。

変更したらもとに戻すという対応関係を覚えておこう。

let x, y, angle;
const len = 50;
const a = 15;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  x = width / 2;
  y = height;
  angle = -90;

  walk(10);
}

function walk(n) {
  if (n === 0) {
    return;
  }

  forward(len);

  left(a);
  walk(n - 1);
  right(a);

  right(a);
  walk(n - 1);
  left(a);

  backward(len);
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}

function backward(v) {
  x -= cos(angle) * v;
  y -= sin(angle) * v;
}

状態を保存する

function saveData() {
  data.push({ x, y, angle });
}

function loadData() {
  const d = data.pop();
  [x, y, angle] = [d.x, d.y, d.angle];
}

saveData() は関数を呼び出した時点で (x, y, angle) を配列に入れて保持しておく。loadData() は配列の末尾にある (x, y, angle) を取り出してセットする関数だ。

function walk(n) {
  if (n === 0) {
    return;
  }

  saveData();
  forward(len);

  saveData();
  left(a);
  walk(n - 1);
  loadData();

  saveData();
  right(a);
  walk(n - 1);
  left(a);
  loadData();

  loadData();
}

メリットとしてはもとに戻す際、具体的な値をセットしなくていいことだ。

let x, y, angle;
const len = 50;
const a = 15;
let data;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  data = [];

  x = width / 2;
  y = height;
  angle = -90;

  walk(10);
}

function walk(n) {
  if (n === 0) {
    return;
  }

  saveData();
  forward(len);

  saveData();
  left(a);
  walk(n - 1);
  loadData();

  saveData();
  right(a);
  walk(n - 1);
  left(a);
  loadData();

  loadData();
}

function saveData() {
  data.push({ x, y, angle });
}

function loadData() {
  const d = data.pop();
  [x, y, angle] = [d.x, d.y, d.angle];
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}

function backward(v) {
  x -= cos(angle) * v;
  y -= sin(angle) * v;
}

奥へ行くほど長さが短くなる

let x, y, angle, len;
const a = 15;

function setup() {
  createCanvas(windowWidth, windowHeight);
  angleMode(DEGREES);
  stroke(240);
  noFill();

  x = width / 2;
  y = height;
  angle = -90;
  len = 50;

  walk(10);
}

function walk(n) {
  if (n === 0) {
    return;
  }

  len -= 2;
  forward(len);

  left(a);
  walk(n - 1);
  right(a);

  right(a);
  walk(n - 1);
  left(a);

  backward(len);
  len += 2;
}

function left(v) {
  angle -= v;
}

function right(v) {
  angle += v;
}

function forward(v) {
  const tx = x + cos(angle) * v;
  const ty = y + sin(angle) * v;

  line(x, y, tx, ty);

  x = tx;
  y = ty;
}

function backward(v) {
  x -= cos(angle) * v;
  y -= sin(angle) * v;
}

再帰のたびに木の長さを短くする作例。