3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Turtle graphics をブラウザ上で使えるようにしてみた

Last updated at Posted at 2024-11-08

はじめに

はじめまして、Latte72 です。
普段は Python や C/C++、JavaScript などを使って開発をしています。最近は自作OSや自作コンパイラ、Deep Learning に興味があり、色々と挑戦中です。

以前に JavaScript で Turtle graphics を実装し、ブラウザ上で利用できるようにしたので、ソースコードとともに実装の解説をしていきます。

完成品(完全版)

demo.gif

作成した完全版のプログラムは機能が多いので、この記事では機能を削減した解説専用のプログラムを作成し、それを利用します

実際の開発では途中で 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 が少しずつ動きながら描画されるのが特徴です。そのためには「図形を少し描画する」⇒「少しの時間プログラムを停止する」という操作を繰り返す必要があります。

turtle_await.gif

(「図形を少し描画する」⇒「少しの時間プログラムを停止する」のイメージ )

Python では同期処理が採用されているので一つの図形を描き終わってから次の図形を描き始めることができます。しかし、JavaScript はデフォルトで非同期処理なので、適切な処理をしないと以下のように複数の操作の描画が同時にに起こってしまいます。

turtle_await.gif

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 クラスのコンストラクタでは、キャンバスのセットアップを行います。

  1. this.cvWidththis.cvHeight:
    • タートルが描画するキャンバスの幅と高さを格納します
  2. this.canvas:
    • document.getElementById(canvasID) を使って、HTML上の要素を取得します
    • canvasID は、タートルの描画先となるキャンバスのIDです
  3. this.canvas.widththis.canvas.height:
    • キャンバスの実際の幅と高さを設定します
  4. 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 メソッド

  1. 描画履歴 (registeredFigures) の初期化:

    • 過去の描画履歴をリセットします
  2. タートルの向き・位置・サイズ:

    • 向きを上方向 (0 度) にし、位置をキャンバス中央に設定
    • タートルの拡大率を 1 に初期化
  3. ペンの設定:

    • 線色と塗りつぶし色を黒 (#000000) に、線の太さ (penSize) とタートルのサイズ (turtleSize) を 1 に設定する
  4. 表示設定と描画速度:

    • ペン描画を有効 (penEnabled) にし、タートルを表示状態に設定する
    • 描画速度は中速 (SPEED_TABLE[6]) に設定する
  5. デフォルトの設定登録と再描画:

    • デフォルトの設定(ペン色やサイズ)を履歴に登録し、_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 の形状を描画するためのメソッドです。

  1. 引数のデフォルト設定

    • centerXcenterY などの引数が指定されていない場合(NaN の場合)は、現在のTurtleの位置と角度を使います
    • これにより、現在の状態でTurtleを描画できるようになっています
  2. 回転角度の計算

    • Turtleの方向角度 directionAngle をラジアンに変換します
    • このラジアン角度は後の座標計算に使われます
    • -90 は、Turtleの向きが通常の「上方向」を基準にしているための調整です
  3. 正弦と余弦の計算

    • ラジアン角度から正弦 (SIN) と余弦 (COS) を計算し、後で座標変換に利用します
  4. Turtleの形状の描画

    • SHAPE 配列に定義されたTurtleの形状(例えば、三角形など)を元に、各頂点の位置を計算して線を引きます
    • 座標は、回転角度と拡大率に基づいて変換されています
    • これにより、指定された位置・向きに合わせてTurtleの形状が描画されます
  5. 描画の完了

    • 最後に形状を塗りつぶし、線で囲むことで、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 によって描かれます。

  1. キャンバスのクリア

    • 既存の描画内容を削除してキャンバスをクリアします
  2. 図形の順次再描画

    • registeredFigures に格納された各図形・スタイル情報に基づいて、順に再描画を行います
    • 各図形・設定の種類 (figure[0]) に応じて、描画メソッドやプロパティ設定が切り替わります
  3. スタイルの更新・図形の再描画

    • 線、ペンの色、塗りつぶし色、ペンの太さ、塗りつぶし開始などのスタイルを更新します
    • スタンプ、Turtleのサイズ、円などの図形をそれぞれの図形を再描画します
  4. Turtleの描画

    • turtleVisibletrue の場合、現在の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 メソッドの役割

  1. 描画開始: beginPath を呼び出して、新しいパスを開始します
  2. 始点の設定: moveTo を使って、指定された始点 (fromX, fromY) に移動します
  3. 終点までの線を引く: lineTo を使って終点 (toX, toY) まで直線を描画します
  4. 線の描画: stroke を呼び出すことで、指定された線のスタイルで実際に描画が行われます
    // 直線を描画する(内部処理)
    _drawLine(fromX, fromY, toX, toY) {
        this.context.beginPath();
        this.context.moveTo(fromX, fromY);
        this.context.lineTo(toX, toY);
        this.context.stroke();
    }

2. _forward メソッド

  1. 進行方向と回数の計算:
    • SIGN は前進・後退の方向を設定するための符号です
    • TIMES は移動を小刻みに分割するための回数で、スムーズな描画更新に使われます
  2. 小刻みな移動と描画の更新:
    • 移動距離を複数回のステップに分割して、for ループで少しずつ centerXcenterY を更新し、各ステップごとに描画を行います
    • penEnabled が有効な場合は、各ステップで描画する線分を更新します
    • turtleVisibletrue なら Turtle 自体を描画します
    • await this._delayProgram で、描画更新ごとに一定時間待機します
  3. 最終位置の調整:
    • centerXcenterY の最終位置を計算し、移動完了時の位置に設定します
  4. 描画履歴の記録:
    • 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 メソッド

  1. 進行方向と回数の計算:
    • SIGN は回転の方向を設定するための符号です
    • TIMES は回転を小刻みに分割するための回数で、スムーズな描画更新に使われます
  2. 小刻みな回転と描画の更新:
    • 回転角度を複数回のステップに分割して、for ループで少しずつ directionAngle を更新し、各ステップごとに描画を行います
    • await this._delayProgram で、描画更新ごとに一定時間待機します
  3. 最終位置の調整:
    • 最終的な角度を計算し、回転完了時の角度に設定します
    // 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 メソッド

  • penEnabledfalse にして、移動時に線を引かないように設定します
    // ペンを持ち上げて描画を停止
    penup() {
        this.penEnabled = false;
    }

5. pendown メソッド

  • penEnabledtrue にして、ペンを下ろして再び描画を開始できるようにします
    // ペンを下ろして描画を再開
    pendown() {
        this.penEnabled = true;
    }

6. pensize メソッド

  • 描画される線の太さを width で設定します
  • 設定は registeredFigures に記録されるため、再描画の際にも適用されます
    // 直線の太さを設定
    pensize(width) {
        this.registeredFigures.push(["pensize", width]);
    }

タートルの表示や大きさに関する機能

1. showturtle メソッド

  • turtleVisibletrue に設定し、Turtleがキャンバス上に表示されるようにします
  • showturtle を呼び出した際は、_redrawObjects でキャンバスを更新し、Turtleの表示が即座に反映されます
    // Turtleを表示
    showturtle() {
        this.turtleVisible = true;
        this._redrawObjects();
    }

2. hideturtle メソッド

  • hideturtleturtleVisiblefalse に設定して、Turtleをキャンバスから非表示にします
  • 表示の変更は _redrawObjects によって再描画され、Turtleが見えなくなります
    // Turtleを非表示
    hideturtle() {
        this.turtleVisible = false;
        this._redrawObjects();
    }

3. turtlesize メソッド

  • turtlesizestretch 倍にサイズを拡大または縮小する設定を行います
  • 例えば、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();
    }

全体の概要

ここまでですべてのプログラムを解説しました。

この記事では gotosetheadingundo などの解説を省いています。
これらの関数の実装が気になる場合は 完全版のソースコード をご覧ください。

以下が以上の内容をまとめたコードです。

turtle.js
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
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 &lt 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
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に投稿したので分かりづらい点も多かったかもしれません。

不具合などありましたら恐れ入りますが、フッターのお問い合わせやコメントにてお知らせいただけるとありがたいです。

最後までお読みいただき、ありがとうございました!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?