はじめに
はじめまして、Latte72 です。
普段は Python や C/C++、JavaScript などを使って開発をしています。最近は自作OSや自作コンパイラ、Deep Learning に興味があり、色々と挑戦中です。
以前に JavaScript で Turtle graphics を実装し、ブラウザ上で利用できるようにしたので、ソースコードとともに実装の解説をしていきます。
完成品(完全版)
- デモページ:https://latte72r.github.io/TurtleGraphics/demo.html
- ソースコード:https://github.com/latte72r/TurtleGraphics/
作成した完全版のプログラムは機能が多いので、この記事では機能を削減した解説専用のプログラムを作成し、それを利用します
実際の開発では途中で JavaScript から TypeScript に切り替えました
(この記事での解説は JavaScript を用いて行います)
Turtle graphicsとは?
「Turtle graphics」とは、1967年にLogo言語の一部として生まれた、シンプルなコマンドで図形を描けるツールです。初心者でもプログラミングの基礎を直感的に学べる教育ツールとして長く親しまれてきました。
Pythonでも標準ライブラリとして使われているので、触れたことがある方も多いのではないでしょうか。
開発の動機と背景
どうして Turtle graphics をJavaScript で実装することにしたのか
私が Python を学習し始めたとき、初めて触れたライブラリが Turtle graphics でした。初心者でもプログラミングの動作が視覚的に分かりやすく、とても楽しかった記憶があります。Python の後に Javascript も学習し、Turtle graphics を Web 上でも再現できたらと思い、今回 JavaScript で作ることにしました。これにより、Pythonの環境構築をしなくても、PCやスマートフォンで手軽に動かせるようにしています。
今回の実装について
今回作成したプログラムでは使用する言語こそPythonからJavaScriptに変化していますが、基本的には同じ動作になるように設計しています。
実際にPythonでの実装を確認しながら設計したわけではないので、異なる仕様を採用している箇所もいくつかあります。
技術的な工夫や実装上の課題
JavaScript で Turtle graphics を実装するにあたり、いくつか苦労した点がありました。その中でも特に大きかったのが「非同期処理」との向き合い方です。
同期処理と非同期処理
Pythonでは前の行の実行が終わってから次の行が実行されます。これが同期処理です。
一方で、JavaScriptでは処理に時間がかかる操作は結果が返ってくるよりも先に次の行が実行されます。これが非同期処理です。非同期処理は、あるタスクを実行中に他のタスクも並行して実行できる仕組みです。これにより、タスクが完了するのを待つ必要がなく、プログラムが効率的に動作します。特に、利用者の入力を待つようなプログラムや、視覚的な操作を扱うプログラムで重要です。
非同期処理によって発生した問題
Turtle graphics は一瞬で図形全体が描画されるのではなく、Turtle が少しずつ動きながら描画されるのが特徴です。そのためには「図形を少し描画する」⇒「少しの時間プログラムを停止する」という操作を繰り返す必要があります。
(「図形を少し描画する」⇒「少しの時間プログラムを停止する」のイメージ )
Python では同期処理が採用されているので一つの図形を描き終わってから次の図形を描き始めることができます。しかし、JavaScript はデフォルトで非同期処理なので、適切な処理をしないと以下のように複数の操作の描画が同時にに起こってしまいます。
( forward(250)
と right(270)
を非同期処理で実行した場合 )
この問題を解決するために Promise
/ setTimeout
/ async
/ await
を使って、一つの操作ごとに少し待機を入れながら進行させることで滑らかな描画を再現しています。そのために _sleepMS
関数を用意しています。実装はこちらをご覧ください。
この記事では JavaScript での非同期処理の詳細について詳しくは扱いません。
気になる方は他の記事を参考にしてください。
プログラムの解説
この記事では Turtle graphics の内部実装について解説しています。
実際の利用方法については リファレンス を参照してください。
定数の事前準備
1. タートルの形を定義する
-
SHAPE
は、タートルを描画するための座標データです -
[x, y]
という形のペアで、タートルの輪郭を示し、タートルの形を描いていきます
// Turtleを描画するための座標データ
const SHAPE = [
[0, 14], [-2, 12], [-1, 8], [-4, 5], [-7, 7], [-9, 6],
[-6, 3], [-7, -1], [-5, -5], [-8, -8], [-6, -10], [-4, -7],
[0, -9], [4, -7], [6, -10], [8, -8], [5, -5], [7, -1],
[6, 3], [9, 6], [7, 7], [4, 5], [1, 8], [2, 12], [0, 14]
];
2. タートルのスピードを設定する
-
SPEED_TABLE
はタートルのスピードを調整するためのテーブルです - キーがスピードのレベルを表していて、値はそのレベルのときにどれくらいの間隔(ミリ秒)でタートルが移動するかを表しています
- 例えば、レベル1なら40ミリ秒ごとに動き、レベル10ならわずか2ミリ秒ごとに動くので「超高速」になります。
実際には、描画に関する計算も行うので、描画の間隔は指定したよりも少し長くなります。
// 描画スピードの設定テーブル(数値が小さいほど速く動く)
const SPEED_TABLE = {
0: 0, 1: 40, 2: 36, 3: 30, 4: 22, 5: 16, 6: 12, 7: 8, 8: 6, 9: 4, 10: 2
};
3. タートルの動きの微小区間を設定
- タートルが1ステップでどれくらい動くかや、どれくらいの角度で回転するかを設定しています
-
DELTA_XY
: タートルが1ステップで進む距離です。前進・後退するときに使われます -
DELTA_ANGLE
: タートルが1回の回転で回る角度を示します
-
// Turtleが動く際の1ステップの移動距離と回転角度
const DELTA_XY = 4;
const DELTA_ANGLE = 4;
Turtle クラスの基本構成
1. コンストラクタ
Turtle
クラスのコンストラクタでは、キャンバスのセットアップを行います。
-
this.cvWidth
とthis.cvHeight
:- タートルが描画するキャンバスの幅と高さを格納します
-
this.canvas
:-
document.getElementById(canvasID)
を使って、HTML上の要素を取得します -
canvasID
は、タートルの描画先となるキャンバスのIDです
-
-
this.canvas.width
とthis.canvas.height
:- キャンバスの実際の幅と高さを設定します
-
this.context
:- キャンバス上に描画するためのツールや設定が詰まったオブジェクトです
最後に this.reset
を呼び出して、タートルの初期位置や描画の設定をリセットします。reset
メソッドは、この後に実装していくタートルの状態を初期化するメソッドです。
Pythonでのデザインと合わせるために lineCap
(線の端の形)と lineJoin
(線の結合部分の形)を round
に設定しています。
// 描画をコントロールするクラスを定義
class Turtle {
constructor(width, height, canvasID) {
// キャンバスの幅と高さを設定
this.cvWidth = width;
this.cvHeight = height;
// キャンバスの要素を取得
this.canvas = document.getElementById(canvasID);
// キャンバスの幅と高さを設定
this.canvas.width = this.cvWidth;
this.canvas.height = this.cvHeight;
// キャンバスのコンテキストを取得
this.context = this.canvas.getContext('2d');
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
this.reset();
}
// ...
}
2. reset
メソッド
-
描画履歴 (
registeredFigures
) の初期化:- 過去の描画履歴をリセットします
-
タートルの向き・位置・サイズ:
- 向きを上方向 (0 度) にし、位置をキャンバス中央に設定
- タートルの拡大率を 1 に初期化
-
ペンの設定:
- 線色と塗りつぶし色を黒 (
#000000
) に、線の太さ (penSize
) とタートルのサイズ (turtleSize
) を 1 に設定する
- 線色と塗りつぶし色を黒 (
-
表示設定と描画速度:
- ペン描画を有効 (
penEnabled
) にし、タートルを表示状態に設定する - 描画速度は中速 (
SPEED_TABLE[6]
) に設定する
- ペン描画を有効 (
-
デフォルトの設定登録と再描画:
- デフォルトの設定(ペン色やサイズ)を履歴に登録し、
_redrawObjects
メソッドでキャンバスの再描画を行う
- デフォルトの設定(ペン色やサイズ)を履歴に登録し、
// Turtleの状態をリセットし、初期位置に戻す
reset() {
// 描画履歴を初期化
this.registeredFigures = [];
// Turtleの向き、位置、サイズを初期化
this.directionAngle = 0;
this.centerX = this.cvWidth / 2;
this.centerY = this.cvHeight / 2;
this.turtleExpand = 1;
// 塗りつぶしの開始インデックスを初期化
this.beginFillIndex = NaN;
// ペンの色、サイズ、塗りつぶしの色を初期化
this.penColor = "#000000";
this.fillColor = "#000000";
this.penSize = 1;
this.turtleSize = 1;
// ペンの有効無効、Turtleの表示非表示、描画速度を初期化
this.penEnabled = true;
this.turtleVisible = true;
this.delayTime = SPEED_TABLE[6];
// デフォルトの描画設定を登録
this.registeredFigures.push(["pencolor", this.penColor]);
this.registeredFigures.push(["fillcolor", this.fillColor]);
this.registeredFigures.push(["pensize", this.penSize]);
this.registeredFigures.push(["turtlesize", this.turtleSize]);
this._redrawObjects();
}
3. _clearCanvas
メソッド
-
clearRect
関数を用いて、キャンバス上に描かれている内容を(左上から幅と高さ全体にわたって)消去します
// キャンバスをクリアする(内部処理)
_clearCanvas() {
this.context.clearRect(0, 0, this.cvWidth, this.cvHeight);
}
4. _sleepMS
メソッド
-
Promise
を使用して、指定されたミリ秒だけ待機します - 次に定義する
_delayProgram
から呼び出されます
// 一定時間待機するプロミスを返す(内部処理)
_sleepMS(milSecond) {
return new Promise(resolve => setTimeout(resolve, milSecond));
}
5. _delayProgram
メソッド
-
_sleepMS
を使ってdelayTime
(描画スピードに対応)だけ待機します - これにより、描画のタイミングを調整でき、視覚的に分かりやすい動きを実現できます
// プログラムの実行を一時停止する(内部処理)
async _delayProgram() {
await this._sleepMS(this.delayTime);
}
全体的な描画に関する機能
1. _drawTurtle
メソッド
Turtle の向きやサイズを基に、特定の位置に Turtle の形状を描画するためのメソッドです。
-
引数のデフォルト設定
-
centerX
やcenterY
などの引数が指定されていない場合(NaN
の場合)は、現在のTurtleの位置と角度を使います - これにより、現在の状態でTurtleを描画できるようになっています
-
-
回転角度の計算
- Turtleの方向角度
directionAngle
をラジアンに変換します - このラジアン角度は後の座標計算に使われます
-
-90
は、Turtleの向きが通常の「上方向」を基準にしているための調整です
- Turtleの方向角度
-
正弦と余弦の計算
- ラジアン角度から正弦 (
SIN
) と余弦 (COS
) を計算し、後で座標変換に利用します
- ラジアン角度から正弦 (
-
Turtleの形状の描画
-
SHAPE
配列に定義されたTurtleの形状(例えば、三角形など)を元に、各頂点の位置を計算して線を引きます - 座標は、回転角度と拡大率に基づいて変換されています
- これにより、指定された位置・向きに合わせてTurtleの形状が描画されます
-
-
描画の完了
- 最後に形状を塗りつぶし、線で囲むことで、Turtleの形が完成します
- その後、線の太さを元の
penSize
に戻します
// Turtleの形状を描画する(内部処理)
_drawTurtle(centerX = NaN, centerY = NaN, directionAngle = NaN, turtleExpand = NaN) {
// 引数が指定されていない場合は現在の状態を使用
if (isNaN(centerX)) { centerX = this.centerX; }
if (isNaN(centerY)) { centerY = this.centerY; }
if (isNaN(directionAngle)) { directionAngle = this.directionAngle; }
if (isNaN(turtleExpand)) { turtleExpand = this.turtleExpand; }
const RADIAN = (directionAngle - 90) / 180 * Math.PI;
const COS = Math.cos(RADIAN);
const SIN = Math.sin(RADIAN);
this.context.beginPath();
this.context.lineWidth = this.turtleExpand;
// Turtleの形状を描画
SHAPE.forEach(element => this.context.lineTo(
centerX + (element[0] * COS - element[1] * SIN) * turtleExpand,
centerY - (element[0] * SIN + element[1] * COS) * turtleExpand));
this.context.fill();
this.context.stroke();
this.context.lineWidth = this.penSize;
}
2. _redrawObjects
メソッド
すべての図形と Turtle をまとめて再描画するためのメソッドです。これから定義していく dot
などのメソッドは実際に図形を描画するわけではなく、registeredFigures
に位置や大きさを記録して、 _redrawObjects
によって描かれます。
-
キャンバスのクリア
- 既存の描画内容を削除してキャンバスをクリアします
-
図形の順次再描画
-
registeredFigures
に格納された各図形・スタイル情報に基づいて、順に再描画を行います - 各図形・設定の種類 (
figure[0]
) に応じて、描画メソッドやプロパティ設定が切り替わります
-
-
スタイルの更新・図形の再描画
- 線、ペンの色、塗りつぶし色、ペンの太さ、塗りつぶし開始などのスタイルを更新します
- スタンプ、Turtleのサイズ、円などの図形をそれぞれの図形を再描画します
-
Turtleの描画
-
turtleVisible
がtrue
の場合、現在のTurtleの位置と形状を描画します
-
// 現在までに記録された全ての図形を再描画する
_redrawObjects(turtle = true) {
// キャンバスをクリア
this._clearCanvas();
// 登録された図形を順に描画
for (let i = 0; i < this.registeredFigures.length; i++) {
let figure = this.registeredFigures[i];
let args = figure[1];
if (figure[0] == "line") {
this._drawLine(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "pencolor") {
this.penColor = args;
this.context.strokeStyle = args;
} else if (figure[0] == "fillcolor") {
this.fillColor = args;
this.context.fillStyle = args;
} else if (figure[0] == "stamp") {
this._drawTurtle(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "turtlesize") {
this.turtleExpand = args;
} else if (figure[0] == "dot") {
this._createDot(args[0], args[1], args[2]);
} else if (figure[0] == "pensize") {
this.penSize = args;
this.context.lineWidth = args;
} else if (figure[0] == "begin_fill") {
this._beginFill(args[0], args[1], args[2]);
}
}
// 表示モードが有効な場合はTurtleを描画
if (this.turtleVisible) {
this._drawTurtle();
}
}
Turtle の直進に関連する機能
1. _drawLine
メソッドの役割
-
描画開始:
beginPath
を呼び出して、新しいパスを開始します -
始点の設定:
moveTo
を使って、指定された始点(fromX, fromY)
に移動します -
終点までの線を引く:
lineTo
を使って終点(toX, toY)
まで直線を描画します -
線の描画:
stroke
を呼び出すことで、指定された線のスタイルで実際に描画が行われます
// 直線を描画する(内部処理)
_drawLine(fromX, fromY, toX, toY) {
this.context.beginPath();
this.context.moveTo(fromX, fromY);
this.context.lineTo(toX, toY);
this.context.stroke();
}
2. _forward
メソッド
-
進行方向と回数の計算:
-
SIGN
は前進・後退の方向を設定するための符号です -
TIMES
は移動を小刻みに分割するための回数で、スムーズな描画更新に使われます
-
-
小刻みな移動と描画の更新:
- 移動距離を複数回のステップに分割して、
for
ループで少しずつcenterX
、centerY
を更新し、各ステップごとに描画を行います -
penEnabled
が有効な場合は、各ステップで描画する線分を更新します -
turtleVisible
がtrue
ならTurtle
自体を描画します -
await this._delayProgram
で、描画更新ごとに一定時間待機します
- 移動距離を複数回のステップに分割して、
-
最終位置の調整:
-
centerX
とcenterY
の最終位置を計算し、移動完了時の位置に設定します
-
-
描画履歴の記録:
-
penEnabled
が有効なら、直線として移動の軌跡をregisteredFigures
に追加し、再描画可能にします
-
// Turtleを移動する(内部処理)
async _forward(distance) {
const SIGN = distance > 0 ? 1 : -1; // 進む方向を決定
const TIMES = distance * SIGN / DELTA_XY; // 進行の分割回数を計算
const START_X = this.centerX;
const START_Y = this.centerY;
const COS = Math.cos(this.directionAngle / 180 * Math.PI);
const SIN = Math.sin(this.directionAngle / 180 * Math.PI);
// 進行中に少しずつ描画更新
if (this.delayTime > 0) {
for (let i = 0; i < TIMES; i++) {
this.centerX += DELTA_XY * COS * SIGN;
this.centerY -= DELTA_XY * SIN * SIGN;
this._redrawObjects();
if (this.penEnabled) {
this._drawLine(START_X, START_Y, this.centerX, this.centerY);
}
if (this.turtleVisible) {
this._drawTurtle();
}
await this._delayProgram();
}
}
this.centerX = START_X + distance * COS;
this.centerY = START_Y - distance * SIN;
// ペンが有効な場合は直線を描画
if (this.penEnabled) {
this.registeredFigures.push(["line", [START_X, START_Y, this.centerX, this.centerY]]);
}
this._redrawObjects();
}
3. forward
メソッド
- Turtleを前方(現在の進行方向)に移動させます
-
_forward
メソッドにdistance
をそのまま渡し、指定した距離だけTurtleが進むようにします
// 前方に移動する
async forward(distance) {
await this._forward(distance);
}
4. backward
メソッド
- Turtleを後方(進行方向の逆)に移動させます
-
_forward
メソッドに-distance
(負の値)を渡すことで、指定距離分だけ逆方向に移動します
// 後方に移動する
async backward(distance) {
await this._forward(-distance);
}
Turtle の回転に関連する機能
1. _right
メソッド
-
進行方向と回数の計算:
-
SIGN
は回転の方向を設定するための符号です -
TIMES
は回転を小刻みに分割するための回数で、スムーズな描画更新に使われます
-
-
小刻みな回転と描画の更新:
- 回転角度を複数回のステップに分割して、
for
ループで少しずつdirectionAngle
を更新し、各ステップごとに描画を行います -
await this._delayProgram
で、描画更新ごとに一定時間待機します
- 回転角度を複数回のステップに分割して、
-
最終位置の調整:
- 最終的な角度を計算し、回転完了時の角度に設定します
// Turtleを回転する(内部処理)
async _right(angle) {
const SIGN = angle > 0 ? 1 : -1; // 回転方向を決定
const TIMES = angle * SIGN / DELTA_ANGLE; // 回転の分割回数を計算
const START_ANGLE = this.directionAngle;
// 回転中に少しずつ描画更新
if (this.delayTime > 0) {
for (let i = 0; i < TIMES; i++) {
this.directionAngle -= DELTA_ANGLE * SIGN;
this._redrawObjects();
await this._delayProgram();
}
}
this.directionAngle = START_ANGLE - angle;
this._redrawObjects();
}
2. right
メソッド
- Turtleを右方向に回転させます
-
_right
メソッドにangle
をそのまま渡し、指定した角度だけTurtleが回転するようにします
async right(angle) {
await this._right(angle);
}
3. left
メソッド
- Turtleを右方向に回転させます
-
_right
メソッドに-distance
(負の値)を渡し、指定した角度だけTurtleが回転するようにします
async left(angle) {
await this._right(-angle);
}
ペンの設定に関する機能
PythonのTurtleにおける pencolor
, fillcolor
, color
は様々な色を引数に受け取れますが、この記事では簡略化のためにその機能を省き、"#A7A7A7"
や "green"
のような文字列タイプの引数のみに対応しています。
完全版では様々なタイプの引数を受け付けるような仕様にしているので、実装が気になる場合はそちらを確認してください。
1. pencolor
メソッド
- ペンの色を引数
color
で指定し、描画に使用する色を変えるためにregisteredFigures
配列に記録します - 設定を更新するために、
_redrawObjects
を呼び出してキャンバス全体の再描画が行います
// ペンの色を設定
pencolor(color) {
this.registeredFigures.push(["pencolor", color]);
this._redrawObjects();
}
2. fillcolor
メソッド
- 図形の塗りつぶしに使用する色を指定し、
registeredFigures
に記録します -
pencolor
と同様に、_redrawObjects
によって再描画をトリガーします
// 塗りつぶしの色を設定
fillcolor(color) {
this.registeredFigures.push(["fillcolor", color]);
this._redrawObjects();
}
3. color
メソッド
- ペンと塗りつぶしの色を同時に設定できます
- 引数が1つの場合はペンと塗りつぶしの両方に同じ色を設定し、引数が2つの場合はペンと塗りつぶしに別々の色を設定します
-
registeredFigures
配列に記録して、_redrawObjects
でキャンバスを再描画します
// ペンの色と塗りつぶしの色を同時に設定
color(...args) {
if (args.length == 1) {
this.registeredFigures.push(["pencolor", args[0]]);
this.registeredFigures.push(["fillcolor", args[0]]);
}
if (args.length == 2) {
this.registeredFigures.push(["pencolor", args[0]]);
this.registeredFigures.push(["fillcolor", args[1]]);
}
this._redrawObjects();
}
4. penup
メソッド
-
penEnabled
をfalse
にして、移動時に線を引かないように設定します
// ペンを持ち上げて描画を停止
penup() {
this.penEnabled = false;
}
5. pendown
メソッド
-
penEnabled
をtrue
にして、ペンを下ろして再び描画を開始できるようにします
// ペンを下ろして描画を再開
pendown() {
this.penEnabled = true;
}
6. pensize
メソッド
- 描画される線の太さを
width
で設定します - 設定は
registeredFigures
に記録されるため、再描画の際にも適用されます
// 直線の太さを設定
pensize(width) {
this.registeredFigures.push(["pensize", width]);
}
タートルの表示や大きさに関する機能
1. showturtle
メソッド
-
turtleVisible
をtrue
に設定し、Turtleがキャンバス上に表示されるようにします -
showturtle
を呼び出した際は、_redrawObjects
でキャンバスを更新し、Turtleの表示が即座に反映されます
// Turtleを表示
showturtle() {
this.turtleVisible = true;
this._redrawObjects();
}
2. hideturtle
メソッド
-
hideturtle
はturtleVisible
をfalse
に設定して、Turtleをキャンバスから非表示にします - 表示の変更は
_redrawObjects
によって再描画され、Turtleが見えなくなります
// Turtleを非表示
hideturtle() {
this.turtleVisible = false;
this._redrawObjects();
}
3. turtlesize
メソッド
-
turtlesize
はstretch
倍にサイズを拡大または縮小する設定を行います - 例えば、
turtlesize(2)
とするとTurtleのサイズが2倍になります。この設定はregisteredFigures
に記録されるため、再描画にも適用されます
// Turtleの大きさを設定
turtlesize(stretch) {
this.registeredFigures.push(["turtlesize", stretch]);
this._redrawObjects();
}
4. speed
メソッド
-
speed
では、SPEED_TABLE
を参照して描画の遅延時間を設定します
// 描画速度を設定
speed(speed) {
this.delayTime = SPEED_TABLE[speed];
}
その他の図形の描画に関する機能
1. stamp
メソッド
-
registeredFigures
にスタンプ情報として、centerX
,centerY
,directionAngle
,turtleExpand
の状態が保存されるため、Turtleの状態をその場で記録しつつ、再描画にも反映されます
// 現在のTurtleの位置にスタンプを押す
stamp() {
this.registeredFigures.push(["stamp", [
this.centerX, this.centerY, this.directionAngle, this.turtleExpand]]);
this._redrawObjects();
}
2. _createDot
メソッド
-
_createDot
は円を描くための内部メソッドで、キャンバス上に指定されたサイズの円を描きます -
penColor
を使用して円を描画し、fillColor
を復元して他の描画に影響を与えないようにしています
// 円を描画する(内部処理)
_createDot(centerX, centerY, size) {
this.context.fillStyle = this.penColor;
this.context.beginPath();
this.context.arc(centerX, centerY, size / 2, 0, 360 * Math.PI, false);
this.context.fill();
this.context.fillStyle = this.fillColor;
}
3. showturtle
メソッド
-
dot
メソッドを呼び出すと、Turtleの現在位置に円を描画します - 描画の情報は
registeredFigures
に登録されるため、キャンバスが再描画される際にこの円も再描画されるようになっています
// 円を描画する
dot(size) {
this.registeredFigures.push(["dot", [this.centerX, this.centerY, size]]);
this._redrawObjects();
}
塗りつぶしに関する機能
1. _fillLine
メソッド
-
_fillLine
メソッドは、塗りつぶしの範囲を定義する直線を描画します -
context.lineTo
だけを使用して直線を描きます- これは
_drawLine
メソッドを使うとcontext.beginPath
が更新されてしまうためです
- これは
// 塗りつぶしのときの直線の描画(内部処理)
_fillLine(fromX, fromY, toX, toY) {
this.context.lineTo(toX, toY);
}
2. _beginFill
メソッド
-
_beginFill
は内部で塗りつぶしの形状を構築し、実際に塗りつぶす処理を行います -
endIndex
が設定されていない場合は何もしません -
beginIndex
からendIndex
までの範囲でregisteredFigures
に保存されている図形(線や円)を取り出して描画し、パスを閉じてから指定色で塗りつぶします
Pythonでの実装に合わせるために context.fill("evenodd")
を使って、偶奇塗りつぶしルールで範囲を塗りつぶしています。
// 塗りつぶしを開始する(内部処理)
_beginFill(beginIndex, endIndex, fillStyle) {
// 塗りつぶしの開始インデックスが設定されていない場合は処理を終了
if (isNaN(endIndex)) {
return;
}
this.context.fillStyle = fillStyle;
this.context.beginPath();
// 指定された範囲の図形を描画
for (let i = beginIndex; i < endIndex; i++) {
let figure = this.registeredFigures[i];
let args = figure[1];
if (figure[0] == "line") {
this._fillLine(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "circle") {
this._fillCircle(args[0], args[1], args[2], args[3], args[4], args[5]);
}
}
this.context.closePath();
this.context.fill("evenodd");
}
3. begin_fill
メソッド
-
begin_fill
を呼ぶと、現在のregisteredFigures
のインデックス位置をbeginFillIndex
に記録し、塗りつぶしの開始を示す情報を配列に追加します - これにより、塗りつぶしの範囲の始点が定義され、
_redrawObjects
で画面が更新されます
// 塗りつぶしを開始する
begin_fill() {
this.beginFillIndex = this.registeredFigures.length;
this.registeredFigures.push(["begin_fill", [this.registeredFigures.length + 1, NaN, NaN]]);
this._redrawObjects();
}
4. end_fill
メソッド
-
end_fill
を呼び出すと、begin_fill
で記録した開始位置から現在の位置までの範囲を塗りつぶします -
beginFillIndex
が有効であれば、範囲内の図形を塗りつぶし、fillColor
を使用してその部分を塗りつぶします - 終了インデックスを
registeredFigures
に追加することで、塗りつぶしの範囲が確定されます
// 塗りつぶしを終了する
end_fill() {
// 塗りつぶしの開始インデックスが設定されていない場合は処理を終了
if (isNaN(this.beginFillIndex)) {
return;
}
// 塗りつぶしの終了インデックスを設定
this.registeredFigures[this.beginFillIndex][1][1] = this.registeredFigures.length;
this.registeredFigures[this.beginFillIndex][1][2] = this.fillColor;
this.beginFillIndex = NaN;
this._redrawObjects();
}
全体の概要
ここまでですべてのプログラムを解説しました。
この記事では goto
や setheading
、undo
などの解説を省いています。
これらの関数の実装が気になる場合は 完全版のソースコード をご覧ください。
以下が以上の内容をまとめたコードです。
turtle.js
// Turtleを描画するための座標データ
const SHAPE = [
[0, 14], [-2, 12], [-1, 8], [-4, 5], [-7, 7], [-9, 6],
[-6, 3], [-7, -1], [-5, -5], [-8, -8], [-6, -10], [-4, -7],
[0, -9], [4, -7], [6, -10], [8, -8], [5, -5], [7, -1],
[6, 3], [9, 6], [7, 7], [4, 5], [1, 8], [2, 12], [0, 14]
];
// 描画スピードの設定テーブル(数値が小さいほど速く動く)
const SPEED_TABLE = {
0: 0, 1: 40, 2: 36, 3: 30, 4: 22, 5: 16, 6: 12, 7: 8, 8: 6, 9: 4, 10: 2
};
// Turtleが動く際の1ステップの移動距離と回転角度
const DELTA_XY = 4;
const DELTA_ANGLE = 4;
// 描画をコントロールするクラスを定義
class Turtle {
constructor(width, height, canvasID) {
// キャンバスの幅と高さを設定
this.cvWidth = width;
this.cvHeight = height;
// キャンバスの要素を取得
this.canvas = document.getElementById(canvasID);
// キャンバスの幅と高さを設定
this.canvas.width = this.cvWidth;
this.canvas.height = this.cvHeight;
// キャンバスのコンテキストを取得
this.context = this.canvas.getContext('2d');
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
this.reset();
}
// Turtleの状態をリセットし、初期位置に戻す
reset() {
// 描画履歴を初期化
this.registeredFigures = [];
// Turtleの向き、位置、サイズを初期化
this.directionAngle = 0;
this.centerX = this.cvWidth / 2;
this.centerY = this.cvHeight / 2;
this.turtleExpand = 1;
// 塗りつぶしの開始インデックスを初期化
this.beginFillIndex = NaN;
// ペンの色、サイズ、塗りつぶしの色を初期化
this.penColor = "#000000";
this.fillColor = "#000000";
this.penSize = 1;
this.turtleSize = 1;
// ペンの有効無効、Turtleの表示非表示、描画速度を初期化
this.penEnabled = true;
this.turtleVisible = true;
this.delayTime = SPEED_TABLE[6];
// デフォルトの描画設定を登録
this.registeredFigures.push(["pencolor", this.penColor]);
this.registeredFigures.push(["fillcolor", this.fillColor]);
this.registeredFigures.push(["pensize", this.penSize]);
this.registeredFigures.push(["turtlesize", this.turtleSize]);
this._redrawObjects();
}
// キャンバスをクリアする(内部処理)
_clearCanvas() {
this.context.clearRect(0, 0, this.cvWidth, this.cvHeight);
}
// 一定時間待機するプロミスを返す(内部処理)
_sleepMS(milSecond) {
return new Promise(resolve => setTimeout(resolve, milSecond));
}
// プログラムの実行を一時停止する(内部処理)
async _delayProgram() {
await this._sleepMS(this.delayTime);
}
// 直線を描画する(内部処理)
_drawLine(fromX, fromY, toX, toY) {
this.context.beginPath();
this.context.moveTo(fromX, fromY);
this.context.lineTo(toX, toY);
this.context.stroke();
}
// Turtleを移動する(内部処理)
async _forward(distance) {
const SIGN = distance > 0 ? 1 : -1; // 進む方向を決定
const TIMES = distance * SIGN / DELTA_XY; // 進行の分割回数を計算
const START_X = this.centerX;
const START_Y = this.centerY;
const COS = Math.cos(this.directionAngle / 180 * Math.PI);
const SIN = Math.sin(this.directionAngle / 180 * Math.PI);
// 進行中に少しずつ描画更新
if (this.delayTime > 0) {
for (let i = 0; i < TIMES; i++) {
this.centerX += DELTA_XY * COS * SIGN;
this.centerY -= DELTA_XY * SIN * SIGN;
this._redrawObjects();
if (this.penEnabled) {
this._drawLine(START_X, START_Y, this.centerX, this.centerY);
}
if (this.turtleVisible) {
this._drawTurtle();
}
await this._delayProgram();
}
}
this.centerX = START_X + distance * COS;
this.centerY = START_Y - distance * SIN;
// ペンが有効な場合は直線を描画
if (this.penEnabled) {
this.registeredFigures.push(["line", [START_X, START_Y, this.centerX, this.centerY]]);
}
this._redrawObjects();
}
// 前方に移動する
async forward(distance) {
await this._forward(distance);
}
// 後方に移動する
async backward(distance) {
await this._forward(-distance);
}
// Turtleを回転する(内部処理)
async _right(angle) {
const SIGN = angle > 0 ? 1 : -1; // 回転方向を決定
const TIMES = angle * SIGN / DELTA_ANGLE; // 回転の分割回数を計算
const START_ANGLE = this.directionAngle;
// 回転中に少しずつ描画更新
if (this.delayTime > 0) {
for (let i = 0; i < TIMES; i++) {
this.directionAngle -= DELTA_ANGLE * SIGN;
this._redrawObjects();
await this._delayProgram();
}
}
this.directionAngle = START_ANGLE - angle;
this._redrawObjects();
}
async right(angle) {
await this._right(angle);
}
async left(angle) {
await this._right(-angle);
}
// ペンの色を設定
pencolor(color) {
this.registeredFigures.push(["pencolor", color]);
this._redrawObjects();
}
// 塗りつぶしの色を設定
fillcolor(color) {
this.registeredFigures.push(["fillcolor", color]);
this._redrawObjects();
}
// ペンの色と塗りつぶしの色を同時に設定
color(...args) {
if (args.length == 1) {
this.registeredFigures.push(["pencolor", args[0]]);
this.registeredFigures.push(["fillcolor", args[0]]);
}
if (args.length == 2) {
this.registeredFigures.push(["pencolor", args[0]]);
this.registeredFigures.push(["fillcolor", args[1]]);
}
this._redrawObjects();
}
// ペンを持ち上げて描画を停止
penup() {
this.penEnabled = false;
}
// ペンを下ろして描画を再開
pendown() {
this.penEnabled = true;
}
// 直線の太さを設定
pensize(width) {
this.registeredFigures.push(["pensize", width]);
}
// Turtleを表示
showturtle() {
this.turtleVisible = true;
this._redrawObjects();
}
// Turtleを非表示
hideturtle() {
this.turtleVisible = false;
this._redrawObjects();
}
// Turtleの大きさを設定
turtlesize(stretch) {
this.registeredFigures.push(["turtlesize", stretch]);
this._redrawObjects();
}
// 描画速度を設定
speed(speed) {
this.delayTime = SPEED_TABLE[speed];
}
// 現在のTurtleの位置にスタンプを押す
stamp() {
this.registeredFigures.push(["stamp", [
this.centerX, this.centerY, this.directionAngle, this.turtleExpand]]);
this._redrawObjects();
}
// 円を描画する(内部処理)
_createDot(centerX, centerY, size) {
this.context.fillStyle = this.penColor;
this.context.beginPath();
this.context.arc(centerX, centerY, size / 2, 0, 360 * Math.PI, false);
this.context.fill();
this.context.fillStyle = this.fillColor;
}
// 円を描画する
dot(size) {
this.registeredFigures.push(["dot", [this.centerX, this.centerY, size]]);
this._redrawObjects();
}
// 塗りつぶしを開始する(内部処理)
_beginFill(beginIndex, endIndex, fillStyle) {
// 塗りつぶしの開始インデックスが設定されていない場合は処理を終了
if (isNaN(endIndex)) {
return;
}
this.context.fillStyle = fillStyle;
this.context.beginPath();
// 指定された範囲の図形を描画
for (let i = beginIndex; i < endIndex; i++) {
let figure = this.registeredFigures[i];
let args = figure[1];
if (figure[0] == "line") {
this._fillLine(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "circle") {
this._fillCircle(args[0], args[1], args[2], args[3], args[4], args[5]);
}
}
this.context.closePath();
this.context.fill("evenodd");
}
// 塗りつぶしを開始する
begin_fill() {
this.beginFillIndex = this.registeredFigures.length;
this.registeredFigures.push(["begin_fill", [this.registeredFigures.length + 1, NaN, NaN]]);
this._redrawObjects();
}
// 塗りつぶしを終了する
end_fill() {
// 塗りつぶしの開始インデックスが設定されていない場合は処理を終了
if (isNaN(this.beginFillIndex)) {
return;
}
// 塗りつぶしの終了インデックスを設定
this.registeredFigures[this.beginFillIndex][1][1] = this.registeredFigures.length;
this.registeredFigures[this.beginFillIndex][1][2] = this.fillColor;
this.beginFillIndex = NaN;
this._redrawObjects();
}
// 塗りつぶしのときの直線の描画(内部処理)
_fillLine(fromX, fromY, toX, toY) {
this.context.lineTo(toX, toY);
}
// Turtleの形状を描画する(内部処理)
_drawTurtle(centerX = NaN, centerY = NaN, directionAngle = NaN, turtleExpand = NaN) {
// 引数が指定されていない場合は現在の状態を使用
if (isNaN(centerX)) { centerX = this.centerX; }
if (isNaN(centerY)) { centerY = this.centerY; }
if (isNaN(directionAngle)) { directionAngle = this.directionAngle; }
if (isNaN(turtleExpand)) { turtleExpand = this.turtleExpand; }
const RADIAN = (directionAngle - 90) / 180 * Math.PI;
const COS = Math.cos(RADIAN);
const SIN = Math.sin(RADIAN);
this.context.beginPath();
this.context.lineWidth = this.turtleExpand;
// Turtleの形状を描画
SHAPE.forEach(element => this.context.lineTo(
centerX + (element[0] * COS - element[1] * SIN) * turtleExpand,
centerY - (element[0] * SIN + element[1] * COS) * turtleExpand));
this.context.fill();
this.context.stroke();
this.context.lineWidth = this.penSize;
}
// 現在までに記録された全ての図形を再描画する
_redrawObjects(turtle = true) {
// キャンバスをクリア
this._clearCanvas();
// 登録された図形を順に描画
for (let i = 0; i < this.registeredFigures.length; i++) {
let figure = this.registeredFigures[i];
let args = figure[1];
if (figure[0] == "line") {
this._drawLine(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "pencolor") {
this.penColor = args;
this.context.strokeStyle = args;
} else if (figure[0] == "fillcolor") {
this.fillColor = args;
this.context.fillStyle = args;
} else if (figure[0] == "stamp") {
this._drawTurtle(args[0], args[1], args[2], args[3]);
} else if (figure[0] == "turtlesize") {
this.turtleExpand = args;
} else if (figure[0] == "dot") {
this._createDot(args[0], args[1], args[2]);
} else if (figure[0] == "pensize") {
this.penSize = args;
this.context.lineWidth = args;
} else if (figure[0] == "begin_fill") {
this._beginFill(args[0], args[1], args[2]);
}
}
// 表示モードが有効な場合はTurtleを描画
if (this.turtleVisible) {
this._drawTurtle();
}
}
}
せっかくなので実際にプログラムを書いて動かすことのできるようにHTMLファイルとCSSファイルも用意しておきます。(詳しい解説は省きます。)
index.html
<!DOCTYPE html>
<html>
<head>
<title>Turtle</title>
<link href="./style.css" rel="stylesheet" type="text/css">
<script type="text/javascript" src="./turtle.js"></script>
</head>
<body>
<div id="left-side">
<canvas id="canvas" title="TurtleGraphics"></canvas>
</div>
<div id="right-side">
<textarea id="textbox" placeholder="Enter the program code." maxlength=9999 cols="48" rows="28" autofocus>
// Example
turtle.color("red", "yellow");
turtle.begin_fill();
for (let i = 0; i < 5; i++) {
await turtle.forward(100);
await turtle.right(144);
}
turtle.end_fill();</textarea>
<div id="options">
<input type="button" id="run-code" value="Run Code" onclick="runCode();" />
</div>
</div>
<script>
let turtle = new Turtle(800, 600, "canvas");
let running = false;
async function runCode() {
const CODE = document.getElementById("textbox").value;
if (CODE == "") {
alert("A program code was not entered.");
} else if (running) {
alert("Another program is running.");
} else {
turtle.reset();
running = true;
await eval("(async () => {try {" + CODE + "} catch(e) {alert(e.message)}})()");
running = false;
}
}
</script>
</body>
</html>
style.css
body {
background-color: #D0D0D0;
}
#left-side {
float: left;
margin: 1em;
}
#right-side {
float: right;
margin: 1em;
}
#canvas {
background-color: #FFFFFF;
}
#textbox {
font-size: 1.6em;
font-family: monospace;
resize: none;
}
#run-code {
font-size: 2.4em;
font-family: monospace;
font-weight: bold;
color: red;
margin: 0.2em 1em;
float: left;
}
まとめ
今回のプロジェクトで、Turtle Graphics をブラウザ上で再現することができました。
HTML の Canvas にここまで複雑な描画をすることはなかなかないと思います。
この記事で紹介した JavaScript ならではの非同期処理や描画の工夫の中に、読んでくださった皆さんの参考になるものがあれば幸いです。
おわりに
初めてQiitaに投稿したので分かりづらい点も多かったかもしれません。
不具合などありましたら恐れ入りますが、フッターのお問い合わせやコメントにてお知らせいただけるとありがたいです。
最後までお読みいただき、ありがとうございました!