キャンバスへの描画

描画は欺瞞である。

M.C. エッシャー、ブルーノ・エルンスト著「M.C. エッシャーの魔法の鏡」より引用
Illustration showing an industrial-looking robot arm drawing a city on a piece of paper

ブラウザはグラフィックを表示するいくつかの方法を提供しています。最も簡単な方法は、スタイルを使用して通常のDOM要素を配置し、色を付けることです。前の章のゲームで示したように、これによってかなりのことができます。ノードに半透明の背景画像を追加することで、思い通りの外観にすることができます。transformスタイルを使用してノードを回転または歪めることも可能です。

しかし、これはDOMを本来の設計目的とは異なる用途に使用することになります。任意の点間に線を描くなど、一部のタスクは通常のHTML要素では非常に扱いにくいです。

代替手段は2つあります。1つ目はDOMベースですが、HTMLではなく*スケーラブルベクターグラフィックス*(SVG)を利用します。SVGは、テキストではなく図形に焦点を当てたドキュメントマークアップ言語と考えてください。SVGドキュメントは、HTMLドキュメントに直接埋め込むか、<img>タグで含めることができます。

2つ目の代替手段は*キャンバス*です。キャンバスは、画像をカプセル化する単一のDOM要素です。ノードが占めるスペースに図形を描くためのプログラミングインターフェースを提供します。キャンバスとSVG画像の主な違いは、SVGでは図形の元の説明が保持されるため、いつでも移動またはサイズ変更できることです。一方、キャンバスは、図形が描画されるとすぐにピクセル(ラスター上の色のついた点)に変換し、これらのピクセルが何を表しているかを記憶しません。キャンバス上の図形を移動する唯一の方法は、キャンバス(または図形の周りのキャンバスの一部)をクリアし、図形を新しい位置に再描画することです。

SVG

本書ではSVGについては詳しく説明しませんが、その仕組みを簡単に説明します。章の最後で、特定のアプリケーションに適した描画メカニズムを決定する際に考慮すべきトレードオフについて改めて説明します。

これは、単純なSVG画像を含むHTMLドキュメントです。

<p>Normal HTML here.</p>
<svg xmlns="http://www.w3.org/2000/svg">
  <circle r="50" cx="50" cy="50" fill="red"/>
  <rect x="120" y="5" width="90" height="90"
        stroke="blue" fill="none"/>
</svg>

xmlns属性は、要素(とその子)を別の*XML名前空間*に変更します。URLで識別されるこの名前空間は、現在使用している言語を指定します。HTMLには存在しない<circle>タグと<rect>タグは、SVGでは意味を持ちます。これらのタグは、属性で指定されたスタイルと位置を使用して図形を描画します。

これらのタグは、HTMLタグと同様に、スクリプトが操作できるDOM要素を作成します。たとえば、これは<circle>要素の色をシアンに変更します。

let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");

canvas要素

キャンバスグラフィックは、<canvas>要素に描画できます。このような要素にwidth属性とheight属性を指定して、ピクセル単位のサイズを決定できます。

新しいキャンバスは空です。つまり、完全に透明であり、ドキュメントでは空のスペースとして表示されます。

<canvas>タグは、さまざまな描画スタイルを可能にすることを目的としています。実際の描画インターフェースにアクセスするには、最初に*コンテキスト*、つまりメソッドが描画インターフェースを提供するオブジェクトを作成する必要があります。現在、広くサポートされている描画スタイルは3つあります。2次元グラフィック用の"2d"、OpenGLインターフェースを介した3次元グラフィック用の"webgl"、そしてWebGLのより現代的で柔軟な代替手段である"webgpu"です。

本書ではWebGLまたはWebGPUについては説明しません。2次元にとどまります。ただし、3次元グラフィックスに興味がある場合は、WebGPUを調べてみることをお勧めします。グラフィックハードウェアへの直接インターフェースを提供し、JavaScriptを使用して複雑なシーンでも効率的にレンダリングできます。

<canvas> DOM要素のgetContextメソッドを使用してコンテキストを作成します。

<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
  let canvas = document.querySelector("canvas");
  let context = canvas.getContext("2d");
  context.fillStyle = "red";
  context.fillRect(10, 10, 100, 50);
</script>

コンテキストオブジェクトを作成した後、この例では、幅100ピクセル、高さ50ピクセルの赤い四角形を、左上隅の座標(10、10)に描画します。

HTML(およびSVG)と同様に、キャンバスが使用する座標系は、左上隅に(0、0)を配置し、正のy軸はそこから下に移動します。これは、(10、10)が左上隅から10ピクセル下および右にあることを意味します。

線と面

キャンバスインターフェースでは、図形を*塗りつぶす*、つまりその領域に特定の色またはパターンを指定するか、*ストローク*、つまりエッジに沿って線を描くことができます。SVGも同じ用語を使用します。

fillRectメソッドは四角形を塗りつぶします。最初に四角形の左上隅のx座標とy座標、次に幅、次に高さを取ります。strokeRectという同様のメソッドは、四角形の輪郭を描画します。

どちらのメソッドもそれ以上のパラメータを取りません。塗りつぶしの色、ストロークの太さなどは、合理的に期待されるようにメソッドの引数によって決定されるのではなく、コンテキストオブジェクトのプロパティによって決定されます。

fillStyleプロパティは、図形の塗りつぶし方法を制御します。CSSで使用される色表記を使用して、色を指定する文字列に設定できます。

strokeStyleプロパティも同様に機能しますが、ストロークされた線に使用される色を決定します。その線の幅は、任意の正の数を含むことができるlineWidthプロパティによって決定されます。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.strokeStyle = "blue";
  cx.strokeRect(5, 5, 50, 50);
  cx.lineWidth = 5;
  cx.strokeRect(135, 5, 50, 50);
</script>

例のように、width属性またはheight属性が指定されていない場合、canvas要素はデフォルトの幅300ピクセル、高さ150ピクセルを取得します。

パス

パスは一連の線です。2Dキャンバスインターフェースは、このようなパスを記述するために独特のアプローチを取ります。それは完全に副作用によって行われます。パスは、保存および受け渡しできる値ではありません。代わりに、パスで何かをしたい場合は、一連のメソッド呼び出しを行ってその形状を記述します。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  for (let y = 10; y < 100; y += 10) {
    cx.moveTo(10, y);
    cx.lineTo(90, y);
  }
  cx.stroke();
</script>

この例では、いくつかの水平線分を持つパスを作成し、strokeメソッドを使用してストロークします。lineToで作成された各セグメントは、パスの*現在*の位置から始まります。その位置は通常、moveToが呼び出されない限り、最後のセグメントの終わりです。その場合、次のセグメントはmoveToに渡された位置から始まります。

パスを塗りつぶす(fillメソッドを使用する)場合、各図形は個別に塗りつぶされます。パスには複数の図形を含めることができます。各moveToモーションは新しい図形を開始します。ただし、パスを塗りつぶす前に、パスを*閉じる*(開始と終了が同じ位置にあることを意味する)必要があります。パスがまだ閉じていない場合は、その端からその開始まで線が追加され、完成したパスで囲まれた図形が塗りつぶされます。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(50, 10);
  cx.lineTo(10, 70);
  cx.lineTo(90, 70);
  cx.fill();
</script>

この例では、塗りつぶされた三角形を描画します。三角形の辺のうち2つだけが明示的に描画されていることに注意してください。3つ目は、右下隅から上に戻るもので、暗黙的であり、パスをストロークした場合は存在しません。

closePathメソッドを使用して、パスの開始点に実際の線分を追加することにより、パスを明示的に閉じることができます。このセグメントは、パスをストロークするときに*描画され*ます。

曲線

パスには曲線を含めることもできます。これらは残念ながら、描画するのが少し複雑です。

quadraticCurveToメソッドは、指定された点に曲線を描画します。線の曲率を決定するために、メソッドには制御点と宛先点が与えられます。この制御点は線を*引き付ける*と想像して、曲線を与えます。線は制御点を通過しませんが、開始点と終了点の方向は、その方向の直線が制御点を指すようになります。次の例はこれを示しています。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control=(60, 10) goal=(90, 90)
  cx.quadraticCurveTo(60, 10, 90, 90);
  cx.lineTo(60, 10);
  cx.closePath();
  cx.stroke();
</script>

(60、10)を制御点として、左から右に2次曲線を描画し、次にその制御点を通過して線の開始点に戻る2つの線分を描画します。結果は、*スタートレック*の記章にいくぶん似ています。制御点の効果を確認できます。下隅から出ている線は、制御点の方向から始まり、ターゲットに向かってカーブします。

bezierCurveToメソッドは、同様の種類の曲線を描画します。単一の制御点の代わりに、このメソッドには2つあります。線の端点ごとに1つです。このような曲線の動作を示す同様のスケッチを以下に示します。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  cx.moveTo(10, 90);
  // control1=(10, 10) control2=(90, 10) goal=(50, 90)
  cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
  cx.lineTo(90, 10);
  cx.lineTo(10, 10);
  cx.closePath();
  cx.stroke();
</script>

2つの制御点は、曲線の両端の向きを指定します。対応する点から遠いほど、曲線はその方向に大きく「膨らみ」ます。

このような曲線を扱うのは難しい場合があります。求める形状を提供する制御点を見つける方法が必ずしも明確とは限らないためです。計算できる場合もありますが、試行錯誤によって適切な値を見つけなければならない場合もあります。

arcメソッドは、円の縁に沿ってカーブする線を描く方法です。円弧の中心の座標のペア、半径、開始角度と終了角度を取ります。

最後の2つのパラメータにより、円の一部のみを描くことができます。角度は度ではなくラジアンで測定されます。これは、円の角度が2π、つまり2 * Math.PI(約6.28)であることを意味します。角度は円の中心の右側の点から始まり、そこから時計回りに数えます。開始を0、終了を2πより大きい値(たとえば7)にすると、完全な円を描くことができます。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.beginPath();
  // center=(50, 50) radius=40 angle=0 to 7
  cx.arc(50, 50, 40, 0, 7);
  // center=(150, 50) radius=40 angle=0 to ½π
  cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
  cx.stroke();
</script>

結果の図には、完全な円の右側(arcの最初の呼び出し)から四分円の右側(2回目の呼び出し)への線が含まれています。

他のパス描画メソッドと同様に、arcで描かれた線は前のパスセグメントに接続されます。moveToを呼び出すか、新しいパスを開始することでこれを回避できます。

円グラフの描画

EconomiCorp, Inc.に就職したばかりだとします。最初の課題は、顧客満足度調査の結果の円グラフを描くことです。

resultsバインディングには、調査回答を表すオブジェクトの配列が含まれています。

const results = [
  {name: "Satisfied", count: 1043, color: "lightblue"},
  {name: "Neutral", count: 563, color: "lightgreen"},
  {name: "Unsatisfied", count: 510, color: "pink"},
  {name: "No comment", count: 175, color: "silver"}
];

円グラフを描くには、円弧と円弧の中心への線のペアで構成される複数の円グラフのスライスを描画します。各円弧が占める角度は、完全な円(2π)を回答の総数で割り、その数(回答ごとの角度)に特定の選択肢を選んだ人の数を掛けて計算できます。

<canvas width="200" height="200"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  // Start at the top
  let currentAngle = -0.5 * Math.PI;
  for (let result of results) {
    let sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    // center=100,100, radius=100
    // from current angle, clockwise by slice's angle
    cx.arc(100, 100, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(100, 100);
    cx.fillStyle = result.color;
    cx.fill();
  }
</script>

しかし、スライスの意味がわからないチャートはあまり役に立ちません。キャンバスにテキストを描画する方法が必要です。

テキスト

2Dキャンバス描画コンテキストは、fillTextメソッドとstrokeTextメソッドを提供します。後者は文字の輪郭を描くのに役立ちますが、通常はfillTextが必要です。指定されたテキストの輪郭を現在のfillStyleで塗りつぶします。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.font = "28px Georgia";
  cx.fillStyle = "fuchsia";
  cx.fillText("I can draw text, too!", 10, 50);
</script>

fontプロパティを使用して、テキストのサイズ、スタイル、フォントを指定できます。この例では、フォントサイズとファミリ名のみを指定しています。スタイルを選択するために、文字列の先頭にitalicまたはboldを追加することもできます。

fillTextstrokeTextの最後の2つの引数は、フォントが描画される位置を指定します。デフォルトでは、テキストのアルファベットベースラインの開始位置を示します。これは、_j_や_p_などの文字のぶら下がっている部分を除いて、文字が「立つ」線です。textAlignプロパティを"end"または"center"に設定することで水平方向の位置を変更し、textBaselineプロパティを"top""middle"、または"bottom"に設定することで垂直方向の位置を変更できます。

円グラフと、スライスにラベルを付ける問題については、章末の演習で再び取り上げます。

画像

コンピュータグラフィックスでは、多くの場合、_ベクター_グラフィックスと_ビットマップ_グラフィックスが区別されます。最初のものは、この章でこれまで行ってきたこと、つまり形状の論理的な記述を提供することによって画像を指定することです。一方、ビットマップグラフィックスは実際の形状を指定するのではなく、ピクセルデータ(色のドットのラスター)を処理します。

drawImageメソッドを使用すると、ピクセルデータをキャンバスに描画できます。このピクセルデータは、<img>要素または別のキャンバスから取得できます。次の例では、デタッチされた<img>要素を作成し、画像ファイルをロードします。ただし、ブラウザがまだロードしていない可能性があるため、メソッドはこの画像からすぐに描画を開始できません。これを処理するために、"load"イベントハンドラを登録し、画像がロードされた後に描画を行います。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/hat.png";
  img.addEventListener("load", () => {
    for (let x = 10; x < 200; x += 30) {
      cx.drawImage(img, x, 10);
    }
  });
</script>

デフォルトでは、drawImageは画像を元のサイズで描画します。描画された画像の幅と高さが元の画像と異なる場合に、それらを指定するために2つの追加の引数を指定することもできます。

drawImageに_9つ_の引数が指定されている場合、画像のフラグメントのみを描画するために使用できます。2番目から5番目の引数は、コピーする必要があるソース画像の長方形(x、y、幅、高さ)を示し、6番目から9番目の引数は、コピー先のキャンバス上の長方形を示します。

これは、複数の_スプライト_(画像要素)を単一の画像ファイルにパックし、必要な部分のみを描画するために使用できます。たとえば、この図には、複数のポーズのゲームキャラクターが含まれています

Pixel art showing a computer game character in 10 different poses. The first 8 form its running animation cycle, the 9th has the character standing still, and the 10th shows him jumping.

描画するポーズを交互に切り替えることで、歩いているキャラクターのように見えるアニメーションを表示できます。

キャンバス上の画像をアニメーション化するには、clearRectメソッドが便利です。fillRectに似ていますが、長方形を着色する代わりに、透明にして以前に描画されたピクセルを削除します。

各_スプライト_、各サブピクチャの幅は24ピクセル、高さは30ピクセルであることがわかっています。次のコードは画像をロードし、次のフレームを描画するための間隔(繰り返されるタイマー)を設定します

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    let cycle = 0;
    setInterval(() => {
      cx.clearRect(0, 0, spriteW, spriteH);
      cx.drawImage(img,
                   // source rectangle
                   cycle * spriteW, 0, spriteW, spriteH,
                   // destination rectangle
                   0,               0, spriteW, spriteH);
      cycle = (cycle + 1) % 8;
    }, 120);
  });
</script>

cycleバインディングは、アニメーションの位置を追跡します。フレームごとにインクリメントされ、剰余演算子を使用して0〜7の範囲にクリップバックされます。このバインディングは、現在のポーズのスプライトが画像内で持つx座標を計算するために使用されます。

変換

キャラクターを右ではなく左に歩かせたい場合はどうでしょうか?もちろん、別のスプライトセットを描くこともできます。しかし、キャンバスに画像を逆方向に描画するように指示することもできます。

scaleメソッドを呼び出すと、その後に描画されるものがすべてスケーリングされます。このメソッドは、水平方向のスケールを設定するためのパラメータと垂直方向のスケールを設定するためのパラメータの2つのパラメータを取ります。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  cx.scale(3, .5);
  cx.beginPath();
  cx.arc(50, 50, 40, 0, 7);
  cx.lineWidth = 3;
  cx.stroke();
</script>

スケーリングすると、線の幅を含め、描画された画像のすべてが指定されたとおりに引き伸ばされたり、圧縮されたりします。負の値でスケーリングすると、画像が反転します。反転は点(0、0)を中心に発生するため、座標系の向きも反転します。水平方向のスケーリング-1が適用されると、_x_位置100に描画された形状は、以前は位置-100だった位置になります。

画像を反転させるには、drawImageの呼び出しの前に単にcx.scale(-1, 1)を追加することはできません。それは私たちの画像をキャンバスの外に移動させ、そこでは見えなくなります。_x_位置0ではなく-50に画像を描画することにより、これを補償するためにdrawImageに与えられた座標を調整できます。描画を行うコードがスケール変更を知る必要がない別の解決策は、スケーリングが行われる軸を調整することです。

scale以外にも、キャンバスの座標系に影響を与えるメソッドがいくつかあります。rotateメソッドで後続の描画された形状を回転し、translateメソッドで移動できます。興味深い、そして混乱させることは、これらの変換が_スタック_されることです。つまり、それぞれが前の変換に対して発生します。

水平方向に10ピクセルを2回移動すると、すべてが右に20ピクセル描画されます。最初に座標系の中心を(50、50)に移動し、次に20度(約0.1πラジアン)回転させると、その回転は点(50、50)_を中心に_行われます。

Diagram showing the result of stacking transformations. The first diagram translates and then rotates, causing the translation to happen normally and rotation to happen around the target of the translation. The second diagram first rotates, and then translates, causing the rotation to happen around the origin and the translation direction to be tilted by that rotation.

しかし、_最初に_20度回転し、_次に_(50、50)だけ平行移動すると、平行移動は回転した座標系で行われ、したがって異なる方向が生成されます。変換が適用される順序は重要です。

指定された_x_位置にある垂直線を中心に画像を反転させるには、次の手順を実行できます。

function flipHorizontally(context, around) {
  context.translate(around, 0);
  context.scale(-1, 1);
  context.translate(-around, 0);
}

y軸をミラーを配置する場所に移動し、ミラーリングを適用し、最後にy軸をミラーリングされた世界の適切な場所に移動します。次の図は、これが機能する理由を説明しています

Diagram showing the effect of translating and mirroring a triangle

これは、中心線を基準にしてミラーリングする前後の座標系を示しています。三角形には、各ステップを示すために番号が付けられています。正の_x_位置に三角形を描画すると、デフォルトでは三角形1がある場所に配置されます。flipHorizontallyの呼び出しは、最初に右への平行移動を実行し、三角形2に移動します。次にスケーリングして、三角形を位置3に反転します。これは、指定された線でミラーリングされた場合、あるべき場所ではありません。2番目のtranslate呼び出しはこれを修正します。最初の平行移動を「キャンセル」し、三角形4をあるべき場所に正確に表示します。

キャラクターの垂直中心線を軸にワールドを反転させることで、位置 (100, 0) に反転したキャラクターを描画できます。

<canvas></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let img = document.createElement("img");
  img.src = "img/player.png";
  let spriteW = 24, spriteH = 30;
  img.addEventListener("load", () => {
    flipHorizontally(cx, 100 + spriteW / 2);
    cx.drawImage(img, 0, 0, spriteW, spriteH,
                 100, 0, spriteW, spriteH);
  });
</script>

変換の保存とクリア

変換は持続します。反転したキャラクターを描画した後に描画する他のすべてものも反転されます。これは不便な場合があります。

現在の変換を保存し、描画と変換を行い、古い変換を復元することが可能です。これは通常、座標系を一時的に変換する必要がある関数に対して適切な処理です。最初に、関数を呼び出したコードが使用していた変換を保存します。次に、関数は現在の変換の上にさらに変換を追加して処理を実行します。最後に、最初に開始した変換に戻します。

2D キャンバスコンテキストの `save` メソッドと `restore` メソッドは、この変換管理を行います。これらは概念的に変換状態のスタックを保持します。`save` を呼び出すと現在の状態がスタックにプッシュされ、`restore` を呼び出すとスタックの一番上の状態が取り出され、コンテキストの現在の変換として使用されます。また、`resetTransform` を呼び出して変換を完全にリセットすることもできます。

次の例の `branch` 関数は、変換を変更してから関数(この場合は自身)を呼び出し、指定された変換で描画を続ける関数で何ができるかを示しています。

この関数は、線を描画し、座標系の中心を線の終点に移動し、自身を2回呼び出す(最初に左に回転し、次に右に回転する)ことで、樹木のような形状を描画します。呼び出しごとに描画される枝の長さが短くなり、長さが8未満になると再帰は停止します。

<canvas width="600" height="300"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  function branch(length, angle, scale) {
    cx.fillRect(0, 0, 1, length);
    if (length < 8) return;
    cx.save();
    cx.translate(0, length);
    cx.rotate(-angle);
    branch(length * scale, angle, scale);
    cx.rotate(2 * angle);
    branch(length * scale, angle, scale);
    cx.restore();
  }
  cx.translate(300, 0);
  branch(60, 0.5, 0.8);
</script>

`save` と `restore` の呼び出しがない場合、`branch` への2回目の再帰呼び出しは、最初の呼び出しによって作成された位置と回転で終了します。現在の枝ではなく、最初の呼び出しによって描画された最も内側の右端の枝に接続されます。結果の形状も面白いかもしれませんが、明らかに木ではありません。

ゲームに戻る

これで、前の章のゲームのキャンバスベースの表示システムの作業を開始するのに十分なキャンバス描画に関する知識が得られました。新しいディスプレイは、色のついたボックスだけを表示するだけではありません。代わりに、`drawImage` を使用してゲームの要素を表す画像を描画します。

`DOMDisplay` と同じインターフェース(つまり、`syncState` メソッドと `clear` メソッド)をサポートする `CanvasDisplay` と呼ばれる別のディスプレイオブジェクトタイプを定義します。第16章

このオブジェクトは、`DOMDisplay` よりも少し多くの情報を保持します。DOM要素のスクロール位置を使用する代わりに、独自のビューポートを追跡します。ビューポートは、現在見ているレベルのどの部分を示します。最後に、`flipPlayer` プロパティを保持するため、プレイヤーが静止している場合でも、最後に移動した方向を向いたままになります。

class CanvasDisplay {
  constructor(parent, level) {
    this.canvas = document.createElement("canvas");
    this.canvas.width = Math.min(600, level.width * scale);
    this.canvas.height = Math.min(450, level.height * scale);
    parent.appendChild(this.canvas);
    this.cx = this.canvas.getContext("2d");

    this.flipPlayer = false;

    this.viewport = {
      left: 0,
      top: 0,
      width: this.canvas.width / scale,
      height: this.canvas.height / scale
    };
  }

  clear() {
    this.canvas.remove();
  }
}

`syncState` メソッドは、最初に新しいビューポートを計算し、適切な位置にゲームシーンを描画します。

CanvasDisplay.prototype.syncState = function(state) {
  this.updateViewport(state);
  this.clearDisplay(state.status);
  this.drawBackground(state.level);
  this.drawActors(state.actors);
};

`DOMDisplay` とは異なり、この表示スタイルは更新ごとに背景を再描画する必要があります。キャンバス上の形状はピクセルに過ぎないため、描画した後、それらを移動(または削除)する良い方法はありません。キャンバスの表示を更新する唯一の方法は、クリアしてシーンを再描画することです。スクロールした可能性もあり、その場合は背景を別の位置に配置する必要があります。

`updateViewport` メソッドは、`DOMDisplay` の `scrollPlayerIntoView` メソッドに似ています。プレイヤーが画面の端に近すぎるかどうかを確認し、その場合はビューポートを移動します。

CanvasDisplay.prototype.updateViewport = function(state) {
  let view = this.viewport, margin = view.width / 3;
  let player = state.player;
  let center = player.pos.plus(player.size.times(0.5));

  if (center.x < view.left + margin) {
    view.left = Math.max(center.x - margin, 0);
  } else if (center.x > view.left + view.width - margin) {
    view.left = Math.min(center.x + margin - view.width,
                         state.level.width - view.width);
  }
  if (center.y < view.top + margin) {
    view.top = Math.max(center.y - margin, 0);
  } else if (center.y > view.top + view.height - margin) {
    view.top = Math.min(center.y + margin - view.height,
                        state.level.height - view.height);
  }
};

`Math.max` と `Math.min` の呼び出しにより、ビューポートがレベル外の空間を表示しないようになります。`Math.max(x, 0)` は、結果の数がゼロより小さくないことを確認します。`Math.min` も同様に、値が指定された境界を下回るようにします。

ディスプレイをクリアするときは、ゲームに勝ったか(明るい)、負けたか(暗い)に応じて、わずかに異なる色を使用します。

CanvasDisplay.prototype.clearDisplay = function(status) {
  if (status == "won") {
    this.cx.fillStyle = "rgb(68, 191, 255)";
  } else if (status == "lost") {
    this.cx.fillStyle = "rgb(44, 136, 214)";
  } else {
    this.cx.fillStyle = "rgb(52, 166, 251)";
  }
  this.cx.fillRect(0, 0,
                   this.canvas.width, this.canvas.height);
};

背景を描画するには、前の章の `touches` メソッドで使用したものと同じトリックを使用して、現在のビューポートに表示されているタイルを実行します。

let otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";

CanvasDisplay.prototype.drawBackground = function(level) {
  let {left, top, width, height} = this.viewport;
  let xStart = Math.floor(left);
  let xEnd = Math.ceil(left + width);
  let yStart = Math.floor(top);
  let yEnd = Math.ceil(top + height);

  for (let y = yStart; y < yEnd; y++) {
    for (let x = xStart; x < xEnd; x++) {
      let tile = level.rows[y][x];
      if (tile == "empty") continue;
      let screenX = (x - left) * scale;
      let screenY = (y - top) * scale;
      let tileX = tile == "lava" ? scale : 0;
      this.cx.drawImage(otherSprites,
                        tileX,         0, scale, scale,
                        screenX, screenY, scale, scale);
    }
  }
};

空でないタイルは `drawImage` で描画されます。 `otherSprites` 画像には、プレイヤー以外の要素に使用される画像が含まれています。左から右に、壁タイル、溶岩タイル、コインのスプライトが含まれています。

Pixel art showing three sprites: a piece of wall, made out of small white stones, a square of orange lava, and a round coin.

`DOMDisplay` と同じスケールを使用するため、背景タイルは20 x 20ピクセルです。したがって、溶岩タイルのオフセットは20(`scale` バインディングの値)であり、壁のオフセットは0です。

スプライト画像の読み込みを待つ必要はありません。まだ読み込まれていない画像で `drawImage` を呼び出すと、単に何も実行されません。そのため、画像の読み込み中に最初の数フレームでゲームが正しく描画されない場合がありますが、これは深刻な問題ではありません。画面を更新し続けるため、読み込みが完了するとすぐに正しいシーンが表示されます。

前に示した歩いているキャラクターは、プレイヤーを表すために使用されます。それを描画するコードは、プレイヤーの現在の動きに基づいて正しいスプライトと方向を選択する必要があります。最初の8つのスプライトには、歩行アニメーションが含まれています。プレイヤーが床に沿って移動している場合、現在の時刻に基づいてそれらを循環します。60ミリ秒ごとにフレームを切り替えたいので、最初に時間を60で割ります。プレイヤーが静止しているときは、9番目のスプライトを描画します。垂直速度がゼロでないという事実によって認識されるジャンプ中は、10番目の右端のスプライトを使用します。

スプライトはプレイヤーオブジェクトよりわずかに幅が広いため(足と腕のためのスペースを確保するために16ピクセルではなく24ピクセル)、メソッドはx座標と幅を指定された量(`playerXOverlap`)だけ調整する必要があります。

let playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
const playerXOverlap = 4;

CanvasDisplay.prototype.drawPlayer = function(player, x, y,
                                              width, height){
  width += playerXOverlap * 2;
  x -= playerXOverlap;
  if (player.speed.x != 0) {
    this.flipPlayer = player.speed.x < 0;
  }

  let tile = 8;
  if (player.speed.y != 0) {
    tile = 9;
  } else if (player.speed.x != 0) {
    tile = Math.floor(Date.now() / 60) % 8;
  }

  this.cx.save();
  if (this.flipPlayer) {
    flipHorizontally(this.cx, x + width / 2);
  }
  let tileX = tile * width;
  this.cx.drawImage(playerSprites, tileX, 0, width, height,
                                   x,     y, width, height);
  this.cx.restore();
};

`drawPlayer` メソッドは、ゲーム内のすべてのアクターを描画する `drawActors` によって呼び出されます。

CanvasDisplay.prototype.drawActors = function(actors) {
  for (let actor of actors) {
    let width = actor.size.x * scale;
    let height = actor.size.y * scale;
    let x = (actor.pos.x - this.viewport.left) * scale;
    let y = (actor.pos.y - this.viewport.top) * scale;
    if (actor.type == "player") {
      this.drawPlayer(actor, x, y, width, height);
    } else {
      let tileX = (actor.type == "coin" ? 2 : 1) * scale;
      this.cx.drawImage(otherSprites,
                        tileX, 0, width, height,
                        x,     y, width, height);
    }
  }
};

プレイヤーではないものを描画するときは、そのタイプを見て正しいスプライトのオフセットを見つけます。溶岩タイルはオフセット20で見つかり、コインのスプライトは40(`scale` の2倍)で見つかります。

キャンバス上の (0, 0) はレベルの左上ではなくビューポートの左上に対応するため、アクターの位置を計算するときはビューポートの位置を引く必要があります。これには `translate` を使用することもできました。どちらの方法でも機能します。

このドキュメントは、新しいディスプレイを `runGame` に接続します。

<body>
  <script>
    runGame(GAME_LEVELS, CanvasDisplay);
  </script>
</body>

グラフィックインターフェースの選択

ブラウザでグラフィックを生成する必要がある場合は、プレーンHTML、SVG、キャンバスから選択できます。すべての状況で機能する単一の*最良*のアプローチはありません。各オプションには長所と短所があります。

プレーンHTMLには、シンプルであるという利点があります。また、テキストとの統合も良好です。SVGとキャンバスの両方でテキストを描画できますが、テキストの配置や、複数行にわたる場合の折り返しはできません。HTMLベースの画像では、テキストブロックを含める方がはるかに簡単です。

SVGを使用して、任意のズームレベルで見ても見栄えの良い鮮明なグラフィックを作成できます。HTMLとは異なり、描画用に設計されているため、その目的に適しています。

SVGとHTMLはどちらも、画像を表すデータ構造(DOM)を構築します。これにより、描画後に要素を変更することができます。ユーザーの操作やアニメーションの一部として、大きな画像のごく一部を繰り返し変更する必要がある場合、キャンバスで行うと不必要に費用がかかる可能性があります。DOMを使用すると、画像内のすべての要素(SVGで描画された形状でも)にマウスイベントハンドラーを登録することもできます。キャンバスではこれはできません。

しかし、canvasのピクセル指向のアプローチは、非常に多くの小さな要素を描画する場合に有利になることがあります。データ構造を構築するのではなく、同じピクセルサーフェスに繰り返し描画するという事実は、canvasの形状ごとのコストを低くします。また、シーンを1ピクセルずつレンダリングする(例えば、レイトレーサーを使用する)またはJavaScriptで画像を後処理する(ぼかしや歪みを加える)など、canvas要素でしか実用的でない効果もあります。

場合によっては、これらのテクニックをいくつか組み合わせる必要があるかもしれません。たとえば、SVGまたはcanvasでグラフを描画し、画像の上にHTML要素を配置することでテキスト情報を表示することができます。

要求の厳しいアプリケーションでない場合は、どのインターフェースを選択してもそれほど問題ありません。この章で作成したゲームの表示は、テキストの描画、マウスの操作の処理、非常に多くの要素の操作を行う必要がないため、これら3つのグラフィックステクノロジーのいずれかを使用して実装できます。

まとめ

この章では、ブラウザでグラフィックを描画するテクニックについて、<canvas>要素を中心に説明しました。

canvasノードは、プログラムが描画できるドキュメント内の領域を表します。この描画は、getContextメソッドで作成された描画コンテキストオブジェクトを介して行われます。

2D描画インターフェースを使用すると、さまざまな形状を塗りつぶしたり、線を引いたりできます。コンテキストのfillStyleプロパティは、形状の塗りつぶし方法を決定します。strokeStylelineWidthプロパティは、線の描画方法を制御します。

矩形とテキストの一部は、1回のメソッド呼び出しで描画できます。fillRectメソッドとstrokeRectメソッドは矩形を描画し、fillTextメソッドとstrokeTextメソッドはテキストを描画します。カスタム形状を作成するには、まずパスを作成する必要があります。

beginPathを呼び出すと、新しいパスが開始されます。他の多くのメソッドは、現在のパスに線と曲線を追加します。たとえば、lineToは直線を追加できます。パスが完了したら、fillメソッドで塗りつぶすか、strokeメソッドで線を引くことができます。

画像または別のcanvasからcanvasにピクセルを移動するには、drawImageメソッドを使用します。デフォルトでは、このメソッドはソース画像全体を描画しますが、パラメータを追加することで、画像の特定の領域をコピーできます。ゲームでは、多くのポーズを含む画像からゲームキャラクターの個々のポーズをコピーすることで、これを使用しました。

変換を使用すると、複数の向きで形状を描画できます。2D描画コンテキストには、translatescale、およびrotateメソッドで変更できる現在の変換があります。これらは、後続のすべての描画操作に影響します。変換の状態は、saveメソッドで保存し、restoreメソッドで復元できます。

canvasにアニメーションを表示する場合、clearRectメソッドを使用して、再描画する前にcanvasの一部をクリアできます。

演習

図形

canvasに次の図形を描画するプログラムを作成してください。

  1. 台形(片側が広い矩形)

  2. 赤い菱形(45度または¼πラジアン回転した矩形)

  3. ジグザグの線

  4. 100本の直線で構成される螺旋

  5. 黄色の星

Picture showing the shapes you are asked to draw

最後の2つの図形を描画する際には、第14章Math.cosMath.sinの説明を参照することをお勧めします。これらの関数を使用して円上の座標を取得する方法について説明しています。

図形ごとに関数を作成することをお勧めします。位置、および必要に応じてサイズや点の数などの他のプロパティをパラメータとして渡します。代わりに、コード全体に数値をハードコーディングすると、コードが不必要に読みにくく、変更しにくくなる傾向があります。

<canvas width="600" height="200"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");

  // Your code here.
</script>
表示ヒント...

台形(1)は、パスを使用して描画するのが最も簡単です。適切な中心座標を選択し、中心の周りに4つの角を追加します。

菱形(2)は、パスを使用して直接描画することも、rotate変換を使用して興味深い方法で描画することもできます。回転を使用するには、flipHorizontally関数で行ったのと同様のトリックを適用する必要があります。矩形の点(0、0)ではなく中心を中心に回転させる必要があるため、まずそこにtranslateし、次に回転し、次に元に戻す必要があります。

変換を作成する図形を描画した後、変換をリセットしてください。

ジグザグ(3)の場合、線分ごとにlineToを新たに呼び出すのは実用的ではありません。代わりに、ループを使用する必要があります。各反復で2つの線分(右に移動してから左に戻る)または1つの線分を描画できます。後者の場合、ループインデックスの偶数性(% 2)を使用して、左に移動するか右に移動するかを決定する必要があります。

螺旋(4)にもループが必要です。螺旋の中心を中心とする円に沿って移動する一連の点を描画すると、円が得られます。ループ中に、現在の点を配置している円の半径を変更し、複数回回転すると、螺旋になります。

示されている星(5)は、quadraticCurveTo線で構成されています。直線で描画することもできます。8つの点を持つ星の場合は円を8つの部分に分割します。これらの点の間に線を描き、星の의 중심に向かって曲線にします。quadraticCurveToでは、中心を制御点として使用できます。

円グラフ

本章のに、円グラフを描画するサンプルプログラムを見ました。各カテゴリの名前が、それを表すスライスの横に表示されるように、このプログラムを変更します。他のデータセットでも機能する、このテキストを自動的に配置するための見栄えの良い方法を見つけてください。カテゴリは、ラベルのための十分なスペースを残すのに十分な大きさであると想定できます。

14章で説明されているMath.sinMath.cosが再び必要になる場合があります。

<canvas width="600" height="300"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");
  let total = results
    .reduce((sum, {count}) => sum + count, 0);
  let currentAngle = -0.5 * Math.PI;
  let centerX = 300, centerY = 150;

  // Add code to draw the slice labels in this loop.
  for (let result of results) {
    let sliceAngle = (result.count / total) * 2 * Math.PI;
    cx.beginPath();
    cx.arc(centerX, centerY, 100,
           currentAngle, currentAngle + sliceAngle);
    currentAngle += sliceAngle;
    cx.lineTo(centerX, centerY);
    cx.fillStyle = result.color;
    cx.fill();
  }
</script>
表示ヒント...

fillTextを呼び出し、コンテキストのtextAlignプロパティとtextBaselineプロパティを、テキストが目的の場所に配置されるように設定する必要があります。

ラベルを配置する賢明な方法は、円の中心からスライスの真ん中を通る線上にテキストを配置することです。テキストを円グラフの側面に直接配置するのではなく、指定されたピクセル数だけテキストを円グラフの側面に移動します。

この線の角度は、currentAngle + 0.5 * sliceAngleです。次のコードは、中心から120ピクセル離れたこの線上の位置を見つけます。

let middleAngle = currentAngle + 0.5 * sliceAngle;
let textX = Math.cos(middleAngle) * 120 + centerX;
let textY = Math.sin(middleAngle) * 120 + centerY;

textBaselineの場合、このアプローチを使用するときは、値"middle"が適切です。textAlignに何を使用するかは、円のどちら側にいるかによって異なります。左側では"right"、右側では"left"にする必要があります。これにより、テキストは円グラフから離れた位置に配置されます。

特定の角度が円のどちら側にあるかを確認する方法がわからない場合は、14章Math.cosの説明を参照してください。角度の余弦は、対応するx座標を示します。これは、円のどちら側にいるかを正確に示します。

跳ねるボール

14章16章で説明したrequestAnimationFrameテクニックを使用して、跳ねるボールが入ったボックスを描画します。ボールは一定の速度で移動し、側面に当たると跳ね返ります。

<canvas width="400" height="400"></canvas>
<script>
  let cx = document.querySelector("canvas").getContext("2d");

  let lastTime = null;
  function frame(time) {
    if (lastTime != null) {
      updateAnimation(Math.min(100, time - lastTime) / 1000);
    }
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  function updateAnimation(step) {
    // Your code here.
  }
</script>
表示ヒント...

ボックスは、strokeRectを使用して簡単に描画できます。そのサイズを保持するバインディングを定義するか、ボックスの幅と高さが異なる場合は2つのバインディングを定義します。丸いボールを作成するには、パスを開始し、arc(x, y, radius, 0, 7)を呼び出します。これは、ゼロから全円よりも大きい円弧を作成します。次に、パスを塗りつぶします。

ボールの位置と速度をモデル化するには、16章Vecクラス(このページで入手可能)を使用できます。開始速度を指定します。できれば、純粋に垂直または水平ではない速度を指定します。フレームごとに、経過した時間だけその速度を乗算します。ボールが垂直壁に近づきすぎると、速度の* x *成分が反転します。同様に、水平壁に当たると、* y *成分が反転します。

ボールの新しい位置と速度を見つけた後、clearRectを使用してシーンを削除し、新しい位置を使用して再描画します。

事前計算されたミラーリング

変換に関する1つの残念な点は、ビットマップの描画速度が低下することです。各ピクセルの位置とサイズを変換する必要があり、ブラウザが将来変換についてより賢くなる可能性はありますが、現在、ビットマップの描画にかかる時間が大幅に増加しています。

私たちのような、変換されたスプライトを1つだけ描画するゲームでは、これは問題ではありません。しかし、数百のキャラクターや、爆発による数千の回転するパーティクルを描画する必要があると想像してみてください。

追加の画像ファイルを読み込まず、フレームごとに`drawImage`で変換を行うことなく、反転したキャラクターを描画する方法を考えてみてください。

表示ヒント...

解決策の鍵は、`drawImage`を使用する際に、canvas要素をソース画像として使用できるという事実です。ドキュメントに追加することなく、追加の`<canvas>`要素を作成し、反転したスプライトを一度だけ描画することができます。実際のフレームを描画する際には、既に反転されたスプライトをメインキャンバスにコピーするだけです。

画像は瞬時に読み込まれないため、注意が必要です。反転描画は一度だけ行い、画像が読み込まれる前に行うと、何も描画されません。画像の`"load"`ハンドラを使用して、反転した画像を追加のキャンバスに描画できます。このキャンバスは、すぐに描画ソースとして使用できます(キャラクターが描画されるまでは空白になります)。